Refinamiento de permisos y ajustes en controles. Añade gestión sobre saldos y visualización. Entre otros..
This commit is contained in:
		| @@ -0,0 +1,109 @@ | |||||||
|  | using GestionIntegral.Api.Dtos.Contables; | ||||||
|  | using GestionIntegral.Api.Services.Contables; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Security.Claims; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Controllers.Contables | ||||||
|  | { | ||||||
|  |     [Route("api/saldos")] | ||||||
|  |     [ApiController] | ||||||
|  |     [Authorize] // Requiere autenticación para todos los endpoints | ||||||
|  |     public class SaldosController : ControllerBase | ||||||
|  |     { | ||||||
|  |         private readonly ISaldoService _saldoService; | ||||||
|  |         private readonly ILogger<SaldosController> _logger; | ||||||
|  |  | ||||||
|  |         // Define un permiso específico para ver saldos, y otro para ajustarlos (SuperAdmin implícito) | ||||||
|  |         private const string PermisoVerSaldos = "CS001"; // Ejemplo: Cuentas Saldos Ver | ||||||
|  |         private const string PermisoAjustarSaldos = "CS002"; // Ejemplo: Cuentas Saldos Ajustar (o solo SuperAdmin) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         public SaldosController(ISaldoService saldoService, ILogger<SaldosController> logger) | ||||||
|  |         { | ||||||
|  |             _saldoService = saldoService; | ||||||
|  |             _logger = logger; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private bool TienePermiso(string codAccRequerido) | ||||||
|  |         { | ||||||
|  |             if (User.IsInRole("SuperAdmin")) return true; | ||||||
|  |             return User.HasClaim(c => c.Type == "permission" && c.Value == codAccRequerido); | ||||||
|  |         } | ||||||
|  |         private int? GetCurrentUserId() | ||||||
|  |         { | ||||||
|  |             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||||
|  |             _logger.LogWarning("No se pudo obtener el UserId del token JWT en SaldosController."); | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // GET: api/saldos | ||||||
|  |         [HttpGet] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<SaldoGestionDto>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         public async Task<IActionResult> GetSaldosGestion( | ||||||
|  |             [FromQuery] string? destino,  | ||||||
|  |             [FromQuery] int? idDestino,  | ||||||
|  |             [FromQuery] int? idEmpresa) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso(PermisoVerSaldos)) // Usar el nuevo permiso | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Acceso denegado a GetSaldosGestion para Usuario ID {userId}", GetCurrentUserId() ?? 0); | ||||||
|  |                 return Forbid(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var saldos = await _saldoService.ObtenerSaldosParaGestionAsync(destino, idDestino, idEmpresa); | ||||||
|  |                 return Ok(saldos); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener saldos para gestión."); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al obtener saldos."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // POST: api/saldos/ajustar | ||||||
|  |         [HttpPost("ajustar")] | ||||||
|  |         [ProducesResponseType(typeof(SaldoGestionDto), StatusCodes.Status200OK)] // Devuelve el saldo actualizado | ||||||
|  |         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] // Solo SuperAdmin o con permiso específico | ||||||
|  |         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  |         public async Task<IActionResult> AjustarSaldoManualmente([FromBody] AjusteSaldoRequestDto ajusteDto) | ||||||
|  |         { | ||||||
|  |             // Esta operación debería ser MUY restringida. Solo SuperAdmin o un permiso muy específico. | ||||||
|  |             if (!User.IsInRole("SuperAdmin") && !TienePermiso(PermisoAjustarSaldos)) | ||||||
|  |             { | ||||||
|  |                  _logger.LogWarning("Intento no autorizado de ajustar saldo por Usuario ID {userId}", GetCurrentUserId() ?? 0); | ||||||
|  |                 return Forbid("No tiene permisos para realizar ajustes manuales de saldo."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||||
|  |  | ||||||
|  |             var idUsuario = GetCurrentUserId(); | ||||||
|  |             if (idUsuario == null) return Unauthorized("No se pudo identificar al usuario."); | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var (exito, error, saldoActualizado) = await _saldoService.RealizarAjusteManualSaldoAsync(ajusteDto, idUsuario.Value); | ||||||
|  |                 if (!exito) | ||||||
|  |                 { | ||||||
|  |                     if (error != null && error.Contains("No se encontró un saldo existente")) | ||||||
|  |                         return NotFound(new { message = error }); | ||||||
|  |                     return BadRequest(new { message = error ?? "Error desconocido al ajustar el saldo." }); | ||||||
|  |                 } | ||||||
|  |                 return Ok(saldoActualizado); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error crítico al ajustar saldo manualmente."); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al procesar el ajuste de saldo."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -47,11 +47,11 @@ namespace GestionIntegral.Api.Controllers.Distribucion | |||||||
|         [HttpGet] |         [HttpGet] | ||||||
|         [ProducesResponseType(typeof(IEnumerable<CanillaDto>), StatusCodes.Status200OK)] |         [ProducesResponseType(typeof(IEnumerable<CanillaDto>), StatusCodes.Status200OK)] | ||||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|         public async Task<IActionResult> GetAllCanillas([FromQuery] string? nomApe, [FromQuery] int? legajo, [FromQuery] bool? soloActivos = true) |         public async Task<IActionResult> GetAllCanillas([FromQuery] string? nomApe, [FromQuery] int? legajo, [FromQuery] bool? esAccionista, [FromQuery] bool? soloActivos = true) | ||||||
|         { |         { | ||||||
|             if (!TienePermiso(PermisoVer)) return Forbid(); |             if (!TienePermiso(PermisoVer)) return Forbid(); | ||||||
|             var canillas = await _canillaService.ObtenerTodosAsync(nomApe, legajo, soloActivos); |             var canillitas = await _canillaService.ObtenerTodosAsync(nomApe, legajo, soloActivos, esAccionista); // <<-- Pasa el parámetro | ||||||
|             return Ok(canillas); |             return Ok(canillitas); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // GET: api/canillas/{id} |         // GET: api/canillas/{id} | ||||||
|   | |||||||
| @@ -47,6 +47,15 @@ namespace GestionIntegral.Api.Controllers.Distribucion | |||||||
|             return Ok(distribuidores); |             return Ok(distribuidores); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         [HttpGet("dropdown")] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<DistribuidorDropdownDto>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         public async Task<IActionResult> GetAllDropdownDistribuidores() | ||||||
|  |         { | ||||||
|  |             var distribuidores = await _distribuidorService.GetAllDropdownAsync(); | ||||||
|  |             return Ok(distribuidores); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         [HttpGet("{id:int}", Name = "GetDistribuidorById")] |         [HttpGet("{id:int}", Name = "GetDistribuidorById")] | ||||||
|         [ProducesResponseType(typeof(DistribuidorDto), StatusCodes.Status200OK)] |         [ProducesResponseType(typeof(DistribuidorDto), StatusCodes.Status200OK)] | ||||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
| @@ -59,6 +68,17 @@ namespace GestionIntegral.Api.Controllers.Distribucion | |||||||
|             return Ok(distribuidor); |             return Ok(distribuidor); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         [HttpGet("{id:int}/lookup", Name = "GetDistribuidorLookupById")] | ||||||
|  |         [ProducesResponseType(typeof(DistribuidorLookupDto), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  |         public async Task<IActionResult> ObtenerLookupPorIdAsync(int id) | ||||||
|  |         { | ||||||
|  |             var distribuidor = await _distribuidorService.ObtenerLookupPorIdAsync(id); | ||||||
|  |             if (distribuidor == null) return NotFound(); | ||||||
|  |             return Ok(distribuidor); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         [HttpPost] |         [HttpPost] | ||||||
|         [ProducesResponseType(typeof(DistribuidorDto), StatusCodes.Status201Created)] |         [ProducesResponseType(typeof(DistribuidorDto), StatusCodes.Status201Created)] | ||||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] |         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|   | |||||||
| @@ -74,6 +74,25 @@ namespace GestionIntegral.Api.Controllers // Ajusta el namespace si es necesario | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // GET: api/empresas/dropdown | ||||||
|  |         [HttpGet("dropdown")] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<EmpresaDropdownDto>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> GetEmpresasDropdown() | ||||||
|  |         { | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var empresas = await _empresaService.ObtenerParaDropdown(); | ||||||
|  |                 return Ok(empresas); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener todas las Empresas."); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al obtener las empresas."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // GET: api/empresas/{id} |         // GET: api/empresas/{id} | ||||||
|         // Permiso Requerido: DE001 (Ver Empresas) |         // Permiso Requerido: DE001 (Ver Empresas) | ||||||
|         [HttpGet("{id:int}", Name = "GetEmpresaById")] |         [HttpGet("{id:int}", Name = "GetEmpresaById")] | ||||||
| @@ -101,6 +120,29 @@ namespace GestionIntegral.Api.Controllers // Ajusta el namespace si es necesario | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         [HttpGet("{id:int}/lookup", Name = "GetEmpresaLookupById")] | ||||||
|  |         [ProducesResponseType(typeof(EmpresaDto), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> ObtenerLookupPorIdAsync(int id) | ||||||
|  |         { | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var empresa = await _empresaService.ObtenerLookupPorIdAsync(id); | ||||||
|  |                 if (empresa == null) | ||||||
|  |                 { | ||||||
|  |                     return NotFound(new { message = $"Empresa con ID {id} no encontrada." }); | ||||||
|  |                 } | ||||||
|  |                 return Ok(empresa); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener Empresa por ID: {Id}", id); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al obtener la empresa."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // POST: api/empresas |         // POST: api/empresas | ||||||
|         // Permiso Requerido: DE002 (Agregar Empresas) |         // Permiso Requerido: DE002 (Agregar Empresas) | ||||||
|         [HttpPost] |         [HttpPost] | ||||||
|   | |||||||
| @@ -0,0 +1,212 @@ | |||||||
|  | using GestionIntegral.Api.Dtos.Distribucion; | ||||||
|  | using GestionIntegral.Api.Services.Distribucion; | ||||||
|  | using Microsoft.AspNetCore.Authorization; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Security.Claims; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Controllers.Distribucion | ||||||
|  | { | ||||||
|  |     [Route("api/novedadescanilla")] // Ruta base más genérica para las novedades | ||||||
|  |     [ApiController] | ||||||
|  |     [Authorize] // Todas las acciones requieren autenticación | ||||||
|  |     public class NovedadesCanillaController : ControllerBase | ||||||
|  |     { | ||||||
|  |         private readonly INovedadCanillaService _novedadService; | ||||||
|  |         private readonly ILogger<NovedadesCanillaController> _logger; | ||||||
|  |  | ||||||
|  |         public NovedadesCanillaController(INovedadCanillaService novedadService, ILogger<NovedadesCanillaController> logger) | ||||||
|  |         { | ||||||
|  |             _novedadService = novedadService; | ||||||
|  |             _logger = logger; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // --- Helper para verificar permisos --- | ||||||
|  |         private bool TienePermiso(string codAccRequerido) | ||||||
|  |         { | ||||||
|  |             if (User.IsInRole("SuperAdmin")) return true; | ||||||
|  |             return User.HasClaim(c => c.Type == "permission" && c.Value == codAccRequerido); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // --- Helper para obtener User ID --- | ||||||
|  |         private int? GetCurrentUserId() | ||||||
|  |         { | ||||||
|  |             var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); | ||||||
|  |             if (int.TryParse(userIdClaim, out int userId)) | ||||||
|  |             { | ||||||
|  |                 return userId; | ||||||
|  |             } | ||||||
|  |             _logger.LogWarning("No se pudo obtener el UserId del token JWT en NovedadesCanillaController."); | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // GET: api/novedadescanilla/porcanilla/{idCanilla} | ||||||
|  |         // Obtiene todas las novedades para un canillita específico, opcionalmente filtrado por fecha. | ||||||
|  |         // Permiso: CG001 (Ver Canillas) o CG006 (Gestionar Novedades). | ||||||
|  |         // Si CG006 es "Permite la Carga/Modificación", entonces CG001 podría ser más apropiado solo para ver. | ||||||
|  |         // Vamos a usar CG001 para ver. Si se quiere más granularidad, se puede crear un permiso "Ver Novedades". | ||||||
|  |         [HttpGet("porcanilla/{idCanilla:int}")] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<NovedadCanillaDto>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> GetNovedadesPorCanilla(int idCanilla, [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso("CG001") && !TienePermiso("CG006")) // Necesita al menos uno de los dos | ||||||
|  |             { | ||||||
|  |                  _logger.LogWarning("Acceso denegado a GetNovedadesPorCanilla para el usuario {UserId} y canillita {IdCanilla}", GetCurrentUserId() ?? 0, idCanilla); | ||||||
|  |                 return Forbid(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var novedades = await _novedadService.ObtenerPorCanillaAsync(idCanilla, fechaDesde, fechaHasta); | ||||||
|  |                 return Ok(novedades); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener novedades para Canillita ID: {IdCanilla}", idCanilla); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al obtener las novedades."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // GET: api/novedadescanilla/{idNovedad} | ||||||
|  |         // Obtiene una novedad específica por su ID. | ||||||
|  |         // Permiso: CG001 o CG006 | ||||||
|  |         [HttpGet("{idNovedad:int}", Name = "GetNovedadCanillaById")] | ||||||
|  |         [ProducesResponseType(typeof(NovedadCanillaDto), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> GetNovedadCanillaById(int idNovedad) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso("CG001") && !TienePermiso("CG006")) return Forbid(); | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var novedad = await _novedadService.ObtenerPorIdAsync(idNovedad); | ||||||
|  |                 if (novedad == null) | ||||||
|  |                 { | ||||||
|  |                     return NotFound(new { message = $"Novedad con ID {idNovedad} no encontrada." }); | ||||||
|  |                 } | ||||||
|  |                 return Ok(novedad); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener NovedadCanilla por ID: {IdNovedad}", idNovedad); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al obtener la novedad."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         // POST: api/novedadescanilla | ||||||
|  |         // Crea una nueva novedad. El IdCanilla viene en el DTO. | ||||||
|  |         // Permiso: CG006 (Permite la Carga/Modificación de Novedades) | ||||||
|  |         [HttpPost] | ||||||
|  |         [ProducesResponseType(typeof(NovedadCanillaDto), StatusCodes.Status201Created)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status401Unauthorized)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> CreateNovedadCanilla([FromBody] CreateNovedadCanillaDto createDto) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso("CG006")) return Forbid(); | ||||||
|  |  | ||||||
|  |             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||||
|  |  | ||||||
|  |             var idUsuario = GetCurrentUserId(); | ||||||
|  |             if (idUsuario == null) return Unauthorized("No se pudo obtener el ID del usuario del token."); | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var (novedadCreada, error) = await _novedadService.CrearAsync(createDto, idUsuario.Value); | ||||||
|  |  | ||||||
|  |                 if (error != null) return BadRequest(new { message = error }); | ||||||
|  |                 if (novedadCreada == null) return StatusCode(StatusCodes.Status500InternalServerError, "Error al crear la novedad."); | ||||||
|  |                  | ||||||
|  |                 // Devuelve la ruta al recurso creado y el recurso mismo | ||||||
|  |                 return CreatedAtRoute("GetNovedadCanillaById", new { idNovedad = novedadCreada.IdNovedad }, novedadCreada); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al crear NovedadCanilla para Canilla ID: {IdCanilla} por Usuario ID: {UsuarioId}", createDto.IdCanilla, idUsuario); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al crear la novedad."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // PUT: api/novedadescanilla/{idNovedad} | ||||||
|  |         // Actualiza una novedad existente. | ||||||
|  |         // Permiso: CG006 | ||||||
|  |         [HttpPut("{idNovedad:int}")] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status401Unauthorized)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> UpdateNovedadCanilla(int idNovedad, [FromBody] UpdateNovedadCanillaDto updateDto) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso("CG006")) return Forbid(); | ||||||
|  |  | ||||||
|  |             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||||
|  |  | ||||||
|  |             var idUsuario = GetCurrentUserId(); | ||||||
|  |             if (idUsuario == null) return Unauthorized("No se pudo obtener el ID del usuario del token."); | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var (exito, error) = await _novedadService.ActualizarAsync(idNovedad, updateDto, idUsuario.Value); | ||||||
|  |  | ||||||
|  |                 if (!exito) | ||||||
|  |                 { | ||||||
|  |                     if (error == "Novedad no encontrada.") return NotFound(new { message = error }); | ||||||
|  |                     return BadRequest(new { message = error }); | ||||||
|  |                 } | ||||||
|  |                 return NoContent(); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al actualizar NovedadCanilla ID: {IdNovedad} por Usuario ID: {UsuarioId}", idNovedad, idUsuario); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al actualizar la novedad."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // DELETE: api/novedadescanilla/{idNovedad} | ||||||
|  |         // Elimina una novedad. | ||||||
|  |         // Permiso: CG006 (Asumiendo que el mismo permiso para Carga/Modificación incluye eliminación) | ||||||
|  |         // Si la eliminación es un permiso separado (ej: CG00X), ajústalo. | ||||||
|  |         [HttpDelete("{idNovedad:int}")] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status401Unauthorized)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> DeleteNovedadCanilla(int idNovedad) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso("CG006")) return Forbid(); | ||||||
|  |  | ||||||
|  |             var idUsuario = GetCurrentUserId(); | ||||||
|  |             if (idUsuario == null) return Unauthorized("No se pudo obtener el ID del usuario del token."); | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var (exito, error) = await _novedadService.EliminarAsync(idNovedad, idUsuario.Value); | ||||||
|  |  | ||||||
|  |                 if (!exito) | ||||||
|  |                 { | ||||||
|  |                     if (error == "Novedad no encontrada.") return NotFound(new { message = error }); | ||||||
|  |                     return BadRequest(new { message = error }); // Podría ser otro error, como "no se pudo eliminar" | ||||||
|  |                 } | ||||||
|  |                 return NoContent(); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al eliminar NovedadCanilla ID: {IdNovedad} por Usuario ID: {UsuarioId}", idNovedad, idUsuario); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al eliminar la novedad."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -11,6 +11,7 @@ using GestionIntegral.Api.Data.Repositories.Impresion; | |||||||
| using System.IO; | using System.IO; | ||||||
| using System.Linq; | using System.Linq; | ||||||
| using GestionIntegral.Api.Data.Repositories.Distribucion; | using GestionIntegral.Api.Data.Repositories.Distribucion; | ||||||
|  | using GestionIntegral.Api.Services.Distribucion; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Controllers | namespace GestionIntegral.Api.Controllers | ||||||
| { | { | ||||||
| @@ -25,6 +26,7 @@ namespace GestionIntegral.Api.Controllers | |||||||
|         private readonly IPublicacionRepository _publicacionRepository; |         private readonly IPublicacionRepository _publicacionRepository; | ||||||
|         private readonly IEmpresaRepository _empresaRepository; |         private readonly IEmpresaRepository _empresaRepository; | ||||||
|         private readonly IDistribuidorRepository _distribuidorRepository; // Para obtener el nombre del distribuidor |         private readonly IDistribuidorRepository _distribuidorRepository; // Para obtener el nombre del distribuidor | ||||||
|  |         private readonly INovedadCanillaService _novedadCanillaService; | ||||||
|  |  | ||||||
|  |  | ||||||
|         // Permisos |         // Permisos | ||||||
| @@ -36,22 +38,25 @@ namespace GestionIntegral.Api.Controllers | |||||||
|         private const string PermisoVerBalanceCuentas = "RR001"; |         private const string PermisoVerBalanceCuentas = "RR001"; | ||||||
|         private const string PermisoVerReporteTiradas = "RR008"; |         private const string PermisoVerReporteTiradas = "RR008"; | ||||||
|         private const string PermisoVerReporteConsumoBobinas = "RR007"; |         private const string PermisoVerReporteConsumoBobinas = "RR007"; | ||||||
|  |         private const string PermisoVerReporteNovedadesCanillas = "RR004"; | ||||||
|  |         private const string PermisoVerReporteListadoDistMensual = "RR009"; | ||||||
|  |  | ||||||
|         public ReportesController( |         public ReportesController( | ||||||
|             IReportesService reportesService, // <--- CORREGIDO |             IReportesService reportesService, | ||||||
|  |             INovedadCanillaService novedadCanillaService, | ||||||
|             ILogger<ReportesController> logger, |             ILogger<ReportesController> logger, | ||||||
|             IPlantaRepository plantaRepository, |             IPlantaRepository plantaRepository, | ||||||
|             IPublicacionRepository publicacionRepository, |             IPublicacionRepository publicacionRepository, | ||||||
|             IEmpresaRepository empresaRepository, |             IEmpresaRepository empresaRepository, | ||||||
|             IDistribuidorRepository distribuidorRepository) // Añadido |             IDistribuidorRepository distribuidorRepository) | ||||||
|         { |         { | ||||||
|             _reportesService = reportesService; // <--- CORREGIDO |             _reportesService = reportesService; | ||||||
|  |             _novedadCanillaService = novedadCanillaService; | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|             _plantaRepository = plantaRepository; |             _plantaRepository = plantaRepository; | ||||||
|             _publicacionRepository = publicacionRepository; |             _publicacionRepository = publicacionRepository; | ||||||
|             _empresaRepository = empresaRepository; |             _empresaRepository = empresaRepository; | ||||||
|             _distribuidorRepository = distribuidorRepository; // Añadido |             _distribuidorRepository = distribuidorRepository; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); |         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||||
| @@ -1457,5 +1462,277 @@ namespace GestionIntegral.Api.Controllers | |||||||
|                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al generar el PDF del ticket."); |                 return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al generar el PDF del ticket."); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // GET: api/reportes/novedades-canillas | ||||||
|  |         // Obtiene los datos para el reporte de novedades de canillitas | ||||||
|  |         [HttpGet("novedades-canillas")] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<NovedadesCanillasReporteDto>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status404NotFound)] // Si no hay datos | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> GetReporteNovedadesCanillasData( | ||||||
|  |             [FromQuery] int idEmpresa, | ||||||
|  |             [FromQuery] DateTime fechaDesde, | ||||||
|  |             [FromQuery] DateTime fechaHasta) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso(PermisoVerReporteNovedadesCanillas)) | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Acceso denegado a GetReporteNovedadesCanillasData. Usuario: {User}", User.Identity?.Name ?? "Desconocido"); | ||||||
|  |                 return Forbid(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (fechaDesde > fechaHasta) | ||||||
|  |             { | ||||||
|  |                 return BadRequest(new { message = "La fecha 'desde' no puede ser posterior a la fecha 'hasta'." }); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var reporteData = await _novedadCanillaService.ObtenerReporteNovedadesAsync(idEmpresa, fechaDesde, fechaHasta); | ||||||
|  |                 if (reporteData == null || !reporteData.Any()) | ||||||
|  |                 { | ||||||
|  |                     // Devolver Ok con array vacío en lugar de NotFound para que el frontend pueda manejarlo como "sin datos" | ||||||
|  |                     return Ok(Enumerable.Empty<NovedadesCanillasReporteDto>()); | ||||||
|  |                 } | ||||||
|  |                 return Ok(reporteData); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al generar datos para el reporte de novedades de canillitas. Empresa: {IdEmpresa}, Desde: {FechaDesde}, Hasta: {FechaHasta}", idEmpresa, fechaDesde, fechaHasta); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, new { message = "Error interno al generar el reporte de novedades." }); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // GET: api/reportes/novedades-canillas/pdf | ||||||
|  |         // Genera el PDF del reporte de novedades de canillitas | ||||||
|  |         [HttpGet("novedades-canillas/pdf")] | ||||||
|  |         [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|  |         public async Task<IActionResult> GetReporteNovedadesCanillasPdf( | ||||||
|  |             [FromQuery] int idEmpresa, | ||||||
|  |         [FromQuery] DateTime fechaDesde, | ||||||
|  |         [FromQuery] DateTime fechaHasta) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso(PermisoVerReporteNovedadesCanillas)) // RR004 | ||||||
|  |             { | ||||||
|  |                 _logger.LogWarning("Acceso denegado a GetReporteNovedadesCanillasPdf. Usuario: {User}", User.Identity?.Name ?? "Desconocido"); | ||||||
|  |                 return Forbid(); | ||||||
|  |             } | ||||||
|  |             if (fechaDesde > fechaHasta) | ||||||
|  |             { | ||||||
|  |                 return BadRequest(new { message = "La fecha 'desde' no puede ser posterior a la fecha 'hasta'." }); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 // Obtener datos para AMBOS datasets | ||||||
|  |                 var novedadesData = await _novedadCanillaService.ObtenerReporteNovedadesAsync(idEmpresa, fechaDesde, fechaHasta); | ||||||
|  |                 var gananciasData = await _novedadCanillaService.ObtenerReporteGananciasAsync(idEmpresa, fechaDesde, fechaHasta); // << OBTENER DATOS DE GANANCIAS | ||||||
|  |  | ||||||
|  |                 // Verificar si hay datos en *alguno* de los datasets necesarios para el reporte | ||||||
|  |                 if ((novedadesData == null || !novedadesData.Any()) && (gananciasData == null || !gananciasData.Any())) | ||||||
|  |                 { | ||||||
|  |                     return NotFound(new { message = "No hay datos para generar el PDF con los parámetros seleccionados." }); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var empresa = await _empresaRepository.GetByIdAsync(idEmpresa); | ||||||
|  |  | ||||||
|  |                 LocalReport report = new LocalReport(); | ||||||
|  |                 string rdlcPath = Path.Combine("Controllers", "Reportes", "RDLC", "ReporteListadoNovedadesCanillas.rdlc"); | ||||||
|  |                 if (!System.IO.File.Exists(rdlcPath)) | ||||||
|  |                 { | ||||||
|  |                     _logger.LogError("Archivo RDLC no encontrado en la ruta: {RdlcPath}", rdlcPath); | ||||||
|  |                     return StatusCode(StatusCodes.Status500InternalServerError, "Archivo de definición de reporte no encontrado."); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 using (var fs = new FileStream(rdlcPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) | ||||||
|  |                 { | ||||||
|  |                     report.LoadReportDefinition(fs); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Nombre del DataSet en RDLC para SP_DistCanillasNovedades (detalles) | ||||||
|  |                 report.DataSources.Add(new ReportDataSource("DSNovedadesCanillasDetalles", novedadesData ?? new List<NovedadesCanillasReporteDto>())); | ||||||
|  |  | ||||||
|  |                 // Nombre del DataSet en RDLC para SP_DistCanillasGanancias (ganancias/resumen) | ||||||
|  |                 report.DataSources.Add(new ReportDataSource("DSNovedadesCanillas", gananciasData ?? new List<CanillaGananciaReporteDto>())); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |                 var parameters = new List<ReportParameter> | ||||||
|  |             { | ||||||
|  |                 new ReportParameter("NomEmp", empresa?.Nombre ?? "N/A"), | ||||||
|  |                 new ReportParameter("FechaDesde", fechaDesde.ToString("dd/MM/yyyy")), | ||||||
|  |                 new ReportParameter("FechaHasta", fechaHasta.ToString("dd/MM/yyyy")) | ||||||
|  |             }; | ||||||
|  |                 report.SetParameters(parameters); | ||||||
|  |  | ||||||
|  |                 byte[] pdfBytes = report.Render("PDF"); | ||||||
|  |                 string fileName = $"ReporteNovedadesCanillas_Emp{idEmpresa}_{fechaDesde:yyyyMMdd}_{fechaHasta:yyyyMMdd}.pdf"; | ||||||
|  |                 return File(pdfBytes, "application/pdf", fileName); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al generar PDF para el reporte de novedades de canillitas. Empresa: {IdEmpresa}", idEmpresa); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, new { message = $"Error interno al generar el PDF: {ex.Message}" }); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         // GET: api/reportes/novedades-canillas-ganancias | ||||||
|  |         [HttpGet("novedades-canillas-ganancias")] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<CanillaGananciaReporteDto>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  |         public async Task<IActionResult> GetReporteGananciasCanillasData( | ||||||
|  |             [FromQuery] int idEmpresa, | ||||||
|  |             [FromQuery] DateTime fechaDesde, | ||||||
|  |             [FromQuery] DateTime fechaHasta) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso(PermisoVerReporteNovedadesCanillas)) return Forbid(); // RR004 | ||||||
|  |  | ||||||
|  |             if (fechaDesde > fechaHasta) | ||||||
|  |             { | ||||||
|  |                 return BadRequest(new { message = "La fecha 'desde' no puede ser posterior a la fecha 'hasta'." }); | ||||||
|  |             } | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var gananciasData = await _novedadCanillaService.ObtenerReporteGananciasAsync(idEmpresa, fechaDesde, fechaHasta); | ||||||
|  |                 if (gananciasData == null || !gananciasData.Any()) | ||||||
|  |                 { | ||||||
|  |                     return Ok(Enumerable.Empty<CanillaGananciaReporteDto>()); | ||||||
|  |                 } | ||||||
|  |                 return Ok(gananciasData); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener datos de ganancias para el reporte de novedades. Empresa: {IdEmpresa}", idEmpresa); | ||||||
|  |                 return StatusCode(StatusCodes.Status500InternalServerError, new { message = "Error interno al obtener datos de ganancias." }); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // GET: api/reportes/listado-distribucion-mensual/diarios | ||||||
|  |         [HttpGet("listado-distribucion-mensual/diarios")] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<ListadoDistCanMensualDiariosDto>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         public async Task<IActionResult> GetListadoDistMensualDiarios( | ||||||
|  |             [FromQuery] DateTime fechaDesde, | ||||||
|  |             [FromQuery] DateTime fechaHasta, | ||||||
|  |             [FromQuery] bool esAccionista) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso(PermisoVerReporteListadoDistMensual)) return Forbid(); | ||||||
|  |             if (fechaDesde > fechaHasta) return BadRequest(new { message = "Fecha Desde no puede ser mayor a Fecha Hasta." }); | ||||||
|  |  | ||||||
|  |             var (data, error) = await _reportesService.ObtenerReporteMensualDiariosAsync(fechaDesde, fechaHasta, esAccionista); | ||||||
|  |             if (error != null) return BadRequest(new { message = error }); | ||||||
|  |             return Ok(data ?? Enumerable.Empty<ListadoDistCanMensualDiariosDto>()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         [HttpGet("listado-distribucion-mensual/diarios/pdf")] | ||||||
|  |         public async Task<IActionResult> GetListadoDistMensualDiariosPdf( | ||||||
|  |             [FromQuery] DateTime fechaDesde, | ||||||
|  |             [FromQuery] DateTime fechaHasta, | ||||||
|  |             [FromQuery] bool esAccionista) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso(PermisoVerReporteListadoDistMensual)) return Forbid(); | ||||||
|  |             if (fechaDesde > fechaHasta) return BadRequest(new { message = "Fecha Desde no puede ser mayor a Fecha Hasta." }); | ||||||
|  |  | ||||||
|  |             var (data, error) = await _reportesService.ObtenerReporteMensualDiariosAsync(fechaDesde, fechaHasta, esAccionista); | ||||||
|  |             if (error != null) return BadRequest(new { message = error }); | ||||||
|  |             if (data == null || !data.Any()) return NotFound(new { message = "No hay datos para generar el PDF." }); | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 LocalReport report = new LocalReport(); | ||||||
|  |                 string rdlcPath = Path.Combine("Controllers", "Reportes", "RDLC", "ReporteListadoDistribucionCanMensualDiarios.rdlc"); | ||||||
|  |                 if (!System.IO.File.Exists(rdlcPath)) | ||||||
|  |                 { | ||||||
|  |                     _logger.LogError("Archivo RDLC no encontrado: {Path}", rdlcPath); | ||||||
|  |                     return StatusCode(StatusCodes.Status500InternalServerError, $"Archivo de reporte no encontrado: {Path.GetFileName(rdlcPath)}"); | ||||||
|  |                 } | ||||||
|  |                 using (var fs = new FileStream(rdlcPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) | ||||||
|  |                 { | ||||||
|  |                     report.LoadReportDefinition(fs); | ||||||
|  |                 } | ||||||
|  |                 report.DataSources.Add(new ReportDataSource("DSListadoDistribucionCanMensualDiarios", data)); | ||||||
|  |  | ||||||
|  |                 var parameters = new List<ReportParameter> | ||||||
|  |             { | ||||||
|  |                 new ReportParameter("FechaDesde", fechaDesde.ToString("dd/MM/yyyy")), | ||||||
|  |                 new ReportParameter("FechaHasta", fechaHasta.ToString("dd/MM/yyyy")), | ||||||
|  |                 new ReportParameter("CanAcc", esAccionista ? "1" : "0") // El RDLC espera un Integer para CanAcc | ||||||
|  |             }; | ||||||
|  |                 report.SetParameters(parameters); | ||||||
|  |  | ||||||
|  |                 byte[] pdfBytes = report.Render("PDF"); | ||||||
|  |                 string tipoDesc = esAccionista ? "Accionistas" : "Canillitas"; | ||||||
|  |                 return File(pdfBytes, "application/pdf", $"ListadoDistMensualDiarios_{tipoDesc}_{fechaDesde:yyyyMMdd}_{fechaHasta:yyyyMMdd}.pdf"); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) { _logger.LogError(ex, "Error PDF ListadoDistMensualDiarios"); return StatusCode(500, "Error interno."); } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         // GET: api/reportes/listado-distribucion-mensual/publicaciones | ||||||
|  |         [HttpGet("listado-distribucion-mensual/publicaciones")] | ||||||
|  |         [ProducesResponseType(typeof(IEnumerable<ListadoDistCanMensualPubDto>), StatusCodes.Status200OK)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  |         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||||
|  |         public async Task<IActionResult> GetListadoDistMensualPorPublicacion( | ||||||
|  |             [FromQuery] DateTime fechaDesde, | ||||||
|  |             [FromQuery] DateTime fechaHasta, | ||||||
|  |             [FromQuery] bool esAccionista) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso(PermisoVerReporteListadoDistMensual)) return Forbid(); | ||||||
|  |             if (fechaDesde > fechaHasta) return BadRequest(new { message = "Fecha Desde no puede ser mayor a Fecha Hasta." }); | ||||||
|  |  | ||||||
|  |             var (data, error) = await _reportesService.ObtenerReporteMensualPorPublicacionAsync(fechaDesde, fechaHasta, esAccionista); | ||||||
|  |             if (error != null) return BadRequest(new { message = error }); | ||||||
|  |             return Ok(data ?? Enumerable.Empty<ListadoDistCanMensualPubDto>()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         [HttpGet("listado-distribucion-mensual/publicaciones/pdf")] | ||||||
|  |         public async Task<IActionResult> GetListadoDistMensualPorPublicacionPdf( | ||||||
|  |             [FromQuery] DateTime fechaDesde, | ||||||
|  |             [FromQuery] DateTime fechaHasta, | ||||||
|  |             [FromQuery] bool esAccionista) | ||||||
|  |         { | ||||||
|  |             if (!TienePermiso(PermisoVerReporteListadoDistMensual)) return Forbid(); | ||||||
|  |             if (fechaDesde > fechaHasta) return BadRequest(new { message = "Fecha Desde no puede ser mayor a Fecha Hasta." }); | ||||||
|  |  | ||||||
|  |             var (data, error) = await _reportesService.ObtenerReporteMensualPorPublicacionAsync(fechaDesde, fechaHasta, esAccionista); | ||||||
|  |             if (error != null) return BadRequest(new { message = error }); | ||||||
|  |             if (data == null || !data.Any()) return NotFound(new { message = "No hay datos para generar el PDF." }); | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 LocalReport report = new LocalReport(); | ||||||
|  |                 string rdlcPath = Path.Combine("Controllers", "Reportes", "RDLC", "ReporteListadoDistribucionCanMensual.rdlc"); | ||||||
|  |                 if (!System.IO.File.Exists(rdlcPath)) | ||||||
|  |                 { | ||||||
|  |                     _logger.LogError("Archivo RDLC no encontrado: {Path}", rdlcPath); | ||||||
|  |                     return StatusCode(StatusCodes.Status500InternalServerError, $"Archivo de reporte no encontrado: {Path.GetFileName(rdlcPath)}"); | ||||||
|  |                 } | ||||||
|  |                 using (var fs = new FileStream(rdlcPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) | ||||||
|  |                 { | ||||||
|  |                     report.LoadReportDefinition(fs); | ||||||
|  |                 } | ||||||
|  |                 report.DataSources.Add(new ReportDataSource("DSListadoDistribucionCanMensual", data)); | ||||||
|  |  | ||||||
|  |                 var parameters = new List<ReportParameter> | ||||||
|  |             { | ||||||
|  |                 new ReportParameter("FechaDesde", fechaDesde.ToString("dd/MM/yyyy")), | ||||||
|  |                 new ReportParameter("FechaHasta", fechaHasta.ToString("dd/MM/yyyy")), | ||||||
|  |                 new ReportParameter("CanAcc", esAccionista ? "1" : "0") | ||||||
|  |             }; | ||||||
|  |                 report.SetParameters(parameters); | ||||||
|  |  | ||||||
|  |                 byte[] pdfBytes = report.Render("PDF"); | ||||||
|  |                 string tipoDesc = esAccionista ? "Accionistas" : "Canillitas"; | ||||||
|  |                 return File(pdfBytes, "application/pdf", $"ListadoDistMensualPub_{tipoDesc}_{fechaDesde:yyyyMMdd}_{fechaHasta:yyyyMMdd}.pdf"); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) { _logger.LogError(ex, "Error PDF ListadoDistMensualPorPublicacion"); return StatusCode(500, "Error interno."); } | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,6 +1,8 @@ | |||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using System.Collections.Generic; // Para IEnumerable | using System.Collections.Generic; // Para IEnumerable | ||||||
| using System.Data; | using System.Data; | ||||||
|  | using GestionIntegral.Api.Dtos.Contables; // Para SaldoGestionDto si lo usas aquí | ||||||
|  | using GestionIntegral.Api.Models.Contables; // Para Saldo, SaldoAjusteHistorial | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Data.Repositories.Contables | namespace GestionIntegral.Api.Data.Repositories.Contables | ||||||
| { | { | ||||||
| @@ -15,5 +17,12 @@ namespace GestionIntegral.Api.Data.Repositories.Contables | |||||||
|                                                                                           // Método para modificar saldo (lo teníamos como privado antes, ahora en el repo) |                                                                                           // Método para modificar saldo (lo teníamos como privado antes, ahora en el repo) | ||||||
|         Task<bool> ModificarSaldoAsync(string destino, int idDestino, int idEmpresa, decimal montoAAgregar, IDbTransaction? transaction = null); |         Task<bool> ModificarSaldoAsync(string destino, int idDestino, int idEmpresa, decimal montoAAgregar, IDbTransaction? transaction = null); | ||||||
|         Task<bool> CheckIfSaldosExistForEmpresaAsync(int id); |         Task<bool> CheckIfSaldosExistForEmpresaAsync(int id); | ||||||
|  |  | ||||||
|  |         // Para obtener la lista de saldos para la página de gestión | ||||||
|  |         Task<IEnumerable<Saldo>> GetSaldosParaGestionAsync(string? destinoFilter, int? idDestinoFilter, int? idEmpresaFilter); | ||||||
|  |         // Para obtener un saldo específico (ya podría existir uno similar, o crearlo si es necesario) | ||||||
|  |         Task<Saldo?> GetSaldoAsync(string destino, int idDestino, int idEmpresa, IDbTransaction? transaction = null); | ||||||
|  |         // Para registrar el historial de ajuste | ||||||
|  |         Task CreateSaldoAjusteHistorialAsync(SaldoAjusteHistorial historialEntry, IDbTransaction transaction); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -5,6 +5,8 @@ using Microsoft.Extensions.Logging; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Data; | using System.Data; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
|  | using GestionIntegral.Api.Models.Contables; | ||||||
|  | using System.Text; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Data.Repositories.Contables | namespace GestionIntegral.Api.Data.Repositories.Contables | ||||||
| { | { | ||||||
| @@ -73,44 +75,47 @@ namespace GestionIntegral.Api.Data.Repositories.Contables | |||||||
|         public async Task<bool> ModificarSaldoAsync(string destino, int idDestino, int idEmpresa, decimal montoAAgregar, IDbTransaction? transaction = null) |         public async Task<bool> ModificarSaldoAsync(string destino, int idDestino, int idEmpresa, decimal montoAAgregar, IDbTransaction? transaction = null) | ||||||
|         { |         { | ||||||
|             var sql = @"UPDATE dbo.cue_Saldos |             var sql = @"UPDATE dbo.cue_Saldos | ||||||
|                          SET Monto = Monto + @MontoAAgregar |                  SET Monto = Monto + @MontoAAgregar, | ||||||
|  |                      FechaUltimaModificacion = @FechaActualizacion -- << AÑADIR | ||||||
|                  WHERE Destino = @Destino AND Id_Destino = @IdDestino AND Id_Empresa = @IdEmpresa;"; |                  WHERE Destino = @Destino AND Id_Destino = @IdDestino AND Id_Empresa = @IdEmpresa;"; | ||||||
|  |  | ||||||
|             // Usar una variable para la conexión para poder aplicar el '!' si es necesario |  | ||||||
|             IDbConnection connection = transaction?.Connection ?? _connectionFactory.CreateConnection(); |             IDbConnection connection = transaction?.Connection ?? _connectionFactory.CreateConnection(); | ||||||
|              bool ownConnection = transaction == null; // Saber si necesitamos cerrar la conexión nosotros |             bool ownConnection = transaction == null; | ||||||
|  |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                  if (ownConnection) await (connection as System.Data.Common.DbConnection)!.OpenAsync(); // Abrir solo si no hay transacción externa |                 if (ownConnection && connection.State != ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                  var parameters = new { |                 var parameters = new | ||||||
|  |                 { | ||||||
|                     MontoAAgregar = montoAAgregar, |                     MontoAAgregar = montoAAgregar, | ||||||
|                     Destino = destino, |                     Destino = destino, | ||||||
|                     IdDestino = idDestino, |                     IdDestino = idDestino, | ||||||
|                      IdEmpresa = idEmpresa |                     IdEmpresa = idEmpresa, | ||||||
|  |                     FechaActualizacion = DateTime.Now // O DateTime.UtcNow si prefieres | ||||||
|                 }; |                 }; | ||||||
|                  // Aplicar '!' aquí también si viene de la transacción |  | ||||||
|                 int rowsAffected = await connection.ExecuteAsync(sql, parameters, transaction: transaction); |                 int rowsAffected = await connection.ExecuteAsync(sql, parameters, transaction: transaction); | ||||||
|                 return rowsAffected == 1; |                 return rowsAffected == 1; | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 _logger.LogError(ex, "Error al modificar saldo para {Destino} ID {IdDestino}, Empresa ID {IdEmpresa}.", destino, idDestino, idEmpresa); |                 _logger.LogError(ex, "Error al modificar saldo para {Destino} ID {IdDestino}, Empresa ID {IdEmpresa}.", destino, idDestino, idEmpresa); | ||||||
|                   if (transaction != null) throw; // Re-lanzar si estamos en una transacción externa |                 if (transaction != null) throw; | ||||||
|                   return false; // Devolver false si fue una operación aislada que falló |                 return false; | ||||||
|             } |             } | ||||||
|             finally |             finally | ||||||
|             { |             { | ||||||
|                  // Cerrar la conexión solo si la abrimos nosotros (no había transacción externa) |  | ||||||
|                 if (ownConnection && connection.State == ConnectionState.Open) |                 if (ownConnection && connection.State == ConnectionState.Open) | ||||||
|                 { |                 { | ||||||
|                      await (connection as System.Data.Common.DbConnection)!.CloseAsync(); |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); | ||||||
|                 } |                 } | ||||||
|                  // Disponer de la conexión si la creamos nosotros |                 if (ownConnection && connection is IDisposable d) d.Dispose(); // Mejorar dispose | ||||||
|                  if(ownConnection) (connection as IDisposable)?.Dispose(); |  | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<bool> CheckIfSaldosExistForEmpresaAsync(int idEmpresa) |         public async Task<bool> CheckIfSaldosExistForEmpresaAsync(int idEmpresa) | ||||||
|         { |         { | ||||||
|             var sql = "SELECT COUNT(1) FROM dbo.cue_Saldos WHERE Id_Empresa = @IdEmpresa"; |             var sql = "SELECT COUNT(1) FROM dbo.cue_Saldos WHERE Id_Empresa = @IdEmpresa"; | ||||||
| @@ -130,5 +135,58 @@ namespace GestionIntegral.Api.Data.Repositories.Contables | |||||||
|                               // O podrías devolver true para ser más conservador si la verificación es crítica. |                               // O podrías devolver true para ser más conservador si la verificación es crítica. | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<Saldo>> GetSaldosParaGestionAsync(string? destinoFilter, int? idDestinoFilter, int? idEmpresaFilter) | ||||||
|  |         { | ||||||
|  |             var sqlBuilder = new StringBuilder("SELECT Id_Saldo AS IdSaldo, Destino, Id_Destino AS IdDestino, Monto, Id_Empresa AS IdEmpresa, FechaUltimaModificacion FROM dbo.cue_Saldos WHERE 1=1"); | ||||||
|  |             var parameters = new DynamicParameters(); | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(destinoFilter)) | ||||||
|  |             { | ||||||
|  |                 sqlBuilder.Append(" AND Destino = @Destino"); | ||||||
|  |                 parameters.Add("Destino", destinoFilter); | ||||||
|  |             } | ||||||
|  |             if (idDestinoFilter.HasValue) | ||||||
|  |             { | ||||||
|  |                 sqlBuilder.Append(" AND Id_Destino = @IdDestino"); | ||||||
|  |                 parameters.Add("IdDestino", idDestinoFilter.Value); | ||||||
|  |             } | ||||||
|  |             if (idEmpresaFilter.HasValue) | ||||||
|  |             { | ||||||
|  |                 sqlBuilder.Append(" AND Id_Empresa = @IdEmpresa"); | ||||||
|  |                 parameters.Add("IdEmpresa", idEmpresaFilter.Value); | ||||||
|  |             } | ||||||
|  |             sqlBuilder.Append(" ORDER BY Destino, Id_Empresa, Id_Destino;"); | ||||||
|  |  | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             return await connection.QueryAsync<Saldo>(sqlBuilder.ToString(), parameters); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<Saldo?> GetSaldoAsync(string destino, int idDestino, int idEmpresa, IDbTransaction? transaction = null) | ||||||
|  |         { | ||||||
|  |             const string sql = "SELECT Id_Saldo AS IdSaldo, Destino, Id_Destino AS IdDestino, Monto, Id_Empresa AS IdEmpresa, FechaUltimaModificacion FROM dbo.cue_Saldos WHERE Destino = @Destino AND Id_Destino = @IdDestino AND Id_Empresa = @IdEmpresa;"; | ||||||
|  |             var conn = transaction?.Connection ?? _connectionFactory.CreateConnection(); | ||||||
|  |             if (transaction == null && conn.State != ConnectionState.Open) { if (conn is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else conn.Open(); } | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 return await conn.QuerySingleOrDefaultAsync<Saldo>(sql, new { Destino = destino, IdDestino = idDestino, IdEmpresa = idEmpresa }, transaction); | ||||||
|  |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (transaction == null && conn.State == ConnectionState.Open) { if (conn is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else conn.Close(); } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task CreateSaldoAjusteHistorialAsync(SaldoAjusteHistorial historialEntry, IDbTransaction transaction) | ||||||
|  |         { | ||||||
|  |             const string sql = @" | ||||||
|  |             INSERT INTO dbo.cue_SaldoAjustesHistorial  | ||||||
|  |                 (Destino, Id_Destino, Id_Empresa, MontoAjuste, SaldoAnterior, SaldoNuevo, Justificacion, FechaAjuste, Id_UsuarioAjuste) | ||||||
|  |             VALUES  | ||||||
|  |                 (@Destino, @IdDestino, @IdEmpresa, @MontoAjuste, @SaldoAnterior, @SaldoNuevo, @Justificacion, @FechaAjuste, @IdUsuarioAjuste);"; | ||||||
|  |  | ||||||
|  |             await transaction.Connection!.ExecuteAsync(sql, historialEntry, transaction); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -21,51 +21,56 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|             _logger = logger; |             _logger = logger; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<(Canilla Canilla, string NombreZona, string NombreEmpresa)>> GetAllAsync(string? nomApeFilter, int? legajoFilter, bool? soloActivos) |         public async Task<IEnumerable<(Canilla Canilla, string? NombreZona, string? NombreEmpresa)>> GetAllAsync( | ||||||
|  |     string? nomApeFilter, | ||||||
|  |     int? legajoFilter, | ||||||
|  |     bool? esAccionista, | ||||||
|  |     bool? soloActivos) // <<-- Parámetro aquí | ||||||
|         { |         { | ||||||
|             var sqlBuilder = new StringBuilder(@" |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|                 SELECT |             var sqlBuilder = new System.Text.StringBuilder(@" | ||||||
|                     c.Id_Canilla AS IdCanilla, c.Legajo, c.NomApe, c.Parada, c.Id_Zona AS IdZona, |         SELECT c.Id_Canilla AS IdCanilla, c.Legajo, c.NomApe, c.Parada, c.Id_Zona AS IdZona,  | ||||||
|                c.Accionista, c.Obs, c.Empresa, c.Baja, c.FechaBaja, |                c.Accionista, c.Obs, c.Empresa, c.Baja, c.FechaBaja, | ||||||
|                z.Nombre AS NombreZona, |                z.Nombre AS NombreZona, | ||||||
|                     ISNULL(e.Nombre, 'N/A (Accionista)') AS NombreEmpresa |                e.Nombre AS NombreEmpresa | ||||||
|         FROM dbo.dist_dtCanillas c |         FROM dbo.dist_dtCanillas c | ||||||
|                 INNER JOIN dbo.dist_dtZonas z ON c.Id_Zona = z.Id_Zona |         LEFT JOIN dbo.dist_dtZonas z ON c.Id_Zona = z.Id_Zona | ||||||
|         LEFT JOIN dbo.dist_dtEmpresas e ON c.Empresa = e.Id_Empresa |         LEFT JOIN dbo.dist_dtEmpresas e ON c.Empresa = e.Id_Empresa | ||||||
|                 WHERE 1=1"); |         WHERE 1=1 "); // Cláusula base para añadir AND fácilmente | ||||||
|  |  | ||||||
|             var parameters = new DynamicParameters(); |             var parameters = new DynamicParameters(); | ||||||
|  |  | ||||||
|             if (soloActivos.HasValue) |  | ||||||
|             { |  | ||||||
|                 sqlBuilder.Append(soloActivos.Value ? " AND c.Baja = 0" : " AND c.Baja = 1"); |  | ||||||
|             } |  | ||||||
|             if (!string.IsNullOrWhiteSpace(nomApeFilter)) |             if (!string.IsNullOrWhiteSpace(nomApeFilter)) | ||||||
|             { |             { | ||||||
|                 sqlBuilder.Append(" AND c.NomApe LIKE @NomApeParam"); |                 sqlBuilder.Append(" AND c.NomApe LIKE @NomApeFilter "); | ||||||
|                 parameters.Add("NomApeParam", $"%{nomApeFilter}%"); |                 parameters.Add("NomApeFilter", $"%{nomApeFilter}%"); | ||||||
|             } |             } | ||||||
|             if (legajoFilter.HasValue) |             if (legajoFilter.HasValue) | ||||||
|             { |             { | ||||||
|                 sqlBuilder.Append(" AND c.Legajo = @LegajoParam"); |                 sqlBuilder.Append(" AND c.Legajo = @LegajoFilter "); | ||||||
|                 parameters.Add("LegajoParam", legajoFilter.Value); |                 parameters.Add("LegajoFilter", legajoFilter.Value); | ||||||
|             } |             } | ||||||
|  |             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;"); |             sqlBuilder.Append(" ORDER BY c.NomApe;"); | ||||||
|  |  | ||||||
|             try |             var result = await connection.QueryAsync<Canilla, string, string, (Canilla, string?, string?)>( | ||||||
|             { |  | ||||||
|                 using var connection = _connectionFactory.CreateConnection(); |  | ||||||
|                 return await connection.QueryAsync<Canilla, string, string, (Canilla, string, string)>( |  | ||||||
|                 sqlBuilder.ToString(), |                 sqlBuilder.ToString(), | ||||||
|                     (canilla, nombreZona, nombreEmpresa) => (canilla, nombreZona, nombreEmpresa), |                 (can, zona, emp) => (can, zona, emp), | ||||||
|                 parameters, |                 parameters, | ||||||
|                 splitOn: "NombreZona,NombreEmpresa" |                 splitOn: "NombreZona,NombreEmpresa" | ||||||
|             ); |             ); | ||||||
|             } |             return result; | ||||||
|             catch (Exception ex) |  | ||||||
|             { |  | ||||||
|                 _logger.LogError(ex, "Error al obtener todos los Canillas. Filtros: NomApe='{NomApeFilter}', Legajo='{LegajoFilter}', SoloActivos='{SoloActivos}'", nomApeFilter, legajoFilter, soloActivos); |  | ||||||
|                 return Enumerable.Empty<(Canilla, string, string)>(); |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<(Canilla? Canilla, string? NombreZona, string? NombreEmpresa)> GetByIdAsync(int id) |         public async Task<(Canilla? Canilla, string? NombreZona, string? NombreEmpresa)> GetByIdAsync(int id) | ||||||
| @@ -160,9 +165,19 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|  |  | ||||||
|             await connection.ExecuteAsync(sqlInsertHistorico, new |             await connection.ExecuteAsync(sqlInsertHistorico, new | ||||||
|             { |             { | ||||||
|                 IdCanillaParam = insertedCanilla.IdCanilla, LegajoParam = insertedCanilla.Legajo, NomApeParam = insertedCanilla.NomApe, ParadaParam = insertedCanilla.Parada, IdZonaParam = insertedCanilla.IdZona, |                 IdCanillaParam = insertedCanilla.IdCanilla, | ||||||
|                 AccionistaParam = insertedCanilla.Accionista, ObsParam = insertedCanilla.Obs, EmpresaParam = insertedCanilla.Empresa, BajaParam = insertedCanilla.Baja, FechaBajaParam = insertedCanilla.FechaBaja, |                 LegajoParam = insertedCanilla.Legajo, | ||||||
|                 Id_UsuarioParam = idUsuario, FechaModParam = DateTime.Now, TipoModParam = "Creado" |                 NomApeParam = insertedCanilla.NomApe, | ||||||
|  |                 ParadaParam = insertedCanilla.Parada, | ||||||
|  |                 IdZonaParam = insertedCanilla.IdZona, | ||||||
|  |                 AccionistaParam = insertedCanilla.Accionista, | ||||||
|  |                 ObsParam = insertedCanilla.Obs, | ||||||
|  |                 EmpresaParam = insertedCanilla.Empresa, | ||||||
|  |                 BajaParam = insertedCanilla.Baja, | ||||||
|  |                 FechaBajaParam = insertedCanilla.FechaBaja, | ||||||
|  |                 Id_UsuarioParam = idUsuario, | ||||||
|  |                 FechaModParam = DateTime.Now, | ||||||
|  |                 TipoModParam = "Creado" | ||||||
|             }, transaction); |             }, transaction); | ||||||
|             return insertedCanilla; |             return insertedCanilla; | ||||||
|         } |         } | ||||||
| @@ -190,10 +205,18 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|             await connection.ExecuteAsync(sqlInsertHistorico, new |             await connection.ExecuteAsync(sqlInsertHistorico, new | ||||||
|             { |             { | ||||||
|                 IdCanillaParam = canillaActual.IdCanilla, |                 IdCanillaParam = canillaActual.IdCanilla, | ||||||
|                 LegajoParam = canillaActual.Legajo, NomApeParam = canillaActual.NomApe, ParadaParam = canillaActual.Parada, IdZonaParam = canillaActual.IdZona, |                 LegajoParam = canillaActual.Legajo, | ||||||
|                 AccionistaParam = canillaActual.Accionista, ObsParam = canillaActual.Obs, EmpresaParam = canillaActual.Empresa, |                 NomApeParam = canillaActual.NomApe, | ||||||
|                 BajaParam = canillaActual.Baja, FechaBajaParam = canillaActual.FechaBaja,  |                 ParadaParam = canillaActual.Parada, | ||||||
|                 Id_UsuarioParam = idUsuario, FechaModParam = DateTime.Now, TipoModParam = "Actualizado" |                 IdZonaParam = canillaActual.IdZona, | ||||||
|  |                 AccionistaParam = canillaActual.Accionista, | ||||||
|  |                 ObsParam = canillaActual.Obs, | ||||||
|  |                 EmpresaParam = canillaActual.Empresa, | ||||||
|  |                 BajaParam = canillaActual.Baja, | ||||||
|  |                 FechaBajaParam = canillaActual.FechaBaja, | ||||||
|  |                 Id_UsuarioParam = idUsuario, | ||||||
|  |                 FechaModParam = DateTime.Now, | ||||||
|  |                 TipoModParam = "Actualizado" | ||||||
|             }, transaction); |             }, transaction); | ||||||
|  |  | ||||||
|             var rowsAffected = await connection.ExecuteAsync(sqlUpdate, canillaAActualizar, transaction); |             var rowsAffected = await connection.ExecuteAsync(sqlUpdate, canillaAActualizar, transaction); | ||||||
| @@ -218,10 +241,19 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|  |  | ||||||
|             await connection.ExecuteAsync(sqlInsertHistorico, new |             await connection.ExecuteAsync(sqlInsertHistorico, new | ||||||
|             { |             { | ||||||
|                 IdCanillaParam = canillaActual.IdCanilla, LegajoParam = canillaActual.Legajo, NomApeParam = canillaActual.NomApe, ParadaParam = canillaActual.Parada, IdZonaParam = canillaActual.IdZona, |                 IdCanillaParam = canillaActual.IdCanilla, | ||||||
|                 AccionistaParam = canillaActual.Accionista, ObsParam = canillaActual.Obs, EmpresaParam = canillaActual.Empresa, |                 LegajoParam = canillaActual.Legajo, | ||||||
|                 BajaNuevaParam = darDeBaja, FechaBajaNuevaParam = (darDeBaja ? fechaBaja : null), |                 NomApeParam = canillaActual.NomApe, | ||||||
|                 Id_UsuarioParam = idUsuario, FechaModParam = DateTime.Now, TipoModHistParam = (darDeBaja ? "Baja" : "Alta") |                 ParadaParam = canillaActual.Parada, | ||||||
|  |                 IdZonaParam = canillaActual.IdZona, | ||||||
|  |                 AccionistaParam = canillaActual.Accionista, | ||||||
|  |                 ObsParam = canillaActual.Obs, | ||||||
|  |                 EmpresaParam = canillaActual.Empresa, | ||||||
|  |                 BajaNuevaParam = darDeBaja, | ||||||
|  |                 FechaBajaNuevaParam = (darDeBaja ? fechaBaja : null), | ||||||
|  |                 Id_UsuarioParam = idUsuario, | ||||||
|  |                 FechaModParam = DateTime.Now, | ||||||
|  |                 TipoModHistParam = (darDeBaja ? "Baja" : "Alta") | ||||||
|             }, transaction); |             }, transaction); | ||||||
|  |  | ||||||
|             var rowsAffected = await connection.ExecuteAsync(sqlUpdate, new { BajaParam = darDeBaja, FechaBajaParam = (darDeBaja ? fechaBaja : null), IdCanillaParam = id }, transaction); |             var rowsAffected = await connection.ExecuteAsync(sqlUpdate, new { BajaParam = darDeBaja, FechaBajaParam = (darDeBaja ? fechaBaja : null), IdCanillaParam = id }, transaction); | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| using Dapper; | using Dapper; | ||||||
|  | using GestionIntegral.Api.Dtos.Distribucion; | ||||||
| using GestionIntegral.Api.Models.Distribucion; | using GestionIntegral.Api.Models.Distribucion; | ||||||
| using Microsoft.Extensions.Logging; | using Microsoft.Extensions.Logging; | ||||||
| using System; // Añadido para Exception | using System; // Añadido para Exception | ||||||
| @@ -62,6 +63,30 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|          |          | ||||||
|  |         public async Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync() | ||||||
|  |         { | ||||||
|  |             var sqlBuilder = new StringBuilder(@" | ||||||
|  |                 SELECT | ||||||
|  |                     Id_Distribuidor AS IdDistribuidor, Nombre | ||||||
|  |                 FROM dbo.dist_dtDistribuidores | ||||||
|  |                 WHERE 1=1"); | ||||||
|  |             var parameters = new DynamicParameters();             | ||||||
|  |             sqlBuilder.Append(" ORDER BY Nombre;"); | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |                 return await connection.QueryAsync<DistribuidorDropdownDto>( | ||||||
|  |                     sqlBuilder.ToString(), | ||||||
|  |                     parameters | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener todos los Distribuidores."); | ||||||
|  |                 return Enumerable.Empty<DistribuidorDropdownDto>(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public async Task<(Distribuidor? Distribuidor, string? NombreZona)> GetByIdAsync(int id) |         public async Task<(Distribuidor? Distribuidor, string? NombreZona)> GetByIdAsync(int id) | ||||||
|         { |         { | ||||||
|             const string sql = @" |             const string sql = @" | ||||||
| @@ -90,6 +115,25 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         public async Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id) | ||||||
|  |         { | ||||||
|  |             const string sql = @" | ||||||
|  |                 SELECT  | ||||||
|  |                     Id_Distribuidor AS IdDistribuidor, Nombre | ||||||
|  |                 FROM dbo.dist_dtDistribuidores  | ||||||
|  |                 WHERE Id_Distribuidor = @IdParam"; | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |                 return await connection.QuerySingleOrDefaultAsync<DistribuidorLookupDto>(sql, new { IdParam = id }); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                  _logger.LogError(ex, "Error al obtener Distribuidor por ID: {IdDistribuidor}", id); | ||||||
|  |                 return null; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public async Task<Distribuidor?> GetByIdSimpleAsync(int id) |         public async Task<Distribuidor?> GetByIdSimpleAsync(int id) | ||||||
|         { |         { | ||||||
|             const string sql = @" |             const string sql = @" | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| using Dapper; | using Dapper; | ||||||
| using GestionIntegral.Api.Data.Repositories; | using GestionIntegral.Api.Data.Repositories; | ||||||
|  | using GestionIntegral.Api.Dtos.Empresas; | ||||||
| using GestionIntegral.Api.Models.Distribucion; | using GestionIntegral.Api.Models.Distribucion; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Data; | using System.Data; | ||||||
| @@ -52,6 +53,25 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public async Task<IEnumerable<EmpresaDropdownDto>> GetAllDropdownAsync() | ||||||
|  |     { | ||||||
|  |       var sqlBuilder = new StringBuilder("SELECT Id_Empresa AS IdEmpresa, Nombre FROM dbo.dist_dtEmpresas WHERE 1=1"); | ||||||
|  |       var parameters = new DynamicParameters(); | ||||||
|  |       sqlBuilder.Append(" ORDER BY Nombre;"); | ||||||
|  |       try | ||||||
|  |       { | ||||||
|  |         using (var connection = _connectionFactory.CreateConnection()) | ||||||
|  |         { | ||||||
|  |           return await connection.QueryAsync<EmpresaDropdownDto>(sqlBuilder.ToString(), parameters); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       catch (Exception ex) | ||||||
|  |       { | ||||||
|  |         _logger.LogError(ex, "Error al obtener todas las Empresas."); | ||||||
|  |         return Enumerable.Empty<EmpresaDropdownDto>(); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public async Task<Empresa?> GetByIdAsync(int id) |     public async Task<Empresa?> GetByIdAsync(int id) | ||||||
|     { |     { | ||||||
|       var sql = "SELECT Id_Empresa AS IdEmpresa, Nombre, Detalle FROM dbo.dist_dtEmpresas WHERE Id_Empresa = @Id"; |       var sql = "SELECT Id_Empresa AS IdEmpresa, Nombre, Detalle FROM dbo.dist_dtEmpresas WHERE Id_Empresa = @Id"; | ||||||
| @@ -69,6 +89,23 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public async Task<Empresa?> ObtenerLookupPorIdAsync(int id) | ||||||
|  |     { | ||||||
|  |       var sql = "SELECT Id_Empresa AS IdEmpresa, Nombre FROM dbo.dist_dtEmpresas WHERE Id_Empresa = @Id"; | ||||||
|  |       try | ||||||
|  |       { | ||||||
|  |         using (var connection = _connectionFactory.CreateConnection()) | ||||||
|  |         { | ||||||
|  |           return await connection.QuerySingleOrDefaultAsync<Empresa>(sql, new { Id = id }); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       catch (Exception ex) | ||||||
|  |       { | ||||||
|  |         _logger.LogError(ex, "Error al obtener Empresa por ID: {IdEmpresa}", id); | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public async Task<bool> ExistsByNameAsync(string nombre, int? excludeId = null) |     public async Task<bool> ExistsByNameAsync(string nombre, int? excludeId = null) | ||||||
|     { |     { | ||||||
|       var sqlBuilder = new StringBuilder("SELECT COUNT(1) FROM dbo.dist_dtEmpresas WHERE Nombre = @Nombre"); |       var sqlBuilder = new StringBuilder("SELECT COUNT(1) FROM dbo.dist_dtEmpresas WHERE Nombre = @Nombre"); | ||||||
| @@ -144,7 +181,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Insertar en historial |       // Insertar en historial | ||||||
|       await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new { |       await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new | ||||||
|  |       { | ||||||
|         IdEmpresa = insertedEmpresa.IdEmpresa, |         IdEmpresa = insertedEmpresa.IdEmpresa, | ||||||
|         insertedEmpresa.Nombre, |         insertedEmpresa.Nombre, | ||||||
|         insertedEmpresa.Detalle, |         insertedEmpresa.Detalle, | ||||||
| @@ -172,7 +210,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|                 VALUES (@IdEmpresa, @NombreActual, @DetalleActual, @IdUsuario, @FechaMod, @TipoMod);"; |                 VALUES (@IdEmpresa, @NombreActual, @DetalleActual, @IdUsuario, @FechaMod, @TipoMod);"; | ||||||
|  |  | ||||||
|       // Insertar en historial (estado anterior) |       // Insertar en historial (estado anterior) | ||||||
|       await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new { |       await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new | ||||||
|  |       { | ||||||
|         IdEmpresa = empresaActual.IdEmpresa, |         IdEmpresa = empresaActual.IdEmpresa, | ||||||
|         NombreActual = empresaActual.Nombre, |         NombreActual = empresaActual.Nombre, | ||||||
|         DetalleActual = empresaActual.Detalle, |         DetalleActual = empresaActual.Detalle, | ||||||
| @@ -182,7 +221,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|       }, transaction: transaction); |       }, transaction: transaction); | ||||||
|  |  | ||||||
|       // Actualizar principal |       // Actualizar principal | ||||||
|       var rowsAffected = await transaction.Connection!.ExecuteAsync(sqlUpdate, new { |       var rowsAffected = await transaction.Connection!.ExecuteAsync(sqlUpdate, new | ||||||
|  |       { | ||||||
|         empresaAActualizar.Nombre, |         empresaAActualizar.Nombre, | ||||||
|         empresaAActualizar.Detalle, |         empresaAActualizar.Detalle, | ||||||
|         empresaAActualizar.IdEmpresa |         empresaAActualizar.IdEmpresa | ||||||
| @@ -202,7 +242,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|                 VALUES (@IdEmpresa, @Nombre, @Detalle, @IdUsuario, @FechaMod, @TipoMod);"; |                 VALUES (@IdEmpresa, @Nombre, @Detalle, @IdUsuario, @FechaMod, @TipoMod);"; | ||||||
|  |  | ||||||
|       // Insertar en historial (estado antes de borrar) |       // Insertar en historial (estado antes de borrar) | ||||||
|       await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new { |       await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new | ||||||
|  |       { | ||||||
|         IdEmpresa = empresaActual.IdEmpresa, |         IdEmpresa = empresaActual.IdEmpresa, | ||||||
|         empresaActual.Nombre, |         empresaActual.Nombre, | ||||||
|         empresaActual.Detalle, |         empresaActual.Detalle, | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
| { | { | ||||||
|     public interface ICanillaRepository |     public interface ICanillaRepository | ||||||
|     { |     { | ||||||
|         Task<IEnumerable<(Canilla Canilla, string NombreZona, string NombreEmpresa)>> GetAllAsync(string? nomApeFilter, int? legajoFilter, bool? soloActivos); |         Task<IEnumerable<(Canilla Canilla, string? NombreZona, string? NombreEmpresa)>> GetAllAsync(string? nomApeFilter, int? legajoFilter, bool? soloActivos, bool? esAccionista); | ||||||
|         Task<(Canilla? Canilla, string? NombreZona, string? NombreEmpresa)> GetByIdAsync(int id); |         Task<(Canilla? Canilla, string? NombreZona, string? NombreEmpresa)> GetByIdAsync(int id); | ||||||
|         Task<Canilla?> GetByIdSimpleAsync(int id); // Para obtener solo la entidad Canilla |         Task<Canilla?> GetByIdSimpleAsync(int id); // Para obtener solo la entidad Canilla | ||||||
|         Task<Canilla?> CreateAsync(Canilla nuevoCanilla, int idUsuario, IDbTransaction transaction); |         Task<Canilla?> CreateAsync(Canilla nuevoCanilla, int idUsuario, IDbTransaction transaction); | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ using GestionIntegral.Api.Models.Distribucion; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using System.Data; | using System.Data; | ||||||
|  | using GestionIntegral.Api.Dtos.Distribucion; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Data.Repositories.Distribucion | namespace GestionIntegral.Api.Data.Repositories.Distribucion | ||||||
| { | { | ||||||
| @@ -16,5 +17,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|         Task<bool> ExistsByNroDocAsync(string nroDoc, int? excludeIdDistribuidor = null); |         Task<bool> ExistsByNroDocAsync(string nroDoc, int? excludeIdDistribuidor = null); | ||||||
|         Task<bool> ExistsByNameAsync(string nombre, int? excludeIdDistribuidor = null); |         Task<bool> ExistsByNameAsync(string nombre, int? excludeIdDistribuidor = null); | ||||||
|         Task<bool> IsInUseAsync(int id); // Verificar en dist_EntradasSalidas, cue_PagosDistribuidor, dist_PorcPago |         Task<bool> IsInUseAsync(int id); // Verificar en dist_EntradasSalidas, cue_PagosDistribuidor, dist_PorcPago | ||||||
|  |         Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync();         | ||||||
|  |         Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,7 +1,8 @@ | |||||||
| using GestionIntegral.Api.Models.Distribucion; | using GestionIntegral.Api.Models.Distribucion; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using System.Data; // Para IDbTransaction | using System.Data; | ||||||
|  | using GestionIntegral.Api.Dtos.Empresas; // Para IDbTransaction | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Data.Repositories.Distribucion | namespace GestionIntegral.Api.Data.Repositories.Distribucion | ||||||
| { | { | ||||||
| @@ -14,5 +15,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion | |||||||
|         Task<bool> DeleteAsync(int id, int idUsuario, IDbTransaction transaction); // Necesita transacción |         Task<bool> DeleteAsync(int id, int idUsuario, IDbTransaction transaction); // Necesita transacción | ||||||
|         Task<bool> ExistsByNameAsync(string nombre, int? excludeId = null); |         Task<bool> ExistsByNameAsync(string nombre, int? excludeId = null); | ||||||
|         Task<bool> IsInUseAsync(int id); |         Task<bool> IsInUseAsync(int id); | ||||||
|  |         Task<IEnumerable<EmpresaDropdownDto>> GetAllDropdownAsync(); | ||||||
|  |         Task<Empresa?> ObtenerLookupPorIdAsync(int id); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -0,0 +1,23 @@ | |||||||
|  | using GestionIntegral.Api.Dtos.Reportes; | ||||||
|  | using GestionIntegral.Api.Models.Distribucion; | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Data; // Para IDbTransaction | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Data.Repositories.Distribucion | ||||||
|  | { | ||||||
|  |     public interface INovedadCanillaRepository | ||||||
|  |     { | ||||||
|  |         // Para obtener novedades y el nombre del canillita | ||||||
|  |         Task<IEnumerable<(NovedadCanilla Novedad, string NombreCanilla)>> GetByCanillaAsync(int idCanilla, DateTime? fechaDesde, DateTime? fechaHasta); | ||||||
|  |         Task<NovedadCanilla?> GetByIdAsync(int idNovedad); | ||||||
|  |         Task<NovedadCanilla?> CreateAsync(NovedadCanilla novedad, int idUsuario, IDbTransaction? transaction = null); | ||||||
|  |         Task<bool> UpdateAsync(NovedadCanilla novedad, int idUsuario, IDbTransaction? transaction = null); | ||||||
|  |         Task<bool> DeleteAsync(int idNovedad, int idUsuario, IDbTransaction? transaction = null); | ||||||
|  |         // Podrías añadir un método para verificar si existe una novedad para un canillita en una fecha específica si es necesario | ||||||
|  |         Task<bool> ExistsByCanillaAndFechaAsync(int idCanilla, DateTime fecha, int? excludeIdNovedad = null); | ||||||
|  |         Task<IEnumerable<NovedadesCanillasReporteDto>> GetReporteNovedadesAsync(int idEmpresa, DateTime fechaDesde, DateTime fechaHasta); | ||||||
|  |         Task<IEnumerable<CanillaGananciaReporteDto>> GetReporteGananciasAsync(int idEmpresa, DateTime fechaDesde, DateTime fechaHasta); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,236 @@ | |||||||
|  | using Dapper; | ||||||
|  | using GestionIntegral.Api.Models.Distribucion; | ||||||
|  | using Microsoft.Extensions.Configuration; // Para IConfiguration | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Data; | ||||||
|  | using Microsoft.Data.SqlClient; // O el proveedor de tu BD | ||||||
|  | using System.Linq; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using GestionIntegral.Api.Dtos.Reportes; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Data.Repositories.Distribucion | ||||||
|  | { | ||||||
|  |     public class NovedadCanillaRepository : INovedadCanillaRepository | ||||||
|  |     { | ||||||
|  |         private readonly DbConnectionFactory _connectionFactory; // Inyecta tu DbConnectionFactory | ||||||
|  |         private readonly ILogger<NovedadCanillaRepository> _logger; | ||||||
|  |  | ||||||
|  |         public NovedadCanillaRepository(DbConnectionFactory connectionFactory, ILogger<NovedadCanillaRepository> logger) | ||||||
|  |         { | ||||||
|  |             _connectionFactory = connectionFactory; | ||||||
|  |             _logger = logger; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private async Task LogHistorialAsync(NovedadCanilla novedadOriginal, int idUsuario, string tipoMod, IDbConnection connection, IDbTransaction? transaction) | ||||||
|  |         { | ||||||
|  |             var historial = new NovedadCanillaHistorial | ||||||
|  |             { | ||||||
|  |                 IdNovedad = novedadOriginal.IdNovedad, | ||||||
|  |                 IdCanilla = novedadOriginal.IdCanilla, | ||||||
|  |                 Fecha = novedadOriginal.Fecha, | ||||||
|  |                 Detalle = novedadOriginal.Detalle, | ||||||
|  |                 IdUsuario = idUsuario, | ||||||
|  |                 FechaMod = DateTime.Now, | ||||||
|  |                 TipoMod = tipoMod | ||||||
|  |             }; | ||||||
|  |             var sqlHistorial = @" | ||||||
|  |                 INSERT INTO dbo.dist_dtNovedadesCanillas_H  | ||||||
|  |                     (Id_Novedad, Id_Canilla, Fecha, Detalle, Id_Usuario, FechaMod, TipoMod) | ||||||
|  |                 VALUES  | ||||||
|  |                     (@IdNovedad, @IdCanilla, @Fecha, @Detalle, @IdUsuario, @FechaMod, @TipoMod);"; | ||||||
|  |             await connection.ExecuteAsync(sqlHistorial, historial, transaction); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<(NovedadCanilla Novedad, string NombreCanilla)>> GetByCanillaAsync(int idCanilla, DateTime? fechaDesde, DateTime? fechaHasta) | ||||||
|  |         { | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             var sqlBuilder = new System.Text.StringBuilder(@" | ||||||
|  |                 SELECT  | ||||||
|  |                     n.Id_Novedad AS IdNovedad,  | ||||||
|  |                     n.Id_Canilla AS IdCanilla,  | ||||||
|  |                     n.Fecha,  | ||||||
|  |                     n.Detalle, | ||||||
|  |                     c.NomApe AS NombreCanilla | ||||||
|  |                 FROM dbo.dist_dtNovedadesCanillas n | ||||||
|  |                 JOIN dbo.dist_dtCanillas c ON n.Id_Canilla = c.Id_Canilla | ||||||
|  |                 WHERE n.Id_Canilla = @IdCanilla"); | ||||||
|  |  | ||||||
|  |             var parameters = new DynamicParameters(); | ||||||
|  |             parameters.Add("IdCanilla", idCanilla); | ||||||
|  |  | ||||||
|  |             if (fechaDesde.HasValue) | ||||||
|  |             { | ||||||
|  |                 sqlBuilder.Append(" AND n.Fecha >= @FechaDesde"); | ||||||
|  |                 parameters.Add("FechaDesde", fechaDesde.Value.Date); // Solo fecha, sin hora | ||||||
|  |             } | ||||||
|  |             if (fechaHasta.HasValue) | ||||||
|  |             { | ||||||
|  |                 sqlBuilder.Append(" AND n.Fecha <= @FechaHasta"); | ||||||
|  |                 // Para incluir todo el día de fechaHasta | ||||||
|  |                 parameters.Add("FechaHasta", fechaHasta.Value.Date.AddDays(1).AddTicks(-1)); | ||||||
|  |             } | ||||||
|  |             sqlBuilder.Append(" ORDER BY n.Fecha DESC, n.Id_Novedad DESC;"); | ||||||
|  |  | ||||||
|  |             var result = await connection.QueryAsync<NovedadCanilla, string, (NovedadCanilla, string)>( | ||||||
|  |                 sqlBuilder.ToString(), | ||||||
|  |                 (novedad, nombreCanilla) => (novedad, nombreCanilla), | ||||||
|  |                 parameters, | ||||||
|  |                 splitOn: "NombreCanilla" | ||||||
|  |             ); | ||||||
|  |             return result; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         public async Task<NovedadCanilla?> GetByIdAsync(int idNovedad) | ||||||
|  |         { | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             var sql = "SELECT Id_Novedad AS IdNovedad, Id_Canilla AS IdCanilla, Fecha, Detalle FROM dbo.dist_dtNovedadesCanillas WHERE Id_Novedad = @IdNovedad;"; | ||||||
|  |             return await connection.QuerySingleOrDefaultAsync<NovedadCanilla>(sql, new { IdNovedad = idNovedad }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<bool> ExistsByCanillaAndFechaAsync(int idCanilla, DateTime fecha, int? excludeIdNovedad = null) | ||||||
|  |         { | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             var sqlBuilder = new System.Text.StringBuilder("SELECT COUNT(1) FROM dbo.dist_dtNovedadesCanillas WHERE Id_Canilla = @IdCanilla AND Fecha = @Fecha"); | ||||||
|  |             var parameters = new DynamicParameters(); | ||||||
|  |             parameters.Add("IdCanilla", idCanilla); | ||||||
|  |             parameters.Add("Fecha", fecha.Date); // Comparar solo la fecha | ||||||
|  |  | ||||||
|  |             if (excludeIdNovedad.HasValue) | ||||||
|  |             { | ||||||
|  |                 sqlBuilder.Append(" AND Id_Novedad != @ExcludeIdNovedad"); | ||||||
|  |                 parameters.Add("ExcludeIdNovedad", excludeIdNovedad.Value); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var count = await connection.ExecuteScalarAsync<int>(sqlBuilder.ToString(), parameters); | ||||||
|  |             return count > 0; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<NovedadCanilla?> CreateAsync(NovedadCanilla novedad, int idUsuario, IDbTransaction? transaction = null) | ||||||
|  |         { | ||||||
|  |             var sql = @" | ||||||
|  |                 INSERT INTO dbo.dist_dtNovedadesCanillas (Id_Canilla, Fecha, Detalle) | ||||||
|  |                 VALUES (@IdCanilla, @Fecha, @Detalle); | ||||||
|  |                 SELECT CAST(SCOPE_IDENTITY() as int);"; | ||||||
|  |  | ||||||
|  |             IDbConnection conn = transaction?.Connection ?? _connectionFactory.CreateConnection(); | ||||||
|  |             bool manageConnection = transaction == null; // Solo gestionar si no hay transacción externa | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 if (manageConnection && conn.State != ConnectionState.Open) await (conn as SqlConnection)!.OpenAsync(); | ||||||
|  |  | ||||||
|  |                 var newId = await conn.QuerySingleAsync<int>(sql, novedad, transaction); | ||||||
|  |                 novedad.IdNovedad = newId; | ||||||
|  |                 await LogHistorialAsync(novedad, idUsuario, "Insertada", conn, transaction); | ||||||
|  |                 return novedad; | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al crear NovedadCanilla para Canilla ID: {IdCanilla}", novedad.IdCanilla); | ||||||
|  |                 return null; | ||||||
|  |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (manageConnection && conn.State == ConnectionState.Open) conn.Close(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<bool> UpdateAsync(NovedadCanilla novedad, int idUsuario, IDbTransaction? transaction = null) | ||||||
|  |         { | ||||||
|  |             var novedadOriginal = await GetByIdAsync(novedad.IdNovedad); // Necesitamos el estado original para el log | ||||||
|  |             if (novedadOriginal == null) return false; // No se encontró | ||||||
|  |  | ||||||
|  |             var sql = @" | ||||||
|  |                 UPDATE dbo.dist_dtNovedadesCanillas SET | ||||||
|  |                     Detalle = @Detalle  | ||||||
|  |                     -- No se permite cambiar IdCanilla ni Fecha de una novedad existente | ||||||
|  |                 WHERE Id_Novedad = @IdNovedad;"; | ||||||
|  |  | ||||||
|  |             IDbConnection conn = transaction?.Connection ?? _connectionFactory.CreateConnection(); | ||||||
|  |             bool manageConnection = transaction == null; | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 if (manageConnection && conn.State != ConnectionState.Open) await (conn as SqlConnection)!.OpenAsync(); | ||||||
|  |  | ||||||
|  |                 await LogHistorialAsync(novedadOriginal, idUsuario, "Modificada", conn, transaction); // Log con datos ANTES de actualizar | ||||||
|  |                 var affectedRows = await conn.ExecuteAsync(sql, novedad, transaction); | ||||||
|  |                 return affectedRows > 0; | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al actualizar NovedadCanilla ID: {IdNovedad}", novedad.IdNovedad); | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (manageConnection && conn.State == ConnectionState.Open) conn.Close(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<bool> DeleteAsync(int idNovedad, int idUsuario, IDbTransaction? transaction = null) | ||||||
|  |         { | ||||||
|  |             var novedadOriginal = await GetByIdAsync(idNovedad); | ||||||
|  |             if (novedadOriginal == null) return false; | ||||||
|  |  | ||||||
|  |             var sql = "DELETE FROM dbo.dist_dtNovedadesCanillas WHERE Id_Novedad = @IdNovedad;"; | ||||||
|  |  | ||||||
|  |             IDbConnection conn = transaction?.Connection ?? _connectionFactory.CreateConnection(); | ||||||
|  |             bool manageConnection = transaction == null; | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 if (manageConnection && conn.State != ConnectionState.Open) await (conn as SqlConnection)!.OpenAsync(); | ||||||
|  |  | ||||||
|  |                 await LogHistorialAsync(novedadOriginal, idUsuario, "Eliminada", conn, transaction); | ||||||
|  |                 var affectedRows = await conn.ExecuteAsync(sql, new { IdNovedad = idNovedad }, transaction); | ||||||
|  |                 return affectedRows > 0; | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al eliminar NovedadCanilla ID: {IdNovedad}", idNovedad); | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (manageConnection && conn.State == ConnectionState.Open) conn.Close(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<NovedadesCanillasReporteDto>> GetReporteNovedadesAsync(int idEmpresa, DateTime fechaDesde, DateTime fechaHasta) | ||||||
|  |         { | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             var parameters = new | ||||||
|  |             { | ||||||
|  |                 idEmpresa, | ||||||
|  |                 fechaDesde = fechaDesde.Date, // Enviar solo la fecha | ||||||
|  |                 fechaHasta = fechaHasta.Date.AddDays(1).AddTicks(-1) // Para incluir todo el día hasta las 23:59:59.999... | ||||||
|  |             }; | ||||||
|  |             // El nombre del SP en el archivo es SP_DistCanillasNovedades | ||||||
|  |             return await connection.QueryAsync<NovedadesCanillasReporteDto>( | ||||||
|  |                 "dbo.SP_DistCanillasNovedades", // Asegúrate que el nombre del SP sea exacto | ||||||
|  |                 parameters, | ||||||
|  |                 commandType: CommandType.StoredProcedure | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<CanillaGananciaReporteDto>> GetReporteGananciasAsync(int idEmpresa, DateTime fechaDesde, DateTime fechaHasta) | ||||||
|  |         { | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             var parameters = new | ||||||
|  |             { | ||||||
|  |                 idEmpresa, | ||||||
|  |                 fechaDesde = fechaDesde.Date, | ||||||
|  |                 fechaHasta = fechaHasta.Date // El SP SP_DistCanillasGanancias maneja el rango inclusivo directamente | ||||||
|  |             }; | ||||||
|  |             return await connection.QueryAsync<CanillaGananciaReporteDto>( | ||||||
|  |                 "dbo.SP_DistCanillasGanancias", // Nombre del SP | ||||||
|  |                 parameters, | ||||||
|  |                 commandType: CommandType.StoredProcedure | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -43,5 +43,7 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes | |||||||
|         Task<(IEnumerable<ListadoDistribucionDistSimpleDto> Simple, IEnumerable<ListadoDistribucionDistPromedioDiaDto> Promedios, string? Error)> ObtenerListadoDistribucionDistribuidoresAsync(int idDistribuidor, int idPublicacion, DateTime fechaDesde, DateTime fechaHasta); |         Task<(IEnumerable<ListadoDistribucionDistSimpleDto> Simple, IEnumerable<ListadoDistribucionDistPromedioDiaDto> Promedios, string? Error)> ObtenerListadoDistribucionDistribuidoresAsync(int idDistribuidor, int idPublicacion, DateTime fechaDesde, DateTime fechaHasta); | ||||||
|         Task<IEnumerable<LiquidacionCanillaDetalleDto>> GetLiquidacionCanillaDetalleAsync(DateTime fecha, int idCanilla); |         Task<IEnumerable<LiquidacionCanillaDetalleDto>> GetLiquidacionCanillaDetalleAsync(DateTime fecha, int idCanilla); | ||||||
|         Task<IEnumerable<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla); |         Task<IEnumerable<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla); | ||||||
|  |         Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); | ||||||
|  |         Task<IEnumerable<ListadoDistCanMensualPubDto>> GetReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -515,5 +515,37 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes | |||||||
|                 return Enumerable.Empty<LiquidacionCanillaGananciaDto>(); |                 return Enumerable.Empty<LiquidacionCanillaGananciaDto>(); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista) | ||||||
|  |         { | ||||||
|  |             using var connection = _dbConnectionFactory.CreateConnection(); | ||||||
|  |             var parameters = new | ||||||
|  |             { | ||||||
|  |                 fechaDesde = fechaDesde.Date, | ||||||
|  |                 fechaHasta = fechaHasta.Date, // El SP parece manejar el rango incluyendo el último día | ||||||
|  |                 accionista = esAccionista | ||||||
|  |             }; | ||||||
|  |             return await connection.QueryAsync<ListadoDistCanMensualDiariosDto>( | ||||||
|  |                 "dbo.SP_DistCanillasAccConImporteEntreFechasDiarios", | ||||||
|  |                 parameters, | ||||||
|  |                 commandType: CommandType.StoredProcedure | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<ListadoDistCanMensualPubDto>> GetReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista) | ||||||
|  |         { | ||||||
|  |             using var connection = _dbConnectionFactory.CreateConnection(); | ||||||
|  |             var parameters = new | ||||||
|  |             { | ||||||
|  |                 fechaDesde = fechaDesde.Date, | ||||||
|  |                 fechaHasta = fechaHasta.Date, | ||||||
|  |                 accionista = esAccionista | ||||||
|  |             }; | ||||||
|  |             return await connection.QueryAsync<ListadoDistCanMensualPubDto>( | ||||||
|  |                 "dbo.SP_DistCanillasAccConImporteEntreFechas", | ||||||
|  |                 parameters, | ||||||
|  |                 commandType: CommandType.StoredProcedure | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
							
								
								
									
										9
									
								
								Backend/GestionIntegral.Api/Models/Contables/Saldo.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Backend/GestionIntegral.Api/Models/Contables/Saldo.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | public class Saldo | ||||||
|  | { | ||||||
|  |     public int IdSaldo { get; set; } | ||||||
|  |     public string Destino { get; set; } = string.Empty; | ||||||
|  |     public int IdDestino { get; set; } | ||||||
|  |     public decimal Monto { get; set; } | ||||||
|  |     public int IdEmpresa { get; set; } | ||||||
|  |     public DateTime FechaUltimaModificacion { get; set; } | ||||||
|  | } | ||||||
| @@ -0,0 +1,19 @@ | |||||||
|  | using System; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Models.Contables | ||||||
|  | { | ||||||
|  |     public class SaldoAjusteHistorial | ||||||
|  |     { | ||||||
|  |         public int IdSaldoAjusteHist { get; set; } // PK, Identity | ||||||
|  |         public string Destino { get; set; } = string.Empty; | ||||||
|  |         public int IdDestino { get; set; } | ||||||
|  |         public int IdEmpresa { get; set; } | ||||||
|  |         public decimal MontoAjuste { get; set; } // El monto que se sumó/restó | ||||||
|  |         public decimal SaldoAnterior { get; set; } | ||||||
|  |         public decimal SaldoNuevo { get; set; } | ||||||
|  |         public string Justificacion { get; set; } = string.Empty; | ||||||
|  |         public DateTime FechaAjuste { get; set; } | ||||||
|  |         public int IdUsuarioAjuste { get; set; } | ||||||
|  |         // Podrías añadir NombreUsuarioAjuste si quieres desnormalizar o hacer un JOIN al consultar | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | namespace GestionIntegral.Api.Models.Distribucion | ||||||
|  | { | ||||||
|  |     public class NovedadCanilla | ||||||
|  |     { | ||||||
|  |         public int IdNovedad { get; set; } | ||||||
|  |         public int IdCanilla { get; set; } | ||||||
|  |         public DateTime Fecha { get; set; } | ||||||
|  |         public string? Detalle { get; set; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,14 @@ | |||||||
|  | namespace GestionIntegral.Api.Models.Distribucion | ||||||
|  | { | ||||||
|  |     public class NovedadCanillaHistorial | ||||||
|  |     { | ||||||
|  |         // No tiene un ID propio, es una tabla de historial puro | ||||||
|  |         public int IdNovedad { get; set; } // FK a la novedad original | ||||||
|  |         public int IdCanilla { get; set; } | ||||||
|  |         public DateTime Fecha { get; set; } | ||||||
|  |         public string? Detalle { get; set; } | ||||||
|  |         public int IdUsuario { get; set; }    // Quién hizo el cambio | ||||||
|  |         public DateTime FechaMod { get; set; } // Cuándo se hizo el cambio | ||||||
|  |         public required string TipoMod { get; set; }    // "Insertada", "Modificada", "Eliminada" | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Dtos.Contables | ||||||
|  | { | ||||||
|  |     public class AjusteSaldoRequestDto | ||||||
|  |     { | ||||||
|  |         [Required(ErrorMessage = "El tipo de destino es obligatorio ('Distribuidores' o 'Canillas').")] | ||||||
|  |         [RegularExpression("^(Distribuidores|Canillas)$", ErrorMessage = "Destino debe ser 'Distribuidores' o 'Canillas'.")] | ||||||
|  |         public string Destino { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|  |         [Required(ErrorMessage = "El ID del destinatario es obligatorio.")] | ||||||
|  |         [Range(1, int.MaxValue, ErrorMessage = "ID de Destinatario inválido.")] | ||||||
|  |         public int IdDestino { get; set; } | ||||||
|  |  | ||||||
|  |         [Required(ErrorMessage = "El ID de la empresa es obligatorio.")] | ||||||
|  |         [Range(1, int.MaxValue, ErrorMessage = "ID de Empresa inválido.")] | ||||||
|  |         public int IdEmpresa { get; set; } | ||||||
|  |  | ||||||
|  |         [Required(ErrorMessage = "El monto del ajuste es obligatorio.")] | ||||||
|  |         // Permitir montos negativos para disminuir deuda o positivos para aumentarla | ||||||
|  |         // No se usa Range aquí para permitir ambos signos. La validación de que no sea cero se puede hacer en el servicio. | ||||||
|  |         public decimal MontoAjuste { get; set; }  | ||||||
|  |  | ||||||
|  |         [Required(ErrorMessage = "La justificación del ajuste es obligatoria.")] | ||||||
|  |         [StringLength(250, MinimumLength = 5, ErrorMessage = "La justificación debe tener entre 5 y 250 caracteres.")] | ||||||
|  |         public string Justificacion { get; set; } = string.Empty; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,16 @@ | |||||||
|  | using System; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Dtos.Contables | ||||||
|  | { | ||||||
|  |     public class SaldoGestionDto | ||||||
|  |     { | ||||||
|  |         public int IdSaldo { get; set; } | ||||||
|  |         public string Destino { get; set; } = string.Empty; | ||||||
|  |         public int IdDestino { get; set; } | ||||||
|  |         public string NombreDestinatario { get; set; } = string.Empty; | ||||||
|  |         public int IdEmpresa { get; set; } | ||||||
|  |         public string NombreEmpresa { get; set; } = string.Empty; | ||||||
|  |         public decimal Monto { get; set; } | ||||||
|  |         public DateTime FechaUltimaModificacion { get; set; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,19 @@ | |||||||
|  | using System; | ||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Dtos.Distribucion | ||||||
|  | { | ||||||
|  |     public class CreateNovedadCanillaDto | ||||||
|  |     { | ||||||
|  |         // IdCanilla se tomará de la ruta o de un campo oculto si el POST es a un endpoint genérico. | ||||||
|  |         // Por ahora, lo incluimos si el endpoint es /api/novedadescanilla | ||||||
|  |         [Required] | ||||||
|  |         public int IdCanilla { get; set; } | ||||||
|  |  | ||||||
|  |         [Required] | ||||||
|  |         public DateTime Fecha { get; set; } | ||||||
|  |  | ||||||
|  |         [MaxLength(250)] | ||||||
|  |         public string? Detalle { get; set; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | namespace GestionIntegral.Api.Dtos.Distribucion | ||||||
|  | { | ||||||
|  |     public class DistribuidorDropdownDto | ||||||
|  |     { | ||||||
|  |         public int IdDistribuidor { get; set; } | ||||||
|  |         public string Nombre { get; set; } = string.Empty; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | namespace GestionIntegral.Api.Dtos.Distribucion | ||||||
|  | { | ||||||
|  |     public class DistribuidorLookupDto | ||||||
|  |     { | ||||||
|  |         public int IdDistribuidor { get; set; } | ||||||
|  |         public string Nombre { get; set; } = string.Empty; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | namespace GestionIntegral.Api.Dtos.Empresas | ||||||
|  | { | ||||||
|  |     public class EmpresaDropdownDto | ||||||
|  |     { | ||||||
|  |         public int IdEmpresa { get; set; } | ||||||
|  |         public string Nombre { get; set; } = string.Empty; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | namespace GestionIntegral.Api.Dtos.Empresas | ||||||
|  | { | ||||||
|  |     public class EmpresaLookupDto | ||||||
|  |     { | ||||||
|  |         public int IdEmpresa { get; set; } | ||||||
|  |         public string Nombre { get; set; } = string.Empty; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,13 @@ | |||||||
|  | using System; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Dtos.Distribucion | ||||||
|  | { | ||||||
|  |     public class NovedadCanillaDto | ||||||
|  |     { | ||||||
|  |         public int IdNovedad { get; set; } | ||||||
|  |         public int IdCanilla { get; set; } | ||||||
|  |         public string NombreCanilla { get; set; } = string.Empty; // Para mostrar en UI | ||||||
|  |         public DateTime Fecha { get; set; } | ||||||
|  |         public string? Detalle { get; set; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,11 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Dtos.Distribucion | ||||||
|  | { | ||||||
|  |     public class UpdateNovedadCanillaDto | ||||||
|  |     { | ||||||
|  |         // No se permite cambiar IdCanilla ni Fecha | ||||||
|  |         [MaxLength(250)] | ||||||
|  |         public string? Detalle { get; set; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,11 @@ | |||||||
|  | namespace GestionIntegral.Api.Dtos.Reportes | ||||||
|  | { | ||||||
|  |     public class CanillaGananciaReporteDto // Nuevo nombre para el DTO | ||||||
|  |     { | ||||||
|  |         public string Canilla { get; set; } = string.Empty; // NomApe del canillita | ||||||
|  |         public int? Legajo { get; set; } | ||||||
|  |         public int? Francos { get; set; } | ||||||
|  |         public int? Faltas { get; set; } | ||||||
|  |         public decimal? TotalRendir { get; set; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,13 @@ | |||||||
|  | namespace GestionIntegral.Api.Dtos.Reportes | ||||||
|  | { | ||||||
|  |     public class ListadoDistCanMensualDiariosDto | ||||||
|  |     { | ||||||
|  |         public string Canilla { get; set; } = string.Empty; | ||||||
|  |         public int? ElDia { get; set; } // Cantidad | ||||||
|  |         public int? ElPlata { get; set; } // Cantidad | ||||||
|  |         public int? Vendidos { get; set; } // Suma de ElDia y ElPlata (cantidades) | ||||||
|  |         public decimal? ImporteElDia { get; set; } | ||||||
|  |         public decimal? ImporteElPlata { get; set; } | ||||||
|  |         public decimal? ImporteTotal { get; set; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,13 @@ | |||||||
|  | namespace GestionIntegral.Api.Dtos.Reportes | ||||||
|  | { | ||||||
|  |     public class ListadoDistCanMensualPubDto | ||||||
|  |     { | ||||||
|  |         public string Publicacion { get; set; } = string.Empty; | ||||||
|  |         public string Canilla { get; set; } = string.Empty; // NomApe | ||||||
|  |         public int? TotalCantSalida { get; set; } | ||||||
|  |         public int? TotalCantEntrada { get; set; } | ||||||
|  |         public decimal? TotalRendir { get; set; } | ||||||
|  |         // No es necesario 'Vendidos' ya que el SP no lo devuelve directamente para esta variante, | ||||||
|  |         // pero se puede calcular en el frontend si es necesario (Llevados - Devueltos). | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,11 @@ | |||||||
|  | using System; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Dtos.Reportes | ||||||
|  | { | ||||||
|  |     public class NovedadesCanillasReporteDto | ||||||
|  |     { | ||||||
|  |         public string NomApe { get; set; } = string.Empty; // Nombre del Canillita | ||||||
|  |         public DateTime Fecha { get; set; } | ||||||
|  |         public string? Detalle { get; set; } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -82,6 +82,10 @@ builder.Services.AddScoped<IRitmoService, RitmoService>(); | |||||||
| builder.Services.AddScoped<ICancionRepository, CancionRepository>(); | builder.Services.AddScoped<ICancionRepository, CancionRepository>(); | ||||||
| builder.Services.AddScoped<ICancionService, CancionService>(); | builder.Services.AddScoped<ICancionService, CancionService>(); | ||||||
| builder.Services.AddScoped<IRadioListaService, RadioListaService>(); | builder.Services.AddScoped<IRadioListaService, RadioListaService>(); | ||||||
|  | builder.Services.AddScoped<INovedadCanillaRepository, NovedadCanillaRepository>(); | ||||||
|  | builder.Services.AddScoped<INovedadCanillaService, NovedadCanillaService>(); | ||||||
|  | // Servicio de Saldos | ||||||
|  | builder.Services.AddScoped<ISaldoService, SaldoService>(); | ||||||
| // Repositorios de Reportes | // Repositorios de Reportes | ||||||
| builder.Services.AddScoped<IReportesRepository, ReportesRepository>(); | builder.Services.AddScoped<IReportesRepository, ReportesRepository>(); | ||||||
| // Servicios de Reportes | // Servicios de Reportes | ||||||
| @@ -199,7 +203,7 @@ if (app.Environment.IsDevelopment()) | |||||||
|     }); |     }); | ||||||
| } | } | ||||||
|  |  | ||||||
| // ¡¡¡NO USAR UseHttpsRedirection si tu API corre en HTTP!!! | // ¡¡¡NO USAR UseHttpsRedirection si la API corre en HTTP!!! | ||||||
| // Comenta o elimina la siguiente línea si SÓLO usas http://localhost:5183 | // Comenta o elimina la siguiente línea si SÓLO usas http://localhost:5183 | ||||||
| // app.UseHttpsRedirection(); | // app.UseHttpsRedirection(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,12 @@ | |||||||
|  | using GestionIntegral.Api.Dtos.Contables; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Services.Contables | ||||||
|  | { | ||||||
|  |     public interface ISaldoService | ||||||
|  |     { | ||||||
|  |         Task<IEnumerable<SaldoGestionDto>> ObtenerSaldosParaGestionAsync(string? destinoFilter, int? idDestinoFilter, int? idEmpresaFilter); | ||||||
|  |         Task<(bool Exito, string? Error, SaldoGestionDto? SaldoActualizado)> RealizarAjusteManualSaldoAsync(AjusteSaldoRequestDto ajusteDto, int idUsuarioAjuste); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -52,7 +52,7 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|             } |             } | ||||||
|             else if (nota.Destino == "Canillas") |             else if (nota.Destino == "Canillas") | ||||||
|             { |             { | ||||||
|                 var canData = await _canillaRepo.GetByIdAsync(nota.IdDestino); // Asumiendo que GetByIdAsync devuelve una tupla |                 var canData = await _canillaRepo.GetByIdAsync(nota.IdDestino); | ||||||
|                 nombreDestinatario = canData.Canilla?.NomApe ?? "Canillita Desconocido"; |                 nombreDestinatario = canData.Canilla?.NomApe ?? "Canillita Desconocido"; | ||||||
|             } |             } | ||||||
|              |              | ||||||
| @@ -95,7 +95,6 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|  |  | ||||||
|         public async Task<(NotaCreditoDebitoDto? Nota, string? Error)> CrearAsync(CreateNotaDto createDto, int idUsuario) |         public async Task<(NotaCreditoDebitoDto? Nota, string? Error)> CrearAsync(CreateNotaDto createDto, int idUsuario) | ||||||
|         { |         { | ||||||
|             // Validar Destinatario |  | ||||||
|             if (createDto.Destino == "Distribuidores") |             if (createDto.Destino == "Distribuidores") | ||||||
|             { |             { | ||||||
|                 if (await _distribuidorRepo.GetByIdSimpleAsync(createDto.IdDestino) == null) |                 if (await _distribuidorRepo.GetByIdSimpleAsync(createDto.IdDestino) == null) | ||||||
| @@ -103,7 +102,7 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|             } |             } | ||||||
|             else if (createDto.Destino == "Canillas") |             else if (createDto.Destino == "Canillas") | ||||||
|             { |             { | ||||||
|                 if (await _canillaRepo.GetByIdSimpleAsync(createDto.IdDestino) == null) // Asumiendo GetByIdSimpleAsync en ICanillaRepository |                 if (await _canillaRepo.GetByIdSimpleAsync(createDto.IdDestino) == null)  | ||||||
|                     return (null, "El canillita especificado no existe."); |                     return (null, "El canillita especificado no existe."); | ||||||
|             } |             } | ||||||
|             else { return (null, "Tipo de destino inválido."); } |             else { return (null, "Tipo de destino inválido."); } | ||||||
| @@ -124,19 +123,29 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); |             IDbTransaction? transaction = null; | ||||||
|             using var transaction = connection.BeginTransaction(); |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|  |                 if (connection.State != ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); | ||||||
|  |                 } | ||||||
|  |                 transaction = connection.BeginTransaction(); | ||||||
|  |                  | ||||||
|                 var notaCreada = await _notaRepo.CreateAsync(nuevaNota, idUsuario, transaction); |                 var notaCreada = await _notaRepo.CreateAsync(nuevaNota, idUsuario, transaction); | ||||||
|                 if (notaCreada == null) throw new DataException("Error al registrar la nota."); |                 if (notaCreada == null) throw new DataException("Error al registrar la nota."); | ||||||
|  |  | ||||||
|                 // Afectar Saldo |                 decimal montoParaSaldo; | ||||||
|                 // Nota de Crédito: Disminuye la deuda del destinatario (monto positivo para el servicio de saldo) |                 if (createDto.Tipo == "Credito")  | ||||||
|                 // Nota de Débito: Aumenta la deuda del destinatario (monto negativo para el servicio de saldo) |                 { | ||||||
|                 decimal montoAjusteSaldo = createDto.Tipo == "Credito" ? createDto.Monto : -createDto.Monto; |                     montoParaSaldo = -createDto.Monto; | ||||||
|  |                 } | ||||||
|  |                 else  | ||||||
|  |                 { | ||||||
|  |                     montoParaSaldo = createDto.Monto; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 bool saldoActualizado = await _saldoRepo.ModificarSaldoAsync(notaCreada.Destino, notaCreada.IdDestino, notaCreada.IdEmpresa, montoAjusteSaldo, transaction); |                 bool saldoActualizado = await _saldoRepo.ModificarSaldoAsync(notaCreada.Destino, notaCreada.IdDestino, notaCreada.IdEmpresa, montoParaSaldo, transaction); | ||||||
|                 if (!saldoActualizado) throw new DataException($"Error al actualizar el saldo para {notaCreada.Destino} ID {notaCreada.IdDestino}."); |                 if (!saldoActualizado) throw new DataException($"Error al actualizar el saldo para {notaCreada.Destino} ID {notaCreada.IdDestino}."); | ||||||
|  |  | ||||||
|                 transaction.Commit(); |                 transaction.Commit(); | ||||||
| @@ -145,32 +154,57 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de CrearAsync NotaCreditoDebito."); } | ||||||
|                 _logger.LogError(ex, "Error CrearAsync NotaCreditoDebito."); |                 _logger.LogError(ex, "Error CrearAsync NotaCreditoDebito."); | ||||||
|                 return (null, $"Error interno: {ex.Message}"); |                 return (null, $"Error interno: {ex.Message}"); | ||||||
|             } |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (connection.State == ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<(bool Exito, string? Error)> ActualizarAsync(int idNota, UpdateNotaDto updateDto, int idUsuario) |         public async Task<(bool Exito, string? Error)> ActualizarAsync(int idNota, UpdateNotaDto updateDto, int idUsuario) | ||||||
|         { |         { | ||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); |             IDbTransaction? transaction = null; | ||||||
|             using var transaction = connection.BeginTransaction(); |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|  |                 if (connection.State != ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); | ||||||
|  |                 } | ||||||
|  |                 transaction = connection.BeginTransaction(); | ||||||
|  |                  | ||||||
|                 var notaExistente = await _notaRepo.GetByIdAsync(idNota);  |                 var notaExistente = await _notaRepo.GetByIdAsync(idNota);  | ||||||
|                 if (notaExistente == null) return (false, "Nota no encontrada."); |                 if (notaExistente == null)  | ||||||
|  |                 { | ||||||
|  |                     transaction.Rollback(); | ||||||
|  |                     return (false, "Nota no encontrada."); | ||||||
|  |                 } | ||||||
|                  |                  | ||||||
|                 // Calcular diferencia de monto para ajustar saldo |                 decimal impactoOriginalSaldo = notaExistente.Tipo == "Credito" ? -notaExistente.Monto : notaExistente.Monto; | ||||||
|                 decimal montoOriginal = notaExistente.Tipo == "Credito" ? notaExistente.Monto : -notaExistente.Monto; |                 decimal impactoNuevoSaldo = notaExistente.Tipo == "Credito" ? -updateDto.Monto : updateDto.Monto; | ||||||
|                 decimal montoNuevo = notaExistente.Tipo == "Credito" ? updateDto.Monto : -updateDto.Monto; // Tipo no cambia |                 decimal diferenciaAjusteSaldo = impactoNuevoSaldo - impactoOriginalSaldo; | ||||||
|                 decimal diferenciaAjusteSaldo = montoNuevo - montoOriginal; |  | ||||||
|  |  | ||||||
|                 notaExistente.Monto = updateDto.Monto; |                 var notaParaActualizarEnRepo = new NotaCreditoDebito | ||||||
|                 notaExistente.Observaciones = updateDto.Observaciones; |                 { | ||||||
|  |                     IdNota = notaExistente.IdNota, | ||||||
|  |                     Destino = notaExistente.Destino,  | ||||||
|  |                     IdDestino = notaExistente.IdDestino,  | ||||||
|  |                     Referencia = notaExistente.Referencia,  | ||||||
|  |                     Tipo = notaExistente.Tipo,  | ||||||
|  |                     Fecha = notaExistente.Fecha,  | ||||||
|  |                     Monto = updateDto.Monto,  | ||||||
|  |                     Observaciones = updateDto.Observaciones,  | ||||||
|  |                     IdEmpresa = notaExistente.IdEmpresa  | ||||||
|  |                 }; | ||||||
|  |  | ||||||
|                 var actualizado = await _notaRepo.UpdateAsync(notaExistente, idUsuario, transaction); |                 var actualizado = await _notaRepo.UpdateAsync(notaParaActualizarEnRepo, idUsuario, transaction); | ||||||
|                 if (!actualizado) throw new DataException("Error al actualizar la nota."); |                 if (!actualizado) throw new DataException("Error al actualizar la nota en la base de datos."); | ||||||
|  |  | ||||||
|                 if (diferenciaAjusteSaldo != 0) |                 if (diferenciaAjusteSaldo != 0) | ||||||
|                 { |                 { | ||||||
| @@ -182,30 +216,45 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|                 _logger.LogInformation("NotaC/D ID {Id} actualizada por Usuario ID {UserId}.", idNota, idUsuario); |                 _logger.LogInformation("NotaC/D ID {Id} actualizada por Usuario ID {UserId}.", idNota, idUsuario); | ||||||
|                 return (true, null); |                 return (true, null); | ||||||
|             } |             } | ||||||
|             catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Nota no encontrada."); } |             catch (KeyNotFoundException) { try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ActualizarAsync NotaCreditoDebito (KeyNotFound)."); } return (false, "Nota no encontrada."); } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ActualizarAsync NotaCreditoDebito."); } | ||||||
|                 _logger.LogError(ex, "Error ActualizarAsync Nota C/D ID: {Id}", idNota); |                 _logger.LogError(ex, "Error ActualizarAsync Nota C/D ID: {Id}", idNota); | ||||||
|                 return (false, $"Error interno: {ex.Message}"); |                 return (false, $"Error interno: {ex.Message}"); | ||||||
|             } |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (connection.State == ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                    if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<(bool Exito, string? Error)> EliminarAsync(int idNota, int idUsuario) |         public async Task<(bool Exito, string? Error)> EliminarAsync(int idNota, int idUsuario) | ||||||
|         { |         { | ||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); |             IDbTransaction? transaction = null; | ||||||
|             using var transaction = connection.BeginTransaction(); |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 var notaExistente = await _notaRepo.GetByIdAsync(idNota); |                 if (connection.State != ConnectionState.Open) | ||||||
|                 if (notaExistente == null) return (false, "Nota no encontrada."); |                 { | ||||||
|  |                      if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); | ||||||
|  |                 } | ||||||
|  |                 transaction = connection.BeginTransaction(); | ||||||
|  |  | ||||||
|                 // Revertir el efecto en el saldo |                 var notaExistente = await _notaRepo.GetByIdAsync(idNota); | ||||||
|                 decimal montoReversion = notaExistente.Tipo == "Credito" ? -notaExistente.Monto : notaExistente.Monto; |                 if (notaExistente == null)  | ||||||
|  |                 { | ||||||
|  |                     transaction.Rollback(); | ||||||
|  |                     return (false, "Nota no encontrada."); | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 decimal montoReversion = notaExistente.Tipo == "Credito" ? notaExistente.Monto : -notaExistente.Monto; | ||||||
|  |  | ||||||
|                 var eliminado = await _notaRepo.DeleteAsync(idNota, idUsuario, transaction); |                 var eliminado = await _notaRepo.DeleteAsync(idNota, idUsuario, transaction); | ||||||
|                 if (!eliminado) throw new DataException("Error al eliminar la nota."); |                 if (!eliminado) throw new DataException("Error al eliminar la nota de la base de datos."); | ||||||
|  |  | ||||||
|                 bool saldoActualizado = await _saldoRepo.ModificarSaldoAsync(notaExistente.Destino, notaExistente.IdDestino, notaExistente.IdEmpresa, montoReversion, transaction); |                 bool saldoActualizado = await _saldoRepo.ModificarSaldoAsync(notaExistente.Destino, notaExistente.IdDestino, notaExistente.IdEmpresa, montoReversion, transaction); | ||||||
|                 if (!saldoActualizado) throw new DataException("Error al revertir el saldo tras la eliminación de la nota."); |                 if (!saldoActualizado) throw new DataException("Error al revertir el saldo tras la eliminación de la nota."); | ||||||
| @@ -214,13 +263,20 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|                 _logger.LogInformation("NotaC/D ID {Id} eliminada y saldo revertido por Usuario ID {UserId}.", idNota, idUsuario); |                 _logger.LogInformation("NotaC/D ID {Id} eliminada y saldo revertido por Usuario ID {UserId}.", idNota, idUsuario); | ||||||
|                 return (true, null); |                 return (true, null); | ||||||
|             } |             } | ||||||
|             catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Nota no encontrada."); } |             catch (KeyNotFoundException) { try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de EliminarAsync NotaCreditoDebito (KeyNotFound)."); } return (false, "Nota no encontrada."); } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de EliminarAsync NotaCreditoDebito."); } | ||||||
|                 _logger.LogError(ex, "Error EliminarAsync NotaC/D ID: {Id}", idNota); |                 _logger.LogError(ex, "Error EliminarAsync NotaC/D ID: {Id}", idNota); | ||||||
|                 return (false, $"Error interno: {ex.Message}"); |                 return (false, $"Error interno: {ex.Message}"); | ||||||
|             } |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (connection.State == ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -95,7 +95,6 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|             if (await _pagoRepo.ExistsByReciboAndTipoMovimientoAsync(createDto.Recibo, createDto.TipoMovimiento)) |             if (await _pagoRepo.ExistsByReciboAndTipoMovimientoAsync(createDto.Recibo, createDto.TipoMovimiento)) | ||||||
|                  return (null, $"Ya existe un pago '{createDto.TipoMovimiento}' con el número de recibo '{createDto.Recibo}'."); |                  return (null, $"Ya existe un pago '{createDto.TipoMovimiento}' con el número de recibo '{createDto.Recibo}'."); | ||||||
|  |  | ||||||
|  |  | ||||||
|             var nuevoPago = new PagoDistribuidor |             var nuevoPago = new PagoDistribuidor | ||||||
|             { |             { | ||||||
|                 IdDistribuidor = createDto.IdDistribuidor, |                 IdDistribuidor = createDto.IdDistribuidor, | ||||||
| @@ -109,19 +108,29 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); |             IDbTransaction? transaction = null; // Declarar fuera para el finally | ||||||
|             using var transaction = connection.BeginTransaction(); |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|  |                 if (connection.State != ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); | ||||||
|  |                 } | ||||||
|  |                 transaction = connection.BeginTransaction(); | ||||||
|  |                  | ||||||
|                 var pagoCreado = await _pagoRepo.CreateAsync(nuevoPago, idUsuario, transaction); |                 var pagoCreado = await _pagoRepo.CreateAsync(nuevoPago, idUsuario, transaction); | ||||||
|                 if (pagoCreado == null) throw new DataException("Error al registrar el pago."); |                 if (pagoCreado == null) throw new DataException("Error al registrar el pago."); | ||||||
|  |  | ||||||
|                 // Afectar Saldo |                 decimal montoParaSaldo; | ||||||
|                 // Si TipoMovimiento es "Recibido", el monto DISMINUYE la deuda del distribuidor (monto positivo para el servicio de saldo). |                 if (createDto.TipoMovimiento == "Recibido") | ||||||
|                 // Si TipoMovimiento es "Realizado" (empresa paga a distribuidor), el monto AUMENTA la deuda (monto negativo para el servicio de saldo). |                 { | ||||||
|                 decimal montoAjusteSaldo = createDto.TipoMovimiento == "Recibido" ? createDto.Monto : -createDto.Monto; |                     montoParaSaldo = -createDto.Monto;  | ||||||
|  |                 } | ||||||
|  |                 else  | ||||||
|  |                 { | ||||||
|  |                     montoParaSaldo = createDto.Monto;  | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 bool saldoActualizado = await _saldoRepo.ModificarSaldoAsync("Distribuidores", pagoCreado.IdDistribuidor, pagoCreado.IdEmpresa, montoAjusteSaldo, transaction); |                 bool saldoActualizado = await _saldoRepo.ModificarSaldoAsync("Distribuidores", pagoCreado.IdDistribuidor, pagoCreado.IdEmpresa, montoParaSaldo, transaction); | ||||||
|                 if (!saldoActualizado) throw new DataException("Error al actualizar el saldo del distribuidor."); |                 if (!saldoActualizado) throw new DataException("Error al actualizar el saldo del distribuidor."); | ||||||
|  |  | ||||||
|                 transaction.Commit(); |                 transaction.Commit(); | ||||||
| @@ -130,37 +139,63 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de CrearAsync PagoDistribuidor."); } | ||||||
|                 _logger.LogError(ex, "Error CrearAsync PagoDistribuidor."); |                 _logger.LogError(ex, "Error CrearAsync PagoDistribuidor."); | ||||||
|                 return (null, $"Error interno: {ex.Message}"); |                 return (null, $"Error interno: {ex.Message}"); | ||||||
|             } |             } | ||||||
|  |             finally  | ||||||
|  |             { | ||||||
|  |                 if (connection.State == ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<(bool Exito, string? Error)> ActualizarAsync(int idPago, UpdatePagoDistribuidorDto updateDto, int idUsuario) |         public async Task<(bool Exito, string? Error)> ActualizarAsync(int idPago, UpdatePagoDistribuidorDto updateDto, int idUsuario) | ||||||
|         { |         { | ||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); |             IDbTransaction? transaction = null; | ||||||
|             using var transaction = connection.BeginTransaction(); |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|  |                 if (connection.State != ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); | ||||||
|  |                 } | ||||||
|  |                 transaction = connection.BeginTransaction(); | ||||||
|  |  | ||||||
|                 var pagoExistente = await _pagoRepo.GetByIdAsync(idPago);  |                 var pagoExistente = await _pagoRepo.GetByIdAsync(idPago);  | ||||||
|                 if (pagoExistente == null) return (false, "Pago no encontrado."); |                 if (pagoExistente == null)  | ||||||
|  |                 { | ||||||
|  |                     transaction.Rollback(); // Rollback si no se encuentra | ||||||
|  |                     return (false, "Pago no encontrado."); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 if (await _tipoPagoRepo.GetByIdAsync(updateDto.IdTipoPago) == null) |                 if (await _tipoPagoRepo.GetByIdAsync(updateDto.IdTipoPago) == null) | ||||||
|  |                 { | ||||||
|  |                     transaction.Rollback(); | ||||||
|                     return (false, "Tipo de pago no válido."); |                     return (false, "Tipo de pago no válido."); | ||||||
|  |                 } | ||||||
|                  |                  | ||||||
|                 // Calcular la diferencia de monto para ajustar el saldo |                 decimal impactoOriginalSaldo = pagoExistente.TipoMovimiento == "Recibido" ? -pagoExistente.Monto : pagoExistente.Monto; | ||||||
|                 decimal montoOriginal = pagoExistente.TipoMovimiento == "Recibido" ? pagoExistente.Monto : -pagoExistente.Monto; |                 decimal impactoNuevoSaldo = pagoExistente.TipoMovimiento == "Recibido" ? -updateDto.Monto : updateDto.Monto; | ||||||
|                 decimal montoNuevo = pagoExistente.TipoMovimiento == "Recibido" ? updateDto.Monto : -updateDto.Monto; |                 decimal diferenciaAjusteSaldo = impactoNuevoSaldo - impactoOriginalSaldo; | ||||||
|                 decimal diferenciaAjusteSaldo = montoNuevo - montoOriginal; |  | ||||||
|  |  | ||||||
|                 // Actualizar campos permitidos |                 var pagoParaActualizarEnRepo = new PagoDistribuidor | ||||||
|                 pagoExistente.Monto = updateDto.Monto; |                 { | ||||||
|                 pagoExistente.IdTipoPago = updateDto.IdTipoPago; |                     IdPago = pagoExistente.IdPago, | ||||||
|                 pagoExistente.Detalle = updateDto.Detalle; |                     IdDistribuidor = pagoExistente.IdDistribuidor,  | ||||||
|  |                     Fecha = pagoExistente.Fecha,                    | ||||||
|  |                     TipoMovimiento = pagoExistente.TipoMovimiento,  | ||||||
|  |                     Recibo = pagoExistente.Recibo,                  | ||||||
|  |                     Monto = updateDto.Monto,                        | ||||||
|  |                     IdTipoPago = updateDto.IdTipoPago,              | ||||||
|  |                     Detalle = updateDto.Detalle,                    | ||||||
|  |                     IdEmpresa = pagoExistente.IdEmpresa             | ||||||
|  |                 }; | ||||||
|  |  | ||||||
|                 var actualizado = await _pagoRepo.UpdateAsync(pagoExistente, idUsuario, transaction); |                 var actualizado = await _pagoRepo.UpdateAsync(pagoParaActualizarEnRepo, idUsuario, transaction); | ||||||
|                 if (!actualizado) throw new DataException("Error al actualizar el pago."); |                 if (!actualizado) throw new DataException("Error al actualizar el pago en la base de datos."); | ||||||
|  |  | ||||||
|                 if (diferenciaAjusteSaldo != 0) |                 if (diferenciaAjusteSaldo != 0) | ||||||
|                 { |                 { | ||||||
| @@ -172,32 +207,45 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|                 _logger.LogInformation("PagoDistribuidor ID {Id} actualizado por Usuario ID {UserId}.", idPago, idUsuario); |                 _logger.LogInformation("PagoDistribuidor ID {Id} actualizado por Usuario ID {UserId}.", idPago, idUsuario); | ||||||
|                 return (true, null); |                 return (true, null); | ||||||
|             } |             } | ||||||
|             catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Pago no encontrado."); } |             catch (KeyNotFoundException) { try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ActualizarAsync PagoDistribuidor (KeyNotFound)."); } return (false, "Pago no encontrado."); } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ActualizarAsync PagoDistribuidor."); } | ||||||
|                 _logger.LogError(ex, "Error ActualizarAsync PagoDistribuidor ID: {Id}", idPago); |                 _logger.LogError(ex, "Error ActualizarAsync PagoDistribuidor ID: {Id}", idPago); | ||||||
|                 return (false, $"Error interno: {ex.Message}"); |                 return (false, $"Error interno: {ex.Message}"); | ||||||
|             } |             } | ||||||
|  |              finally | ||||||
|  |             { | ||||||
|  |                 if (connection.State == ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<(bool Exito, string? Error)> EliminarAsync(int idPago, int idUsuario) |         public async Task<(bool Exito, string? Error)> EliminarAsync(int idPago, int idUsuario) | ||||||
|         { |         { | ||||||
|             using var connection = _connectionFactory.CreateConnection(); |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|             if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); |             IDbTransaction? transaction = null; | ||||||
|             using var transaction = connection.BeginTransaction(); |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 var pagoExistente = await _pagoRepo.GetByIdAsync(idPago); |                 if (connection.State != ConnectionState.Open) | ||||||
|                 if (pagoExistente == null) return (false, "Pago no encontrado."); |                 { | ||||||
|  |                      if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); | ||||||
|  |                 } | ||||||
|  |                 transaction = connection.BeginTransaction(); | ||||||
|                  |                  | ||||||
|                 // Revertir el efecto en el saldo |                 var pagoExistente = await _pagoRepo.GetByIdAsync(idPago); | ||||||
|                 // Si fue "Recibido", el saldo disminuyó (montoAjusteSaldo fue +Monto). Al eliminar, revertimos sumando -Monto (o restando +Monto). |                 if (pagoExistente == null)  | ||||||
|                 // Si fue "Realizado", el saldo aumentó (montoAjusteSaldo fue -Monto). Al eliminar, revertimos sumando +Monto (o restando -Monto). |                 { | ||||||
|                 decimal montoReversion = pagoExistente.TipoMovimiento == "Recibido" ? -pagoExistente.Monto : pagoExistente.Monto; |                     transaction.Rollback(); | ||||||
|  |                     return (false, "Pago no encontrado."); | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 decimal montoReversion = pagoExistente.TipoMovimiento == "Recibido" ? pagoExistente.Monto : -pagoExistente.Monto; | ||||||
|  |  | ||||||
|                 var eliminado = await _pagoRepo.DeleteAsync(idPago, idUsuario, transaction); |                 var eliminado = await _pagoRepo.DeleteAsync(idPago, idUsuario, transaction); | ||||||
|                 if (!eliminado) throw new DataException("Error al eliminar el pago."); |                 if (!eliminado) throw new DataException("Error al eliminar el pago de la base de datos."); | ||||||
|  |  | ||||||
|                 bool saldoActualizado = await _saldoRepo.ModificarSaldoAsync("Distribuidores", pagoExistente.IdDistribuidor, pagoExistente.IdEmpresa, montoReversion, transaction); |                 bool saldoActualizado = await _saldoRepo.ModificarSaldoAsync("Distribuidores", pagoExistente.IdDistribuidor, pagoExistente.IdEmpresa, montoReversion, transaction); | ||||||
|                 if (!saldoActualizado) throw new DataException("Error al revertir el saldo del distribuidor tras la eliminación del pago."); |                 if (!saldoActualizado) throw new DataException("Error al revertir el saldo del distribuidor tras la eliminación del pago."); | ||||||
| @@ -206,13 +254,20 @@ namespace GestionIntegral.Api.Services.Contables | |||||||
|                 _logger.LogInformation("PagoDistribuidor ID {Id} eliminado por Usuario ID {UserId}.", idPago, idUsuario); |                 _logger.LogInformation("PagoDistribuidor ID {Id} eliminado por Usuario ID {UserId}.", idPago, idUsuario); | ||||||
|                 return (true, null); |                 return (true, null); | ||||||
|             } |             } | ||||||
|             catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Pago no encontrado."); } |             catch (KeyNotFoundException) { try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de EliminarAsync PagoDistribuidor (KeyNotFound)."); } return (false, "Pago no encontrado."); } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de EliminarAsync PagoDistribuidor."); } | ||||||
|                 _logger.LogError(ex, "Error EliminarAsync PagoDistribuidor ID: {Id}", idPago); |                 _logger.LogError(ex, "Error EliminarAsync PagoDistribuidor ID: {Id}", idPago); | ||||||
|                 return (false, $"Error interno: {ex.Message}"); |                 return (false, $"Error interno: {ex.Message}"); | ||||||
|             } |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (connection.State == ConnectionState.Open) | ||||||
|  |                 { | ||||||
|  |                     if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
							
								
								
									
										164
									
								
								Backend/GestionIntegral.Api/Services/Contables/SaldoService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								Backend/GestionIntegral.Api/Services/Contables/SaldoService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | |||||||
|  | using GestionIntegral.Api.Data; | ||||||
|  | using GestionIntegral.Api.Data.Repositories.Contables; | ||||||
|  | using GestionIntegral.Api.Data.Repositories.Distribucion; // Para IDistribuidorRepository, ICanillaRepository | ||||||
|  | using GestionIntegral.Api.Dtos.Contables; | ||||||
|  | using GestionIntegral.Api.Models.Contables; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Data; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Services.Contables | ||||||
|  | { | ||||||
|  |     public class SaldoService : ISaldoService | ||||||
|  |     { | ||||||
|  |         private readonly ISaldoRepository _saldoRepo; | ||||||
|  |         private readonly IDistribuidorRepository _distribuidorRepo; // Para nombres | ||||||
|  |         private readonly ICanillaRepository _canillaRepo;         // Para nombres | ||||||
|  |         private readonly IEmpresaRepository _empresaRepo;           // Para nombres | ||||||
|  |         private readonly DbConnectionFactory _connectionFactory; | ||||||
|  |         private readonly ILogger<SaldoService> _logger; | ||||||
|  |  | ||||||
|  |         public SaldoService( | ||||||
|  |             ISaldoRepository saldoRepo, | ||||||
|  |             IDistribuidorRepository distribuidorRepo, | ||||||
|  |             ICanillaRepository canillaRepo, | ||||||
|  |             IEmpresaRepository empresaRepo, | ||||||
|  |             DbConnectionFactory connectionFactory, | ||||||
|  |             ILogger<SaldoService> logger) | ||||||
|  |         { | ||||||
|  |             _saldoRepo = saldoRepo; | ||||||
|  |             _distribuidorRepo = distribuidorRepo; | ||||||
|  |             _canillaRepo = canillaRepo; | ||||||
|  |             _empresaRepo = empresaRepo; | ||||||
|  |             _connectionFactory = connectionFactory; | ||||||
|  |             _logger = logger; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private async Task<SaldoGestionDto> MapToGestionDto(Saldo saldo) | ||||||
|  |         { | ||||||
|  |             if (saldo == null) return null!; | ||||||
|  |  | ||||||
|  |             string nombreDestinatario = "N/A"; | ||||||
|  |             if (saldo.Destino == "Distribuidores") | ||||||
|  |             { | ||||||
|  |                 var distData = await _distribuidorRepo.GetByIdAsync(saldo.IdDestino); | ||||||
|  |                 nombreDestinatario = distData.Distribuidor?.Nombre ?? $"Dist. ID {saldo.IdDestino}"; | ||||||
|  |             } | ||||||
|  |             else if (saldo.Destino == "Canillas") | ||||||
|  |             { | ||||||
|  |                 var canData = await _canillaRepo.GetByIdAsync(saldo.IdDestino); | ||||||
|  |                 nombreDestinatario = canData.Canilla?.NomApe ?? $"Can. ID {saldo.IdDestino}"; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             var empresa = await _empresaRepo.GetByIdAsync(saldo.IdEmpresa); | ||||||
|  |  | ||||||
|  |             return new SaldoGestionDto | ||||||
|  |             { | ||||||
|  |                 IdSaldo = saldo.IdSaldo, | ||||||
|  |                 Destino = saldo.Destino, | ||||||
|  |                 IdDestino = saldo.IdDestino, | ||||||
|  |                 NombreDestinatario = nombreDestinatario, | ||||||
|  |                 IdEmpresa = saldo.IdEmpresa, | ||||||
|  |                 NombreEmpresa = empresa?.Nombre ?? $"Emp. ID {saldo.IdEmpresa}", | ||||||
|  |                 Monto = saldo.Monto, | ||||||
|  |                 FechaUltimaModificacion = saldo.FechaUltimaModificacion | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<SaldoGestionDto>> ObtenerSaldosParaGestionAsync(string? destinoFilter, int? idDestinoFilter, int? idEmpresaFilter) | ||||||
|  |         { | ||||||
|  |             var saldos = await _saldoRepo.GetSaldosParaGestionAsync(destinoFilter, idDestinoFilter, idEmpresaFilter); | ||||||
|  |             var dtos = new List<SaldoGestionDto>(); | ||||||
|  |             foreach (var saldo in saldos) | ||||||
|  |             { | ||||||
|  |                 dtos.Add(await MapToGestionDto(saldo)); | ||||||
|  |             } | ||||||
|  |             return dtos; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<(bool Exito, string? Error, SaldoGestionDto? SaldoActualizado)> RealizarAjusteManualSaldoAsync(AjusteSaldoRequestDto ajusteDto, int idUsuarioAjuste) | ||||||
|  |         { | ||||||
|  |             if (ajusteDto.MontoAjuste == 0) | ||||||
|  |                 return (false, "El monto de ajuste no puede ser cero.", null); | ||||||
|  |  | ||||||
|  |             // Validar existencia de Destino y Empresa | ||||||
|  |             if (ajusteDto.Destino == "Distribuidores") | ||||||
|  |             { | ||||||
|  |                 if (await _distribuidorRepo.GetByIdSimpleAsync(ajusteDto.IdDestino) == null) | ||||||
|  |                     return (false, "El distribuidor especificado no existe.", null); | ||||||
|  |             } | ||||||
|  |             else if (ajusteDto.Destino == "Canillas") | ||||||
|  |             { | ||||||
|  |                 if (await _canillaRepo.GetByIdSimpleAsync(ajusteDto.IdDestino) == null) | ||||||
|  |                     return (false, "El canillita especificado no existe.", null); | ||||||
|  |             } else { | ||||||
|  |                  return (false, "Tipo de destino inválido.", null); | ||||||
|  |             } | ||||||
|  |             if (await _empresaRepo.GetByIdAsync(ajusteDto.IdEmpresa) == null) | ||||||
|  |                  return (false, "La empresa especificada no existe.", null); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             if (connection.State != ConnectionState.Open) { if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); } | ||||||
|  |             using var transaction = connection.BeginTransaction(); | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var saldoActual = await _saldoRepo.GetSaldoAsync(ajusteDto.Destino, ajusteDto.IdDestino, ajusteDto.IdEmpresa, transaction); | ||||||
|  |                 if (saldoActual == null) | ||||||
|  |                 { | ||||||
|  |                     // Podríamos crear el saldo aquí si no existe y se quiere permitir un ajuste sobre un saldo nuevo. | ||||||
|  |                     // O devolver error. Por ahora, error. | ||||||
|  |                     transaction.Rollback(); | ||||||
|  |                     return (false, "No se encontró un saldo existente para el destinatario y empresa especificados.", null); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 decimal saldoAnterior = saldoActual.Monto; | ||||||
|  |                  | ||||||
|  |                 bool modificado = await _saldoRepo.ModificarSaldoAsync(ajusteDto.Destino, ajusteDto.IdDestino, ajusteDto.IdEmpresa, ajusteDto.MontoAjuste, transaction); | ||||||
|  |                 if (!modificado) | ||||||
|  |                 { | ||||||
|  |                     throw new DataException("No se pudo modificar el saldo principal."); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Obtener el saldo después de la modificación para el historial | ||||||
|  |                 var saldoDespuesDeModificacion = await _saldoRepo.GetSaldoAsync(ajusteDto.Destino, ajusteDto.IdDestino, ajusteDto.IdEmpresa, transaction); | ||||||
|  |                 if(saldoDespuesDeModificacion == null) throw new DataException("No se pudo obtener el saldo después de la modificación."); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |                 var historial = new SaldoAjusteHistorial | ||||||
|  |                 { | ||||||
|  |                     Destino = ajusteDto.Destino, | ||||||
|  |                     IdDestino = ajusteDto.IdDestino, | ||||||
|  |                     IdEmpresa = ajusteDto.IdEmpresa, | ||||||
|  |                     MontoAjuste = ajusteDto.MontoAjuste, | ||||||
|  |                     SaldoAnterior = saldoAnterior, | ||||||
|  |                     SaldoNuevo = saldoDespuesDeModificacion.Monto, // saldoActual.Monto + ajusteDto.MontoAjuste, | ||||||
|  |                     Justificacion = ajusteDto.Justificacion, | ||||||
|  |                     FechaAjuste = DateTime.Now, // O UtcNow | ||||||
|  |                     IdUsuarioAjuste = idUsuarioAjuste | ||||||
|  |                 }; | ||||||
|  |                 await _saldoRepo.CreateSaldoAjusteHistorialAsync(historial, transaction); | ||||||
|  |  | ||||||
|  |                 transaction.Commit(); | ||||||
|  |                 _logger.LogInformation("Ajuste manual de saldo realizado para {Destino} ID {IdDestino}, Empresa ID {IdEmpresa} por Usuario ID {IdUsuarioAjuste}. Monto: {MontoAjuste}", | ||||||
|  |                     ajusteDto.Destino, ajusteDto.IdDestino, ajusteDto.IdEmpresa, idUsuarioAjuste, ajusteDto.MontoAjuste); | ||||||
|  |                  | ||||||
|  |                 var saldoDtoActualizado = await MapToGestionDto(saldoDespuesDeModificacion); | ||||||
|  |                 return (true, null, saldoDtoActualizado); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 try { transaction.Rollback(); } catch (Exception rbEx){ _logger.LogError(rbEx, "Error en Rollback de RealizarAjusteManualSaldoAsync."); } | ||||||
|  |                 _logger.LogError(ex, "Error en RealizarAjusteManualSaldoAsync."); | ||||||
|  |                 return (false, $"Error interno al realizar el ajuste: {ex.Message}", null); | ||||||
|  |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                  if (connection.State == ConnectionState.Open) { if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -54,11 +54,11 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|             }; |             }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<CanillaDto>> ObtenerTodosAsync(string? nomApeFilter, int? legajoFilter, bool? soloActivos) |         public async Task<IEnumerable<CanillaDto>> ObtenerTodosAsync(string? nomApeFilter, int? legajoFilter, bool? esAccionista, bool? soloActivos) | ||||||
|         { |         { | ||||||
|             var canillasData = await _canillaRepository.GetAllAsync(nomApeFilter, legajoFilter, soloActivos); |             var data = await _canillaRepository.GetAllAsync(nomApeFilter, legajoFilter, soloActivos, esAccionista); | ||||||
|             // Filtrar nulos y asegurar al compilador que no hay nulos en la lista final |             // Filtrar nulos y asegurar al compilador que no hay nulos en la lista final | ||||||
|             return canillasData.Select(MapToDto).Where(dto => dto != null).Select(dto => dto!); |             return data.Select(MapToDto).Where(dto => dto != null).Select(dto => dto!); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<CanillaDto?> ObtenerPorIdAsync(int id) |         public async Task<CanillaDto?> ObtenerPorIdAsync(int id) | ||||||
| @@ -131,12 +131,20 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|                     nombreEmpresaParaDto = empresaData?.Nombre ?? "Empresa Desconocida"; |                     nombreEmpresaParaDto = empresaData?.Nombre ?? "Empresa Desconocida"; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 var dtoCreado = new CanillaDto { |                 var dtoCreado = new CanillaDto | ||||||
|                      IdCanilla = canillaCreado.IdCanilla, Legajo = canillaCreado.Legajo, NomApe = canillaCreado.NomApe, |                 { | ||||||
|                      Parada = canillaCreado.Parada, IdZona = canillaCreado.IdZona, NombreZona = zona.Nombre, // Usar nombre de zona ya obtenido |                     IdCanilla = canillaCreado.IdCanilla, | ||||||
|                      Accionista = canillaCreado.Accionista, Obs = canillaCreado.Obs, Empresa = canillaCreado.Empresa, |                     Legajo = canillaCreado.Legajo, | ||||||
|  |                     NomApe = canillaCreado.NomApe, | ||||||
|  |                     Parada = canillaCreado.Parada, | ||||||
|  |                     IdZona = canillaCreado.IdZona, | ||||||
|  |                     NombreZona = zona.Nombre, // Usar nombre de zona ya obtenido | ||||||
|  |                     Accionista = canillaCreado.Accionista, | ||||||
|  |                     Obs = canillaCreado.Obs, | ||||||
|  |                     Empresa = canillaCreado.Empresa, | ||||||
|                     NombreEmpresa = nombreEmpresaParaDto, |                     NombreEmpresa = nombreEmpresaParaDto, | ||||||
|                      Baja = canillaCreado.Baja, FechaBaja = null |                     Baja = canillaCreado.Baja, | ||||||
|  |                     FechaBaja = null | ||||||
|                 }; |                 }; | ||||||
|  |  | ||||||
|                 _logger.LogInformation("Canilla ID {IdCanilla} creado por Usuario ID {IdUsuario}.", canillaCreado.IdCanilla, idUsuario); |                 _logger.LogInformation("Canilla ID {IdCanilla} creado por Usuario ID {IdUsuario}.", canillaCreado.IdCanilla, idUsuario); | ||||||
| @@ -205,7 +213,8 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|                 _logger.LogInformation("Canilla ID {IdCanilla} actualizado por Usuario ID {IdUsuario}.", id, idUsuario); |                 _logger.LogInformation("Canilla ID {IdCanilla} actualizado por Usuario ID {IdUsuario}.", id, idUsuario); | ||||||
|                 return (true, null); |                 return (true, null); | ||||||
|             } |             } | ||||||
|             catch (KeyNotFoundException) { |             catch (KeyNotFoundException) | ||||||
|  |             { | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction.Rollback(); } catch { } | ||||||
|                 return (false, "Canillita no encontrado durante la actualización."); |                 return (false, "Canillita no encontrado durante la actualización."); | ||||||
|             } |             } | ||||||
| @@ -240,7 +249,8 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|                 _logger.LogInformation("Estado de baja cambiado a {EstadoBaja} para Canilla ID {IdCanilla} por Usuario ID {IdUsuario}.", darDeBaja, id, idUsuario); |                 _logger.LogInformation("Estado de baja cambiado a {EstadoBaja} para Canilla ID {IdCanilla} por Usuario ID {IdUsuario}.", darDeBaja, id, idUsuario); | ||||||
|                 return (true, null); |                 return (true, null); | ||||||
|             } |             } | ||||||
|             catch (KeyNotFoundException) { |             catch (KeyNotFoundException) | ||||||
|  |             { | ||||||
|                 try { transaction.Rollback(); } catch { } |                 try { transaction.Rollback(); } catch { } | ||||||
|                 return (false, "Canillita no encontrado durante el cambio de estado de baja."); |                 return (false, "Canillita no encontrado durante el cambio de estado de baja."); | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -66,6 +66,20 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|             return data.Select(MapToDto).Where(dto => dto != null).Select(dto => dto!); |             return data.Select(MapToDto).Where(dto => dto != null).Select(dto => dto!); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<DistribuidorDropdownDto>> GetAllDropdownAsync() | ||||||
|  |         { | ||||||
|  |             var data = await _distribuidorRepository.GetAllDropdownAsync(); | ||||||
|  |             // Asegurar que el resultado no sea nulo y no contiene elementos nulos | ||||||
|  |             if (data == null) | ||||||
|  |             { | ||||||
|  |                 return new List<DistribuidorDropdownDto> | ||||||
|  |                 { | ||||||
|  |                     new DistribuidorDropdownDto { IdDistribuidor = 0, Nombre = "No hay distribuidores disponibles" } | ||||||
|  |                 }; | ||||||
|  |             } | ||||||
|  |             return data.Where(x => x != null)!; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public async Task<DistribuidorDto?> ObtenerPorIdAsync(int id) |         public async Task<DistribuidorDto?> ObtenerPorIdAsync(int id) | ||||||
|         { |         { | ||||||
|             var data = await _distribuidorRepository.GetByIdAsync(id); |             var data = await _distribuidorRepository.GetByIdAsync(id); | ||||||
| @@ -73,6 +87,12 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|             return MapToDto(data); |             return MapToDto(data); | ||||||
|         }         |         }         | ||||||
|  |  | ||||||
|  |         public async Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id) | ||||||
|  |         { | ||||||
|  |             var data = await _distribuidorRepository.ObtenerLookupPorIdAsync(id); | ||||||
|  |             return data; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public async Task<(DistribuidorDto? Distribuidor, string? Error)> CrearAsync(CreateDistribuidorDto createDto, int idUsuario) |         public async Task<(DistribuidorDto? Distribuidor, string? Error)> CrearAsync(CreateDistribuidorDto createDto, int idUsuario) | ||||||
|         { |         { | ||||||
|             if (await _distribuidorRepository.ExistsByNroDocAsync(createDto.NroDoc)) |             if (await _distribuidorRepository.ExistsByNroDocAsync(createDto.NroDoc)) | ||||||
|   | |||||||
| @@ -43,6 +43,18 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|          |          | ||||||
|  |         public async Task<IEnumerable<EmpresaDropdownDto>> ObtenerParaDropdown() | ||||||
|  |         { | ||||||
|  |             // El repositorio ya devuelve solo las activas si es necesario | ||||||
|  |             var empresas = await _empresaRepository.GetAllDropdownAsync(); | ||||||
|  |             // Mapeo Entidad -> DTO | ||||||
|  |             return empresas.Select(e => new EmpresaDropdownDto | ||||||
|  |             { | ||||||
|  |                 IdEmpresa = e.IdEmpresa, | ||||||
|  |                 Nombre = e.Nombre | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public async Task<EmpresaDto?> ObtenerPorIdAsync(int id) |         public async Task<EmpresaDto?> ObtenerPorIdAsync(int id) | ||||||
|         { |         { | ||||||
|             // El repositorio ya devuelve solo las activas si es necesario |             // El repositorio ya devuelve solo las activas si es necesario | ||||||
| @@ -57,6 +69,19 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|             }; |             }; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         public async Task<EmpresaLookupDto?> ObtenerLookupPorIdAsync(int id) | ||||||
|  |         { | ||||||
|  |             // El repositorio ya devuelve solo las activas si es necesario | ||||||
|  |             var empresa = await _empresaRepository.ObtenerLookupPorIdAsync(id); | ||||||
|  |             if (empresa == null) return null; | ||||||
|  |             // Mapeo Entidad -> DTO | ||||||
|  |             return new EmpresaLookupDto | ||||||
|  |             { | ||||||
|  |                 IdEmpresa = empresa.IdEmpresa, | ||||||
|  |                 Nombre = empresa.Nombre | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public async Task<(EmpresaDto? Empresa, string? Error)> CrearAsync(CreateEmpresaDto createDto, int idUsuario) |         public async Task<(EmpresaDto? Empresa, string? Error)> CrearAsync(CreateEmpresaDto createDto, int idUsuario) | ||||||
|         { |         { | ||||||
|             // Validación de negocio: Nombre duplicado |             // Validación de negocio: Nombre duplicado | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
| { | { | ||||||
|     public interface ICanillaService |     public interface ICanillaService | ||||||
|     { |     { | ||||||
|         Task<IEnumerable<CanillaDto>> ObtenerTodosAsync(string? nomApeFilter, int? legajoFilter, bool? soloActivos); |         Task<IEnumerable<CanillaDto>> ObtenerTodosAsync(string? nomApeFilter, int? legajoFilter, bool? esAccionista, bool? soloActivos); | ||||||
|         Task<CanillaDto?> ObtenerPorIdAsync(int id); |         Task<CanillaDto?> ObtenerPorIdAsync(int id); | ||||||
|         Task<(CanillaDto? Canilla, string? Error)> CrearAsync(CreateCanillaDto createDto, int idUsuario); |         Task<(CanillaDto? Canilla, string? Error)> CrearAsync(CreateCanillaDto createDto, int idUsuario); | ||||||
|         Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateCanillaDto updateDto, int idUsuario); |         Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateCanillaDto updateDto, int idUsuario); | ||||||
|   | |||||||
| @@ -11,5 +11,7 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|         Task<(DistribuidorDto? Distribuidor, string? Error)> CrearAsync(CreateDistribuidorDto createDto, int idUsuario); |         Task<(DistribuidorDto? Distribuidor, string? Error)> CrearAsync(CreateDistribuidorDto createDto, int idUsuario); | ||||||
|         Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateDistribuidorDto updateDto, int idUsuario); |         Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateDistribuidorDto updateDto, int idUsuario); | ||||||
|         Task<(bool Exito, string? Error)> EliminarAsync(int id, int idUsuario); |         Task<(bool Exito, string? Error)> EliminarAsync(int id, int idUsuario); | ||||||
|  |         Task<IEnumerable<DistribuidorDropdownDto>> GetAllDropdownAsync(); | ||||||
|  |         Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -11,5 +11,7 @@ namespace GestionIntegral.Api.Services.Distribucion | |||||||
|         Task<(EmpresaDto? Empresa, string? Error)> CrearAsync(CreateEmpresaDto createDto, int idUsuario); |         Task<(EmpresaDto? Empresa, string? Error)> CrearAsync(CreateEmpresaDto createDto, int idUsuario); | ||||||
|         Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateEmpresaDto updateDto, int idUsuario); |         Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateEmpresaDto updateDto, int idUsuario); | ||||||
|         Task<(bool Exito, string? Error)> EliminarAsync(int id, int idUsuario); |         Task<(bool Exito, string? Error)> EliminarAsync(int id, int idUsuario); | ||||||
|  |         Task<IEnumerable<EmpresaDropdownDto>> ObtenerParaDropdown(); | ||||||
|  |         Task<EmpresaLookupDto?> ObtenerLookupPorIdAsync(int id); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -0,0 +1,19 @@ | |||||||
|  | using GestionIntegral.Api.Dtos.Distribucion; | ||||||
|  | using GestionIntegral.Api.Dtos.Reportes; | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Services.Distribucion | ||||||
|  | { | ||||||
|  |     public interface INovedadCanillaService | ||||||
|  |     { | ||||||
|  |         Task<IEnumerable<NovedadCanillaDto>> ObtenerPorCanillaAsync(int idCanilla, DateTime? fechaDesde, DateTime? fechaHasta); | ||||||
|  |         Task<NovedadCanillaDto?> ObtenerPorIdAsync(int idNovedad); | ||||||
|  |         Task<(NovedadCanillaDto? Novedad, string? Error)> CrearAsync(CreateNovedadCanillaDto createDto, int idUsuario); | ||||||
|  |         Task<(bool Exito, string? Error)> ActualizarAsync(int idNovedad, UpdateNovedadCanillaDto updateDto, int idUsuario); | ||||||
|  |         Task<(bool Exito, string? Error)> EliminarAsync(int idNovedad, int idUsuario); | ||||||
|  |         Task<IEnumerable<NovedadesCanillasReporteDto>> ObtenerReporteNovedadesAsync(int idEmpresa, DateTime fechaDesde, DateTime fechaHasta); | ||||||
|  |         Task<IEnumerable<CanillaGananciaReporteDto>> ObtenerReporteGananciasAsync(int idEmpresa, DateTime fechaDesde, DateTime fechaHasta); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,254 @@ | |||||||
|  | // En Services/Distribucion (o donde corresponda) | ||||||
|  | using GestionIntegral.Api.Data; // Para DbConnectionFactory | ||||||
|  | using GestionIntegral.Api.Data.Repositories.Distribucion; | ||||||
|  | using GestionIntegral.Api.Dtos.Distribucion; | ||||||
|  | using GestionIntegral.Api.Dtos.Reportes; | ||||||
|  | using GestionIntegral.Api.Models.Distribucion; // Asegúrate que el modelo Canilla tenga NomApe | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Data; // Para IDbTransaction | ||||||
|  | using System.Linq; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
|  | namespace GestionIntegral.Api.Services.Distribucion | ||||||
|  | { | ||||||
|  |     public class NovedadCanillaService : INovedadCanillaService | ||||||
|  |     { | ||||||
|  |         private readonly INovedadCanillaRepository _novedadRepository; | ||||||
|  |         private readonly ICanillaRepository _canillaRepository; | ||||||
|  |         private readonly DbConnectionFactory _connectionFactory; | ||||||
|  |         private readonly ILogger<NovedadCanillaService> _logger; | ||||||
|  |  | ||||||
|  |         public NovedadCanillaService( | ||||||
|  |             INovedadCanillaRepository novedadRepository, | ||||||
|  |             ICanillaRepository canillaRepository, | ||||||
|  |             DbConnectionFactory connectionFactory, | ||||||
|  |             ILogger<NovedadCanillaService> logger) | ||||||
|  |         { | ||||||
|  |             _novedadRepository = novedadRepository; | ||||||
|  |             _canillaRepository = canillaRepository; | ||||||
|  |             _connectionFactory = connectionFactory; | ||||||
|  |             _logger = logger; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private NovedadCanillaDto MapToDto((NovedadCanilla Novedad, string NombreCanilla) data) | ||||||
|  |         { | ||||||
|  |             return new NovedadCanillaDto | ||||||
|  |             { | ||||||
|  |                 IdNovedad = data.Novedad.IdNovedad, | ||||||
|  |                 IdCanilla = data.Novedad.IdCanilla, | ||||||
|  |                 NombreCanilla = data.NombreCanilla, // Viene de la tupla en GetByCanillaAsync | ||||||
|  |                 Fecha = data.Novedad.Fecha, | ||||||
|  |                 Detalle = data.Novedad.Detalle | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |         private NovedadCanillaDto MapToDto(NovedadCanilla data, string nombreCanilla) | ||||||
|  |         { | ||||||
|  |             return new NovedadCanillaDto | ||||||
|  |             { | ||||||
|  |                 IdNovedad = data.IdNovedad, | ||||||
|  |                 IdCanilla = data.IdCanilla, | ||||||
|  |                 NombreCanilla = nombreCanilla, | ||||||
|  |                 Fecha = data.Fecha, | ||||||
|  |                 Detalle = data.Detalle | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<NovedadCanillaDto>> ObtenerPorCanillaAsync(int idCanilla, DateTime? fechaDesde, DateTime? fechaHasta) | ||||||
|  |         { | ||||||
|  |             var data = await _novedadRepository.GetByCanillaAsync(idCanilla, fechaDesde, fechaHasta); | ||||||
|  |             return data.Select(MapToDto); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<NovedadCanillaDto?> ObtenerPorIdAsync(int idNovedad) | ||||||
|  |         { | ||||||
|  |             var novedad = await _novedadRepository.GetByIdAsync(idNovedad); | ||||||
|  |             if (novedad == null) return null; | ||||||
|  |  | ||||||
|  |             // Asumiendo que _canillaRepository.GetByIdAsync devuelve una tupla (Canilla? Canilla, ...) | ||||||
|  |             // O un DTO CanillaDto que tiene NomApe | ||||||
|  |             var canillaDataResult = await _canillaRepository.GetByIdAsync(novedad.IdCanilla); | ||||||
|  |  | ||||||
|  |             // Ajusta esto según lo que realmente devuelva GetByIdAsync | ||||||
|  |             // Si devuelve CanillaDto: | ||||||
|  |             // string nombreCanilla = canillaDataResult?.NomApe ?? "Desconocido"; | ||||||
|  |             // Si devuelve la tupla (Canilla? Canilla, string? NombreZona, string? NombreEmpresa): | ||||||
|  |             string nombreCanilla = canillaDataResult.Canilla?.NomApe ?? "Desconocido"; | ||||||
|  |  | ||||||
|  |             return MapToDto(novedad, nombreCanilla); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<(NovedadCanillaDto? Novedad, string? Error)> CrearAsync(CreateNovedadCanillaDto createDto, int idUsuario) | ||||||
|  |         { | ||||||
|  |             // Asegúrate que GetByIdSimpleAsync devuelva un objeto Canilla o algo con NomApe | ||||||
|  |             var canilla = await _canillaRepository.GetByIdSimpleAsync(createDto.IdCanilla); | ||||||
|  |             if (canilla == null) | ||||||
|  |             { | ||||||
|  |                 return (null, "El canillita especificado no existe."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var nuevaNovedad = new NovedadCanilla | ||||||
|  |             { | ||||||
|  |                 IdCanilla = createDto.IdCanilla, | ||||||
|  |                 Fecha = createDto.Fecha.Date, | ||||||
|  |                 Detalle = createDto.Detalle | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             // Abre la conexión explícitamente si no se usa una transacción externa | ||||||
|  |             if (connection is System.Data.Common.DbConnection dbConn && connection.State != ConnectionState.Open) | ||||||
|  |             { | ||||||
|  |                 await dbConn.OpenAsync(); | ||||||
|  |             } | ||||||
|  |             else if (connection.State != ConnectionState.Open) | ||||||
|  |             { | ||||||
|  |                 connection.Open(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             using var transaction = connection.BeginTransaction(); | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var creada = await _novedadRepository.CreateAsync(nuevaNovedad, idUsuario, transaction); | ||||||
|  |                 if (creada == null) | ||||||
|  |                 { | ||||||
|  |                     transaction.Rollback(); | ||||||
|  |                     return (null, "Error al guardar la novedad en la base de datos."); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 transaction.Commit(); | ||||||
|  |                 _logger.LogInformation("Novedad ID {IdNovedad} para Canilla ID {IdCanilla} creada por Usuario ID {UserId}.", creada.IdNovedad, creada.IdCanilla, idUsuario); | ||||||
|  |                 // Asegúrate que 'canilla.NomApe' sea accesible. Si GetByIdSimpleAsync devuelve la entidad Canilla, esto está bien. | ||||||
|  |                 return (MapToDto(creada, canilla.NomApe ?? "Canilla sin nombre"), null); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 try { transaction.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error durante Rollback en CrearAsync NovedadCanilla."); } | ||||||
|  |                 _logger.LogError(ex, "Error CrearAsync NovedadCanilla para Canilla ID: {IdCanilla}", createDto.IdCanilla); | ||||||
|  |                 return (null, $"Error interno al crear la novedad: {ex.Message}"); | ||||||
|  |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (connection.State == ConnectionState.Open) connection.Close(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<(bool Exito, string? Error)> ActualizarAsync(int idNovedad, UpdateNovedadCanillaDto updateDto, int idUsuario) | ||||||
|  |         { | ||||||
|  |             var existente = await _novedadRepository.GetByIdAsync(idNovedad); | ||||||
|  |             if (existente == null) | ||||||
|  |             { | ||||||
|  |                 return (false, "Novedad no encontrada."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             existente.Detalle = updateDto.Detalle; | ||||||
|  |  | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             if (connection is System.Data.Common.DbConnection dbConn && connection.State != ConnectionState.Open) | ||||||
|  |             { | ||||||
|  |                 await dbConn.OpenAsync(); | ||||||
|  |             } | ||||||
|  |             else if (connection.State != ConnectionState.Open) | ||||||
|  |             { | ||||||
|  |                 connection.Open(); | ||||||
|  |             } | ||||||
|  |             using var transaction = connection.BeginTransaction(); | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var actualizado = await _novedadRepository.UpdateAsync(existente, idUsuario, transaction); | ||||||
|  |                 if (!actualizado) | ||||||
|  |                 { | ||||||
|  |                     transaction.Rollback(); | ||||||
|  |                     return (false, "Error al actualizar la novedad en la base de datos."); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 transaction.Commit(); | ||||||
|  |                 _logger.LogInformation("Novedad ID {IdNovedad} actualizada por Usuario ID {UserId}.", idNovedad, idUsuario); | ||||||
|  |                 return (true, null); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 try { transaction.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error durante Rollback en ActualizarAsync NovedadCanilla."); } | ||||||
|  |                 _logger.LogError(ex, "Error ActualizarAsync NovedadCanilla ID: {IdNovedad}", idNovedad); | ||||||
|  |                 return (false, $"Error interno al actualizar la novedad: {ex.Message}"); | ||||||
|  |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (connection.State == ConnectionState.Open) connection.Close(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<(bool Exito, string? Error)> EliminarAsync(int idNovedad, int idUsuario) | ||||||
|  |         { | ||||||
|  |             var existente = await _novedadRepository.GetByIdAsync(idNovedad); | ||||||
|  |             if (existente == null) | ||||||
|  |             { | ||||||
|  |                 return (false, "Novedad no encontrada."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             using var connection = _connectionFactory.CreateConnection(); | ||||||
|  |             if (connection is System.Data.Common.DbConnection dbConn && connection.State != ConnectionState.Open) | ||||||
|  |             { | ||||||
|  |                 await dbConn.OpenAsync(); | ||||||
|  |             } | ||||||
|  |             else if (connection.State != ConnectionState.Open) | ||||||
|  |             { | ||||||
|  |                 connection.Open(); | ||||||
|  |             } | ||||||
|  |             using var transaction = connection.BeginTransaction(); | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var eliminado = await _novedadRepository.DeleteAsync(idNovedad, idUsuario, transaction); | ||||||
|  |                 if (!eliminado) | ||||||
|  |                 { | ||||||
|  |                     transaction.Rollback(); | ||||||
|  |                     return (false, "Error al eliminar la novedad de la base de datos."); | ||||||
|  |                 } | ||||||
|  |                 transaction.Commit(); | ||||||
|  |                 _logger.LogInformation("Novedad ID {IdNovedad} eliminada por Usuario ID {UserId}.", idNovedad, idUsuario); | ||||||
|  |                 return (true, null); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 try { transaction.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error durante Rollback en EliminarAsync NovedadCanilla."); } | ||||||
|  |                 _logger.LogError(ex, "Error EliminarAsync NovedadCanilla ID: {IdNovedad}", idNovedad); | ||||||
|  |                 return (false, $"Error interno al eliminar la novedad: {ex.Message}"); | ||||||
|  |             } | ||||||
|  |             finally | ||||||
|  |             { | ||||||
|  |                 if (connection.State == ConnectionState.Open) connection.Close(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<NovedadesCanillasReporteDto>> ObtenerReporteNovedadesAsync(int idEmpresa, DateTime fechaDesde, DateTime fechaHasta) | ||||||
|  |         { | ||||||
|  |             // Podría añadir validaciones o lógica de negocio adicional si fuera necesario | ||||||
|  |             // antes de llamar al repositorio. Por ahora, es una llamada directa. | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 return await _novedadRepository.GetReporteNovedadesAsync(idEmpresa, fechaDesde, fechaHasta); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener datos para el reporte de novedades de canillitas. Empresa: {IdEmpresa}, Desde: {FechaDesde}, Hasta: {FechaHasta}", idEmpresa, fechaDesde, fechaHasta); | ||||||
|  |                 // Podría relanzar o devolver una lista vacía con un mensaje de error, | ||||||
|  |                 // dependiendo de cómo quiera manejar los errores en la capa de servicio. | ||||||
|  |                 // Por simplicidad, relanzamos para que el controlador lo maneje. | ||||||
|  |                 throw; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<IEnumerable<CanillaGananciaReporteDto>> ObtenerReporteGananciasAsync(int idEmpresa, DateTime fechaDesde, DateTime fechaHasta) | ||||||
|  |         { | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 return await _novedadRepository.GetReporteGananciasAsync(idEmpresa, fechaDesde, fechaHasta); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener datos para el reporte de ganancias de canillitas. Empresa: {IdEmpresa}, Desde: {FechaDesde}, Hasta: {FechaHasta}", idEmpresa, fechaDesde, fechaHasta); | ||||||
|  |                 throw; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -69,5 +69,8 @@ namespace GestionIntegral.Api.Services.Reportes | |||||||
|             IEnumerable<LiquidacionCanillaGananciaDto> Ganancias, |             IEnumerable<LiquidacionCanillaGananciaDto> Ganancias, | ||||||
|             string? Error |             string? Error | ||||||
|         )> ObtenerDatosTicketLiquidacionAsync(DateTime fecha, int idCanilla); |         )> ObtenerDatosTicketLiquidacionAsync(DateTime fecha, int idCanilla); | ||||||
|  |  | ||||||
|  |         Task<(IEnumerable<ListadoDistCanMensualDiariosDto> Data, string? Error)> ObtenerReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); | ||||||
|  |         Task<(IEnumerable<ListadoDistCanMensualPubDto> Data, string? Error)> ObtenerReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -490,5 +490,33 @@ namespace GestionIntegral.Api.Services.Reportes | |||||||
|                 ); |                 ); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         public async Task<(IEnumerable<ListadoDistCanMensualDiariosDto> Data, string? Error)> ObtenerReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista) | ||||||
|  |         { | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var data = await _reportesRepository.GetReporteMensualDiariosAsync(fechaDesde, fechaHasta, esAccionista); | ||||||
|  |                 return (data, null); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener reporte mensual canillitas (diarios)."); | ||||||
|  |                 return (Enumerable.Empty<ListadoDistCanMensualDiariosDto>(), "Error al obtener datos del reporte (diarios)."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public async Task<(IEnumerable<ListadoDistCanMensualPubDto> Data, string? Error)> ObtenerReporteMensualPorPublicacionAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista) | ||||||
|  |         { | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 var data = await _reportesRepository.GetReporteMensualPorPublicacionAsync(fechaDesde, fechaHasta, esAccionista); | ||||||
|  |                 return (data, null); | ||||||
|  |             } | ||||||
|  |             catch (Exception ex) | ||||||
|  |             { | ||||||
|  |                 _logger.LogError(ex, "Error al obtener reporte mensual canillitas (por publicación)."); | ||||||
|  |                 return (Enumerable.Empty<ListadoDistCanMensualPubDto>(), "Error al obtener datos del reporte (por publicación)."); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -13,7 +13,7 @@ using System.Reflection; | |||||||
| [assembly: System.Reflection.AssemblyCompanyAttribute("GestionIntegral.Api")] | [assembly: System.Reflection.AssemblyCompanyAttribute("GestionIntegral.Api")] | ||||||
| [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | ||||||
| [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] | [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] | ||||||
| [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+062cc05fd00a484e43f8b4ff022e53ac49670a78")] | [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8fb94f8cefc3b498397ffcbb9b9a2e66c13b25b9")] | ||||||
| [assembly: System.Reflection.AssemblyProductAttribute("GestionIntegral.Api")] | [assembly: System.Reflection.AssemblyProductAttribute("GestionIntegral.Api")] | ||||||
| [assembly: System.Reflection.AssemblyTitleAttribute("GestionIntegral.Api")] | [assembly: System.Reflection.AssemblyTitleAttribute("GestionIntegral.Api")] | ||||||
| [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| {"GlobalPropertiesHash":"C9goqBDGh4B0L1HpPwpJHjfbRNoIuzqnU7zFMHk1LhM=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["V/5slELlkFDzZ8iiVKV8Jt0Ia8AL5AZxPCWo9apx5lQ=","bxlPVWHR7EivQofjz9PzA8dMpKpZqCfOZ\u002BHD\u002Bf1Ew9Y=","\u002BzMwu5DIAA49kPmSydn2WMzj\u002Bdcf0MC3YakKoR6HwYg=","FUb20tYUiusFv5/KhAPdh2OB4ArUWiGApXbQJdx8tX0=","pTWqrhLBwEeWg1GsRlTKzfOAnT1JEklZ8F1/EYlc1Nk=","Hu0oNH4YYNcbnR5Ts4qd5yzC5j5JbY2kEDXces8V1vs=","TKMARE0bLM2dm9NOqxxWztnuqao5IvCh24TEHCtht6I=","84UEEMEbmmNwHVXD5Iw3dtKHTZC0Zqbk3rIRO\u002BxOq4o=","qfTzsJ\u002B5ilLyrc6EhNm61KkSH37yRi85MtgW1\u002BUD2Vo=","4ayt/JAApEOfr0yjg9szkYMPzSs6x2k3QEwmrK5RZVY=","d0weYwKWe3mH5R2BURuNLkAyytO/viA6zivv9AcIBtQ=","Ssyx6SvSGgWMOzhc9pQpk6f6\u002BmVbKQNKeDJbvVA2tjs=","FSqDybxILZmKXw160ANhj76usnM83geRrbPvJxr89OA=","k3qzLxTWHeeJhAuWKMdta6j24bmJ9BMRMjuFEEVCRu0=","x/sHyso3gy4zVCu3ljpnTYCqu8IGZNRok1JoXiabIP8=","fdI2RZZ9M9QOVHCYU5cE\u002BgVVuT7ssRbMzdXvX8rHofc=","8ePFhqKT0OT9nEg3b5T7COC81U\u002BQBcf\u002BindBGyMy6z0=","/ghcduGmSd1I25YtYli\u002BqxF0xuscxc4cTDkbEC6XYVA=","/a3YEu0oBUeA5Qr2VMdppqLuz4CQPWJt2JfBl2dtUwA=","jEO/q4IO3UFTWxlyFwRr7kbGWcTIiS\u002BClxx3kahX/Fk=","4iYOCKYvhsROdGkA1hINVBejb6r8IkwFj9SNMKub3DM=","CeDswsZIn5a7t\u002BKeHJA222yhFvDVVEW1ky98Xxnxebc=","50j34YXOc950QSqaQBMtgezD3tV5mWWR9c5qZcYQoz4=","W/aX9jIKpjNEVoGrU6RXFOY8SDJVT6XB4Rg4QCaeQkQ=","16IbB\u002B3zYHZvsWbCQK6hBFmKJ6Z28SecBn2jm8R3w8I=","COJtHNQqycTJqXkFv2hhpLUT\u002B/AD4IWyQlmxkUVQPNk=","cp6a5bdvkLnUn3x47KQODzPycnx57RmWO\u002B9q8MuoGQo=","oKZRNhIQRaZrETEa3L6JiwIp0\u002BmjzJo193EWBoCuVUg=","sjwbCAEQX51sEWhYVGBihWUNBxniUKZALVJIGK\u002BYgsk=","A4m4kVcox60bvdkJ1CswoZADAT70WPcs4TAKdpMoUjM=","zSzyOuNcK0NQJLwK8Yg4sH4EflX7RPf65Fl2CZUWIGs=","1I2C2FVhJyFRbvyuGXnropbTYN\u002BqpCoTcHfxWbfWF10="],"CachedAssets":{},"CachedCopyCandidates":{}} | {"GlobalPropertiesHash":"C9goqBDGh4B0L1HpPwpJHjfbRNoIuzqnU7zFMHk1LhM=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["V/5slELlkFDzZ8iiVKV8Jt0Ia8AL5AZxPCWo9apx5lQ=","bxlPVWHR7EivQofjz9PzA8dMpKpZqCfOZ\u002BHD\u002Bf1Ew9Y=","\u002BzMwu5DIAA49kPmSydn2WMzj\u002Bdcf0MC3YakKoR6HwYg=","FUb20tYUiusFv5/KhAPdh2OB4ArUWiGApXbQJdx8tX0=","pTWqrhLBwEeWg1GsRlTKzfOAnT1JEklZ8F1/EYlc1Nk=","Hu0oNH4YYNcbnR5Ts4qd5yzC5j5JbY2kEDXces8V1vs=","TKMARE0bLM2dm9NOqxxWztnuqao5IvCh24TEHCtht6I=","84UEEMEbmmNwHVXD5Iw3dtKHTZC0Zqbk3rIRO\u002BxOq4o=","qfTzsJ\u002B5ilLyrc6EhNm61KkSH37yRi85MtgW1\u002BUD2Vo=","4ayt/JAApEOfr0yjg9szkYMPzSs6x2k3QEwmrK5RZVY=","d0weYwKWe3mH5R2BURuNLkAyytO/viA6zivv9AcIBtQ=","Ssyx6SvSGgWMOzhc9pQpk6f6\u002BmVbKQNKeDJbvVA2tjs=","FSqDybxILZmKXw160ANhj76usnM83geRrbPvJxr89OA=","k3qzLxTWHeeJhAuWKMdta6j24bmJ9BMRMjuFEEVCRu0=","x/sHyso3gy4zVCu3ljpnTYCqu8IGZNRok1JoXiabIP8=","fdI2RZZ9M9QOVHCYU5cE\u002BgVVuT7ssRbMzdXvX8rHofc=","8ePFhqKT0OT9nEg3b5T7COC81U\u002BQBcf\u002BindBGyMy6z0=","/ghcduGmSd1I25YtYli\u002BqxF0xuscxc4cTDkbEC6XYVA=","/a3YEu0oBUeA5Qr2VMdppqLuz4CQPWJt2JfBl2dtUwA=","jEO/q4IO3UFTWxlyFwRr7kbGWcTIiS\u002BClxx3kahX/Fk=","4iYOCKYvhsROdGkA1hINVBejb6r8IkwFj9SNMKub3DM=","CeDswsZIn5a7t\u002BKeHJA222yhFvDVVEW1ky98Xxnxebc=","50j34YXOc950QSqaQBMtgezD3tV5mWWR9c5qZcYQoz4=","W/aX9jIKpjNEVoGrU6RXFOY8SDJVT6XB4Rg4QCaeQkQ=","16IbB\u002B3zYHZvsWbCQK6hBFmKJ6Z28SecBn2jm8R3w8I=","COJtHNQqycTJqXkFv2hhpLUT\u002B/AD4IWyQlmxkUVQPNk=","cp6a5bdvkLnUn3x47KQODzPycnx57RmWO\u002B9q8MuoGQo=","oKZRNhIQRaZrETEa3L6JiwIp0\u002BmjzJo193EWBoCuVUg=","sjwbCAEQX51sEWhYVGBihWUNBxniUKZALVJIGK\u002BYgsk=","A4m4kVcox60bvdkJ1CswoZADAT70WPcs4TAKdpMoUjM=","zSzyOuNcK0NQJLwK8Yg4sH4EflX7RPf65Fl2CZUWIGs=","ko3Qcj1qg4o0KikPIBL6WHcUA8sCBGtBIyzr8DuluqQ="],"CachedAssets":{},"CachedCopyCandidates":{}} | ||||||
| @@ -1 +1 @@ | |||||||
| {"GlobalPropertiesHash":"w3MBbMV9Msh0YEq9AW/8s16bzXJ93T9lMVXKPm/r6es=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["V/5slELlkFDzZ8iiVKV8Jt0Ia8AL5AZxPCWo9apx5lQ=","bxlPVWHR7EivQofjz9PzA8dMpKpZqCfOZ\u002BHD\u002Bf1Ew9Y=","\u002BzMwu5DIAA49kPmSydn2WMzj\u002Bdcf0MC3YakKoR6HwYg=","FUb20tYUiusFv5/KhAPdh2OB4ArUWiGApXbQJdx8tX0=","pTWqrhLBwEeWg1GsRlTKzfOAnT1JEklZ8F1/EYlc1Nk=","Hu0oNH4YYNcbnR5Ts4qd5yzC5j5JbY2kEDXces8V1vs=","TKMARE0bLM2dm9NOqxxWztnuqao5IvCh24TEHCtht6I=","84UEEMEbmmNwHVXD5Iw3dtKHTZC0Zqbk3rIRO\u002BxOq4o=","qfTzsJ\u002B5ilLyrc6EhNm61KkSH37yRi85MtgW1\u002BUD2Vo=","4ayt/JAApEOfr0yjg9szkYMPzSs6x2k3QEwmrK5RZVY=","d0weYwKWe3mH5R2BURuNLkAyytO/viA6zivv9AcIBtQ=","Ssyx6SvSGgWMOzhc9pQpk6f6\u002BmVbKQNKeDJbvVA2tjs=","FSqDybxILZmKXw160ANhj76usnM83geRrbPvJxr89OA=","k3qzLxTWHeeJhAuWKMdta6j24bmJ9BMRMjuFEEVCRu0=","x/sHyso3gy4zVCu3ljpnTYCqu8IGZNRok1JoXiabIP8=","fdI2RZZ9M9QOVHCYU5cE\u002BgVVuT7ssRbMzdXvX8rHofc=","8ePFhqKT0OT9nEg3b5T7COC81U\u002BQBcf\u002BindBGyMy6z0=","/ghcduGmSd1I25YtYli\u002BqxF0xuscxc4cTDkbEC6XYVA=","/a3YEu0oBUeA5Qr2VMdppqLuz4CQPWJt2JfBl2dtUwA=","jEO/q4IO3UFTWxlyFwRr7kbGWcTIiS\u002BClxx3kahX/Fk=","4iYOCKYvhsROdGkA1hINVBejb6r8IkwFj9SNMKub3DM=","CeDswsZIn5a7t\u002BKeHJA222yhFvDVVEW1ky98Xxnxebc=","50j34YXOc950QSqaQBMtgezD3tV5mWWR9c5qZcYQoz4=","W/aX9jIKpjNEVoGrU6RXFOY8SDJVT6XB4Rg4QCaeQkQ=","16IbB\u002B3zYHZvsWbCQK6hBFmKJ6Z28SecBn2jm8R3w8I=","COJtHNQqycTJqXkFv2hhpLUT\u002B/AD4IWyQlmxkUVQPNk=","cp6a5bdvkLnUn3x47KQODzPycnx57RmWO\u002B9q8MuoGQo=","oKZRNhIQRaZrETEa3L6JiwIp0\u002BmjzJo193EWBoCuVUg=","sjwbCAEQX51sEWhYVGBihWUNBxniUKZALVJIGK\u002BYgsk=","A4m4kVcox60bvdkJ1CswoZADAT70WPcs4TAKdpMoUjM=","zSzyOuNcK0NQJLwK8Yg4sH4EflX7RPf65Fl2CZUWIGs=","1I2C2FVhJyFRbvyuGXnropbTYN\u002BqpCoTcHfxWbfWF10="],"CachedAssets":{},"CachedCopyCandidates":{}} | {"GlobalPropertiesHash":"w3MBbMV9Msh0YEq9AW/8s16bzXJ93T9lMVXKPm/r6es=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["V/5slELlkFDzZ8iiVKV8Jt0Ia8AL5AZxPCWo9apx5lQ=","bxlPVWHR7EivQofjz9PzA8dMpKpZqCfOZ\u002BHD\u002Bf1Ew9Y=","\u002BzMwu5DIAA49kPmSydn2WMzj\u002Bdcf0MC3YakKoR6HwYg=","FUb20tYUiusFv5/KhAPdh2OB4ArUWiGApXbQJdx8tX0=","pTWqrhLBwEeWg1GsRlTKzfOAnT1JEklZ8F1/EYlc1Nk=","Hu0oNH4YYNcbnR5Ts4qd5yzC5j5JbY2kEDXces8V1vs=","TKMARE0bLM2dm9NOqxxWztnuqao5IvCh24TEHCtht6I=","84UEEMEbmmNwHVXD5Iw3dtKHTZC0Zqbk3rIRO\u002BxOq4o=","qfTzsJ\u002B5ilLyrc6EhNm61KkSH37yRi85MtgW1\u002BUD2Vo=","4ayt/JAApEOfr0yjg9szkYMPzSs6x2k3QEwmrK5RZVY=","d0weYwKWe3mH5R2BURuNLkAyytO/viA6zivv9AcIBtQ=","Ssyx6SvSGgWMOzhc9pQpk6f6\u002BmVbKQNKeDJbvVA2tjs=","FSqDybxILZmKXw160ANhj76usnM83geRrbPvJxr89OA=","k3qzLxTWHeeJhAuWKMdta6j24bmJ9BMRMjuFEEVCRu0=","x/sHyso3gy4zVCu3ljpnTYCqu8IGZNRok1JoXiabIP8=","fdI2RZZ9M9QOVHCYU5cE\u002BgVVuT7ssRbMzdXvX8rHofc=","8ePFhqKT0OT9nEg3b5T7COC81U\u002BQBcf\u002BindBGyMy6z0=","/ghcduGmSd1I25YtYli\u002BqxF0xuscxc4cTDkbEC6XYVA=","/a3YEu0oBUeA5Qr2VMdppqLuz4CQPWJt2JfBl2dtUwA=","jEO/q4IO3UFTWxlyFwRr7kbGWcTIiS\u002BClxx3kahX/Fk=","4iYOCKYvhsROdGkA1hINVBejb6r8IkwFj9SNMKub3DM=","CeDswsZIn5a7t\u002BKeHJA222yhFvDVVEW1ky98Xxnxebc=","50j34YXOc950QSqaQBMtgezD3tV5mWWR9c5qZcYQoz4=","W/aX9jIKpjNEVoGrU6RXFOY8SDJVT6XB4Rg4QCaeQkQ=","16IbB\u002B3zYHZvsWbCQK6hBFmKJ6Z28SecBn2jm8R3w8I=","COJtHNQqycTJqXkFv2hhpLUT\u002B/AD4IWyQlmxkUVQPNk=","cp6a5bdvkLnUn3x47KQODzPycnx57RmWO\u002B9q8MuoGQo=","oKZRNhIQRaZrETEa3L6JiwIp0\u002BmjzJo193EWBoCuVUg=","sjwbCAEQX51sEWhYVGBihWUNBxniUKZALVJIGK\u002BYgsk=","A4m4kVcox60bvdkJ1CswoZADAT70WPcs4TAKdpMoUjM=","zSzyOuNcK0NQJLwK8Yg4sH4EflX7RPf65Fl2CZUWIGs=","ko3Qcj1qg4o0KikPIBL6WHcUA8sCBGtBIyzr8DuluqQ="],"CachedAssets":{},"CachedCopyCandidates":{}} | ||||||
							
								
								
									
										162
									
								
								Frontend/src/components/Modals/Contables/AjusteSaldoModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								Frontend/src/components/Modals/Contables/AjusteSaldoModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | |||||||
|  | import React, { useState, useEffect } from 'react'; | ||||||
|  | import { | ||||||
|  |     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, InputAdornment | ||||||
|  | } from '@mui/material'; | ||||||
|  | import type { SaldoGestionDto } from '../../../models/dtos/Contables/SaldoGestionDto'; | ||||||
|  | import type { AjusteSaldoRequestDto } from '../../../models/dtos/Contables/AjusteSaldoRequestDto'; | ||||||
|  |  | ||||||
|  | const modalStyle = { | ||||||
|  |     position: 'absolute' as 'absolute', | ||||||
|  |     top: '50%', | ||||||
|  |     left: '50%', | ||||||
|  |     transform: 'translate(-50%, -50%)', | ||||||
|  |     width: { xs: '90%', sm: 450 }, | ||||||
|  |     bgcolor: 'background.paper', | ||||||
|  |     border: '2px solid #000', | ||||||
|  |     boxShadow: 24, | ||||||
|  |     p: 3, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | interface AjusteSaldoModalProps { | ||||||
|  |   open: boolean; | ||||||
|  |   onClose: () => void; | ||||||
|  |   onSubmit: (data: AjusteSaldoRequestDto) => Promise<void>; // El padre maneja la recarga | ||||||
|  |   saldoParaAjustar: SaldoGestionDto | null; | ||||||
|  |   errorMessage?: string | null; | ||||||
|  |   clearErrorMessage: () => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({ | ||||||
|  |   open, | ||||||
|  |   onClose, | ||||||
|  |   onSubmit, | ||||||
|  |   saldoParaAjustar, | ||||||
|  |   errorMessage, | ||||||
|  |   clearErrorMessage | ||||||
|  | }) => { | ||||||
|  |   const [montoAjuste, setMontoAjuste] = useState<string>(''); | ||||||
|  |   const [justificacion, setJustificacion] = useState(''); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (open) { | ||||||
|  |       setMontoAjuste(''); | ||||||
|  |       setJustificacion(''); | ||||||
|  |       setLocalErrors({}); | ||||||
|  |       clearErrorMessage(); | ||||||
|  |     } | ||||||
|  |   }, [open, clearErrorMessage]); | ||||||
|  |  | ||||||
|  |   const validate = (): boolean => { | ||||||
|  |     const errors: { [key: string]: string | null } = {}; | ||||||
|  |     const numMontoAjuste = parseFloat(montoAjuste); | ||||||
|  |  | ||||||
|  |     if (!montoAjuste.trim()) { | ||||||
|  |         errors.montoAjuste = 'El monto de ajuste es obligatorio.'; | ||||||
|  |     } else if (isNaN(numMontoAjuste)) { | ||||||
|  |         errors.montoAjuste = 'El monto debe ser un número.'; | ||||||
|  |     } else if (numMontoAjuste === 0) { | ||||||
|  |         errors.montoAjuste = 'El monto de ajuste no puede ser cero.'; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!justificacion.trim()) { | ||||||
|  |         errors.justificacion = 'La justificación es obligatoria.'; | ||||||
|  |     } else if (justificacion.trim().length < 5 || justificacion.trim().length > 250) { | ||||||
|  |         errors.justificacion = 'La justificación debe tener entre 5 y 250 caracteres.'; | ||||||
|  |     } | ||||||
|  |     setLocalErrors(errors); | ||||||
|  |     return Object.keys(errors).length === 0; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleInputChange = (fieldName: 'montoAjuste' | 'justificacion') => { | ||||||
|  |     if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null })); | ||||||
|  |     if (errorMessage) clearErrorMessage(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||||
|  |     event.preventDefault(); | ||||||
|  |     clearErrorMessage(); | ||||||
|  |     if (!validate() || !saldoParaAjustar) return; | ||||||
|  |  | ||||||
|  |     setLoading(true); | ||||||
|  |     try { | ||||||
|  |       const dataToSubmit: AjusteSaldoRequestDto = { | ||||||
|  |         destino: saldoParaAjustar.destino as 'Distribuidores' | 'Canillas', | ||||||
|  |         idDestino: saldoParaAjustar.idDestino, | ||||||
|  |         idEmpresa: saldoParaAjustar.idEmpresa, | ||||||
|  |         montoAjuste: parseFloat(montoAjuste), | ||||||
|  |         justificacion, | ||||||
|  |       }; | ||||||
|  |       await onSubmit(dataToSubmit); | ||||||
|  |       onClose(); // Cerrar en éxito (el padre recargará) | ||||||
|  |     } catch (error: any) { | ||||||
|  |       // El error de API es manejado por la página padre | ||||||
|  |       console.error("Error en submit de AjusteSaldoModal:", error); | ||||||
|  |     } finally { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   if (!saldoParaAjustar) return null; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Modal open={open} onClose={onClose}> | ||||||
|  |       <Box sx={modalStyle}> | ||||||
|  |         <Typography variant="h6" component="h2" gutterBottom> | ||||||
|  |           Ajustar Saldo Manualmente | ||||||
|  |         </Typography> | ||||||
|  |         <Typography variant="body2" gutterBottom> | ||||||
|  |             Destinatario: <strong>{saldoParaAjustar.nombreDestinatario}</strong> ({saldoParaAjustar.destino}) | ||||||
|  |         </Typography> | ||||||
|  |         <Typography variant="body2" gutterBottom> | ||||||
|  |             Empresa: <strong>{saldoParaAjustar.nombreEmpresa}</strong> | ||||||
|  |         </Typography> | ||||||
|  |          <Typography variant="body2" color="text.secondary" gutterBottom> | ||||||
|  |             Saldo Actual: <strong>{saldoParaAjustar.monto.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong> | ||||||
|  |         </Typography> | ||||||
|  |  | ||||||
|  |         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||||
|  |           <TextField | ||||||
|  |             label="Monto de Ajuste (+/-)" | ||||||
|  |             type="number" | ||||||
|  |             fullWidth | ||||||
|  |             required | ||||||
|  |             value={montoAjuste} | ||||||
|  |             onChange={(e) => { setMontoAjuste(e.target.value); handleInputChange('montoAjuste'); }} | ||||||
|  |             margin="normal" | ||||||
|  |             error={!!localErrors.montoAjuste} | ||||||
|  |             helperText={localErrors.montoAjuste || 'Ingrese un valor positivo para aumentar deuda o negativo para disminuirla.'} | ||||||
|  |             disabled={loading} | ||||||
|  |             InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} | ||||||
|  |             inputProps={{ step: "0.01" }} | ||||||
|  |             autoFocus | ||||||
|  |           /> | ||||||
|  |           <TextField | ||||||
|  |             label="Justificación del Ajuste" | ||||||
|  |             fullWidth | ||||||
|  |             required | ||||||
|  |             value={justificacion} | ||||||
|  |             onChange={(e) => { setJustificacion(e.target.value); handleInputChange('justificacion'); }} | ||||||
|  |             margin="normal" | ||||||
|  |             multiline | ||||||
|  |             rows={3} | ||||||
|  |             error={!!localErrors.justificacion} | ||||||
|  |             helperText={localErrors.justificacion || ''} | ||||||
|  |             disabled={loading} | ||||||
|  |             inputProps={{ maxLength: 250 }} | ||||||
|  |           /> | ||||||
|  |           {errorMessage && <Alert severity="error" sx={{ mt: 1 }}>{errorMessage}</Alert>} | ||||||
|  |           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||||
|  |             <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> | ||||||
|  |             <Button type="submit" variant="contained" disabled={loading}> | ||||||
|  |               {loading ? <CircularProgress size={24} /> : 'Aplicar Ajuste'} | ||||||
|  |             </Button> | ||||||
|  |           </Box> | ||||||
|  |         </Box> | ||||||
|  |       </Box> | ||||||
|  |     </Modal> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default AjusteSaldoModal; | ||||||
| @@ -8,14 +8,14 @@ import AddIcon from '@mui/icons-material/Add'; | |||||||
| import DeleteIcon from '@mui/icons-material/Delete'; | import DeleteIcon from '@mui/icons-material/Delete'; | ||||||
| import type { EntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/EntradaSalidaCanillaDto'; | import type { EntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/EntradaSalidaCanillaDto'; | ||||||
| import type { UpdateEntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/UpdateEntradaSalidaCanillaDto'; | import type { UpdateEntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/UpdateEntradaSalidaCanillaDto'; | ||||||
| import type { PublicacionDto } from '../../../models/dtos/Distribucion/PublicacionDto'; | // import type { CanillaDto } from '../../../models/dtos/Distribucion/CanillaDto'; // Ya no es necesario cargar todos los canillitas aquí | ||||||
| import type { CanillaDto } from '../../../models/dtos/Distribucion/CanillaDto'; |  | ||||||
| import publicacionService from '../../../services/Distribucion/publicacionService'; | import publicacionService from '../../../services/Distribucion/publicacionService'; | ||||||
| import canillaService from '../../../services/Distribucion/canillaService'; | // import canillaService from '../../../services/Distribucion/canillaService'; // Ya no es necesario | ||||||
| import entradaSalidaCanillaService from '../../../services/Distribucion/entradaSalidaCanillaService'; | import entradaSalidaCanillaService from '../../../services/Distribucion/entradaSalidaCanillaService'; | ||||||
| import type { CreateBulkEntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/CreateBulkEntradaSalidaCanillaDto'; | import type { CreateBulkEntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/CreateBulkEntradaSalidaCanillaDto'; | ||||||
| import type { EntradaSalidaCanillaItemDto } from '../../../models/dtos/Distribucion/EntradaSalidaCanillaItemDto'; | import type { EntradaSalidaCanillaItemDto } from '../../../models/dtos/Distribucion/EntradaSalidaCanillaItemDto'; | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
|  | import type { PublicacionDropdownDto } from '../../../models/dtos/Distribucion/PublicacionDropdownDto'; | ||||||
|  |  | ||||||
| const modalStyle = { | const modalStyle = { | ||||||
|   position: 'absolute' as 'absolute', |   position: 'absolute' as 'absolute', | ||||||
| @@ -34,14 +34,21 @@ const modalStyle = { | |||||||
| interface EntradaSalidaCanillaFormModalProps { | interface EntradaSalidaCanillaFormModalProps { | ||||||
|   open: boolean; |   open: boolean; | ||||||
|   onClose: () => void; |   onClose: () => void; | ||||||
|  |   // El onSubmit de la página padre se usa solo para edición. La creación se maneja internamente. | ||||||
|   onSubmit: (data: UpdateEntradaSalidaCanillaDto, idParte: number) => Promise<void>; |   onSubmit: (data: UpdateEntradaSalidaCanillaDto, idParte: number) => Promise<void>; | ||||||
|   initialData?: EntradaSalidaCanillaDto | null; |   initialData?: EntradaSalidaCanillaDto | null; // Para edición | ||||||
|  |   prefillData?: { // Para creación, prellenar desde la página padre | ||||||
|  |     fecha?: string; // YYYY-MM-DD | ||||||
|  |     idCanilla?: number | string; | ||||||
|  |     nombreCanilla?: string; // << AÑADIR NOMBRE PARA MOSTRAR | ||||||
|  |     idPublicacion?: number | string; // Para pre-seleccionar la primera publicación en la lista de items | ||||||
|  |   } | null; | ||||||
|   errorMessage?: string | null; |   errorMessage?: string | null; | ||||||
|   clearErrorMessage: () => void; |   clearErrorMessage: () => void; | ||||||
| } | } | ||||||
|  |  | ||||||
| interface FormRowItem { | interface FormRowItem { | ||||||
|   id: string; |   id: string; // ID temporal para el frontend | ||||||
|   idPublicacion: number | string; |   idPublicacion: number | string; | ||||||
|   cantSalida: string; |   cantSalida: string; | ||||||
|   cantEntrada: string; |   cantEntrada: string; | ||||||
| @@ -50,56 +57,62 @@ interface FormRowItem { | |||||||
|  |  | ||||||
| const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps> = ({ | const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps> = ({ | ||||||
|   open, |   open, | ||||||
|   onClose, // Este onClose es el que se pasa desde GestionarEntradasSalidasCanillaPage |   onClose, | ||||||
|   onSubmit, // Este onSubmit es el que se pasa para la lógica de EDICIÓN |   onSubmit: onSubmitEdit, // Renombrar para claridad, ya que solo se usa para editar | ||||||
|   initialData, |   initialData, | ||||||
|  |   prefillData, | ||||||
|   errorMessage: parentErrorMessage, |   errorMessage: parentErrorMessage, | ||||||
|   clearErrorMessage |   clearErrorMessage | ||||||
| }) => { | }) => { | ||||||
|   const [idCanilla, setIdCanilla] = useState<number | string>(''); |   // Estados para los campos que SÍ son editables o parte del formulario de items | ||||||
|   const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]); |   const [editIdPublicacion, setEditIdPublicacion] = useState<number | string>(''); // Solo para modo edición | ||||||
|   const [editIdPublicacion, setEditIdPublicacion] = useState<number | string>(''); |  | ||||||
|   const [editCantSalida, setEditCantSalida] = useState<string>('0'); |   const [editCantSalida, setEditCantSalida] = useState<string>('0'); | ||||||
|   const [editCantEntrada, setEditCantEntrada] = useState<string>('0'); |   const [editCantEntrada, setEditCantEntrada] = useState<string>('0'); | ||||||
|   const [editObservacion, setEditObservacion] = useState(''); |   const [editObservacion, setEditObservacion] = useState(''); | ||||||
|   const [items, setItems] = useState<FormRowItem[]>([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); // Iniciar con una fila |   const [items, setItems] = useState<FormRowItem[]>([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); | ||||||
|   const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]); |  | ||||||
|   const [canillitas, setCanillitas] = useState<CanillaDto[]>([]); |   // Estados para datos de dropdowns | ||||||
|   const [loading, setLoading] = useState(false); // Loading para submit |   const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]); // Sigue siendo necesario para la lista de items | ||||||
|   const [loadingDropdowns, setLoadingDropdowns] = useState(false); // Loading para canillas/pubs |    | ||||||
|   const [loadingItems, setLoadingItems] = useState(false); // Loading para pre-carga de items |   // Estados de carga y error | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [loadingDropdowns, setLoadingDropdowns] = useState(false); // Solo para publicaciones | ||||||
|  |   const [loadingItems, setLoadingItems] = useState(false); | ||||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|   const [modalSpecificApiError, setModalSpecificApiError] = useState<string | null>(null); |   const [modalSpecificApiError, setModalSpecificApiError] = useState<string | null>(null); | ||||||
|  |  | ||||||
|   const isEditing = Boolean(initialData); |   const isEditing = Boolean(initialData && initialData.idParte); | ||||||
|  |  | ||||||
|   // Efecto para cargar datos de dropdowns (Publicaciones, Canillitas) SOLO UNA VEZ o cuando open cambia a true |   // Datos que vienen prellenados y no son editables en el modal (Fecha y Canillita) | ||||||
|  |   const displayFecha = isEditing ? (initialData?.fecha ? initialData.fecha.split('T')[0] : '') : (prefillData?.fecha || ''); | ||||||
|  |   const displayIdCanilla = isEditing ? initialData?.idCanilla : prefillData?.idCanilla; | ||||||
|  |   const displayNombreCanilla = isEditing ? initialData?.nomApeCanilla : prefillData?.nombreCanilla; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   // Cargar publicaciones para el dropdown de items | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const fetchDropdownData = async () => { |     const fetchPublicacionesDropdown = async () => { | ||||||
|       setLoadingDropdowns(true); |       setLoadingDropdowns(true); | ||||||
|       setLocalErrors(prev => ({ ...prev, dropdowns: null })); |       setLocalErrors(prev => ({ ...prev, dropdowns: null })); | ||||||
|       try { |       try { | ||||||
|         const [pubsData, canillitasData] = await Promise.all([ |         // Usar getPublicacionesForDropdown si lo tienes, sino getAllPublicaciones | ||||||
|           publicacionService.getAllPublicaciones(undefined, undefined, true), |         const pubsData = await publicacionService.getPublicacionesForDropdown(true); | ||||||
|           canillaService.getAllCanillas(undefined, undefined, true) |  | ||||||
|         ]); |  | ||||||
|         setPublicaciones(pubsData); |         setPublicaciones(pubsData); | ||||||
|         setCanillitas(canillitasData); |  | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.error("Error al cargar datos para dropdowns", error); |         console.error("Error al cargar publicaciones para dropdown", error); | ||||||
|         setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar datos necesarios (publicaciones/canillitas).' })); |         setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar publicaciones.' })); | ||||||
|       } finally { |       } finally { | ||||||
|         setLoadingDropdowns(false); |         setLoadingDropdowns(false); | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (open) { |     if (open) { | ||||||
|       fetchDropdownData(); |       fetchPublicacionesDropdown(); | ||||||
|     } |     } | ||||||
|   }, [open]); |   }, [open]); | ||||||
|  |  | ||||||
|  |  | ||||||
|   // Efecto para inicializar el formulario cuando se abre o cambia initialData |   // Inicializar formulario y/o pre-cargar items | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (open) { |     if (open) { | ||||||
|       clearErrorMessage(); |       clearErrorMessage(); | ||||||
| @@ -107,34 +120,61 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|       setLocalErrors({}); |       setLocalErrors({}); | ||||||
|  |  | ||||||
|       if (isEditing && initialData) { |       if (isEditing && initialData) { | ||||||
|         setIdCanilla(initialData.idCanilla || ''); |  | ||||||
|         setFecha(initialData.fecha ? initialData.fecha.split('T')[0] : new Date().toISOString().split('T')[0]); |  | ||||||
|         setEditIdPublicacion(initialData.idPublicacion || ''); |         setEditIdPublicacion(initialData.idPublicacion || ''); | ||||||
|         setEditCantSalida(initialData.cantSalida?.toString() || '0'); |         setEditCantSalida(initialData.cantSalida?.toString() || '0'); | ||||||
|         setEditCantEntrada(initialData.cantEntrada?.toString() || '0'); |         setEditCantEntrada(initialData.cantEntrada?.toString() || '0'); | ||||||
|         setEditObservacion(initialData.observacion || ''); |         setEditObservacion(initialData.observacion || ''); | ||||||
|         setItems([]); // En modo edición, no pre-cargamos items de la lista |         setItems([]); // No hay lista de items en modo edición de un solo movimiento | ||||||
|       } else { |       } else { // Modo Creación | ||||||
|         // Modo NUEVO: resetear campos principales y dejar que el efecto de 'fecha' cargue los items |         // Limpiar campos de edición | ||||||
|         setIdCanilla(''); |         setEditIdPublicacion(''); | ||||||
|         setFecha(new Date().toISOString().split('T')[0]); // Fecha actual por defecto |         setEditCantSalida('0'); | ||||||
|         // Los items se cargarán por el siguiente useEffect basado en la fecha |         setEditCantEntrada('0'); | ||||||
|       } |         setEditObservacion(''); | ||||||
|     } |  | ||||||
|   }, [open, initialData, isEditing, clearErrorMessage]); |  | ||||||
|  |  | ||||||
|  |  | ||||||
|   // Efecto para pre-cargar/re-cargar items cuando cambia la FECHA (en modo NUEVO) |  | ||||||
|   // y cuando las publicaciones están disponibles. |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (open && !isEditing && publicaciones.length > 0 && fecha) { // Asegurarse que 'fecha' tiene un valor |  | ||||||
|       const diaSemana = new Date(fecha + 'T00:00:00Z').getUTCDay(); // Usar UTC para getDay consistente |  | ||||||
|       setLoadingItems(true); // Indicador de carga para los items |  | ||||||
|       setLocalErrors(prev => ({ ...prev, general: null })); |  | ||||||
|  |  | ||||||
|  |         // Lógica para pre-cargar items basada en displayFecha y prefillData.idPublicacion | ||||||
|  |         // (Esta lógica se mueve al siguiente useEffect que depende de displayFecha y publicaciones) | ||||||
|  |         // Por ahora, solo aseguramos que `items` se resetee si es necesario. | ||||||
|  |         const idPubPrefill = prefillData?.idPublicacion; | ||||||
|  |         if (idPubPrefill && publicaciones.length > 0) { | ||||||
|  |              // Si ya tenemos publicaciones, y un prefill de publicación, intentamos setearlo | ||||||
|  |              const diaSemana = new Date(displayFecha + 'T00:00:00Z').getUTCDay(); | ||||||
|  |              setLoadingItems(true); | ||||||
|  |              publicacionService.getPublicacionesPorDiaSemana(diaSemana) | ||||||
|  |                  .then(pubsPorDefecto => { | ||||||
|  |                      let itemsIniciales: FormRowItem[]; | ||||||
|  |                      if (pubsPorDefecto.find(p => p.idPublicacion === Number(idPubPrefill))) { | ||||||
|  |                          // Si la publicación prellenada está en las de por defecto, la usamos | ||||||
|  |                          itemsIniciales = [{ id: Date.now().toString(), idPublicacion: Number(idPubPrefill), cantSalida: '0', cantEntrada: '0', observacion: '' }]; | ||||||
|  |                      } else if (pubsPorDefecto.length > 0) { | ||||||
|  |                          // Si no, pero hay otras por defecto, usamos la primera de ellas | ||||||
|  |                          itemsIniciales = pubsPorDefecto.map(pub => ({ | ||||||
|  |                              id: `${Date.now().toString()}-${pub.idPublicacion}`, | ||||||
|  |                              idPublicacion: pub.idPublicacion, | ||||||
|  |                              cantSalida: '0', cantEntrada: '0', observacion: '' | ||||||
|  |                          })); | ||||||
|  |                      } else { | ||||||
|  |                          // Si no hay ninguna por defecto, y la prellenada no aplica, usamos la prellenada sola o vacía | ||||||
|  |                          itemsIniciales = [{ id: Date.now().toString(), idPublicacion: idPubPrefill || '', cantSalida: '0', cantEntrada: '0', observacion: '' }]; | ||||||
|  |                      } | ||||||
|  |                      setItems(itemsIniciales.length > 0 ? itemsIniciales : [{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); | ||||||
|  |                  }) | ||||||
|  |                  .catch(() => setItems([{ id: Date.now().toString(), idPublicacion: idPubPrefill || '', cantSalida: '0', cantEntrada: '0', observacion: '' }])) // Fallback | ||||||
|  |                  .finally(() => setLoadingItems(false)); | ||||||
|  |         } else if (publicaciones.length === 0 && !loadingDropdowns) { // Si no hay prefill de pub o no hay pubs aún | ||||||
|  |              setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, [open, initialData, isEditing, prefillData, clearErrorMessage, publicaciones, loadingDropdowns, displayFecha]); // Añadir displayFecha | ||||||
|  |  | ||||||
|  |   // Efecto para pre-cargar items por defecto cuando cambia la FECHA (displayFecha) en modo NUEVO | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (open && !isEditing && publicaciones.length > 0 && displayFecha) { | ||||||
|  |       const diaSemana = new Date(displayFecha + 'T00:00:00Z').getUTCDay(); | ||||||
|  |       setLoadingItems(true); | ||||||
|       publicacionService.getPublicacionesPorDiaSemana(diaSemana) |       publicacionService.getPublicacionesPorDiaSemana(diaSemana) | ||||||
|         .then(pubsPorDefecto => { |         .then(pubsPorDefecto => { | ||||||
|           if (pubsPorDefecto.length > 0) { |  | ||||||
|           const itemsPorDefecto = pubsPorDefecto.map(pub => ({ |           const itemsPorDefecto = pubsPorDefecto.map(pub => ({ | ||||||
|             id: `${Date.now().toString()}-${pub.idPublicacion}`, |             id: `${Date.now().toString()}-${pub.idPublicacion}`, | ||||||
|             idPublicacion: pub.idPublicacion, |             idPublicacion: pub.idPublicacion, | ||||||
| @@ -142,30 +182,28 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|             cantEntrada: '0', |             cantEntrada: '0', | ||||||
|             observacion: '' |             observacion: '' | ||||||
|           })); |           })); | ||||||
|             setItems(itemsPorDefecto); |           setItems(itemsPorDefecto.length > 0 ? itemsPorDefecto : [{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); | ||||||
|           } else { |  | ||||||
|             // Si no hay configuraciones para el día, iniciar con una fila vacía |  | ||||||
|             setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); |  | ||||||
|           } |  | ||||||
|         }) |         }) | ||||||
|         .catch(err => { |         .catch(err => { | ||||||
|           console.error("Error al cargar/recargar publicaciones por defecto para el día:", err); |           console.error("Error al cargar publicaciones por defecto para el día:", err); | ||||||
|           setLocalErrors(prev => ({ ...prev, general: 'Error al pre-cargar publicaciones del día.' })); |           setLocalErrors(prev => ({ ...prev, general: 'Error al pre-cargar publicaciones del día.' })); | ||||||
|           setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); |           setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); | ||||||
|         }) |         }) | ||||||
|         .finally(() => setLoadingItems(false)); |         .finally(() => setLoadingItems(false)); | ||||||
|     } else if (open && !isEditing && publicaciones.length === 0 && !loadingDropdowns) { |  | ||||||
|       // Si las publicaciones aún no se cargaron pero los dropdowns terminaron de cargar, iniciar con 1 item vacío. |  | ||||||
|       setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); |  | ||||||
|     } |     } | ||||||
|   }, [open, isEditing, fecha, publicaciones, loadingDropdowns]); // Dependencias clave |   }, [open, isEditing, displayFecha, publicaciones]); // Dependencia de displayFecha y publicaciones | ||||||
|  |  | ||||||
|  |  | ||||||
|   const validate = (): boolean => { |   const validate = (): boolean => { | ||||||
|  |     // ... (lógica de validación sin cambios, pero 'idCanilla' y 'fecha' ya no son estados del modal) | ||||||
|     const currentErrors: { [key: string]: string | null } = {}; |     const currentErrors: { [key: string]: string | null } = {}; | ||||||
|     if (!idCanilla) currentErrors.idCanilla = 'Seleccione un canillita.'; |     // Validar displayIdCanilla y displayFecha si es modo creación | ||||||
|     if (!fecha.trim()) currentErrors.fecha = 'La fecha es obligatoria.'; |     if (!isEditing) { | ||||||
|     else if (!/^\d{4}-\d{2}-\d{2}$/.test(fecha)) currentErrors.fecha = 'Formato de fecha inválido (YYYY-MM-DD).'; |         if (!displayIdCanilla) currentErrors.idCanilla = 'El canillita es obligatorio (provisto por la página).'; | ||||||
|  |         if (!displayFecha || !displayFecha.trim()) currentErrors.fecha = 'La fecha es obligatoria (provista por la página).'; | ||||||
|  |         else if (!/^\d{4}-\d{2}-\d{2}$/.test(displayFecha)) currentErrors.fecha = 'Formato de fecha inválido.'; | ||||||
|  |     } | ||||||
|  |     // ... resto de la validación para items (modo creación) o campos edit (modo edición) ... | ||||||
|     if (isEditing) { |     if (isEditing) { | ||||||
|       const salidaNum = parseInt(editCantSalida, 10); |       const salidaNum = parseInt(editCantSalida, 10); | ||||||
|       const entradaNum = parseInt(editCantEntrada, 10); |       const entradaNum = parseInt(editCantEntrada, 10); | ||||||
| @@ -177,14 +215,15 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|       } else if (!isNaN(salidaNum) && !isNaN(entradaNum) && entradaNum > salidaNum) { |       } else if (!isNaN(salidaNum) && !isNaN(entradaNum) && entradaNum > salidaNum) { | ||||||
|         currentErrors.editCantEntrada = 'Cant. Entrada no puede ser mayor a Cant. Salida.'; |         currentErrors.editCantEntrada = 'Cant. Entrada no puede ser mayor a Cant. Salida.'; | ||||||
|       } |       } | ||||||
|     } else { |       if (!editIdPublicacion) { // En edición, la publicación es fija, pero debe existir | ||||||
|  |         currentErrors.editIdPublicacion = 'Error: Publicación no especificada para edición.'; | ||||||
|  |       } | ||||||
|  |     } else { // Modo Creación (Bulk) | ||||||
|       let hasValidItemWithQuantityOrPub = false; |       let hasValidItemWithQuantityOrPub = false; | ||||||
|       const publicacionIdsEnLote = new Set<number>(); |       const publicacionIdsEnLote = new Set<number>(); | ||||||
|  |  | ||||||
|       if (items.length === 0) { |       if (items.length === 0) { | ||||||
|         currentErrors.general = "Debe agregar al menos una publicación."; |         currentErrors.general = "Debe agregar al menos una publicación."; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       items.forEach((item, index) => { |       items.forEach((item, index) => { | ||||||
|         const salidaNum = parseInt(item.cantSalida, 10); |         const salidaNum = parseInt(item.cantSalida, 10); | ||||||
|         const entradaNum = parseInt(item.cantEntrada, 10); |         const entradaNum = parseInt(item.cantEntrada, 10); | ||||||
| @@ -199,9 +238,8 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|           const pubIdNum = Number(item.idPublicacion); |           const pubIdNum = Number(item.idPublicacion); | ||||||
|           if (publicacionIdsEnLote.has(pubIdNum)) { |           if (publicacionIdsEnLote.has(pubIdNum)) { | ||||||
|             currentErrors[`item_${item.id}_idPublicacion`] = `Pub. ${index + 1} duplicada.`; |             currentErrors[`item_${item.id}_idPublicacion`] = `Pub. ${index + 1} duplicada.`; | ||||||
|           } else { |           } else { publicacionIdsEnLote.add(pubIdNum); } | ||||||
|             publicacionIdsEnLote.add(pubIdNum); |  | ||||||
|           } |  | ||||||
|           if (item.cantSalida.trim() === '' || isNaN(salidaNum) || salidaNum < 0) { |           if (item.cantSalida.trim() === '' || isNaN(salidaNum) || salidaNum < 0) { | ||||||
|             currentErrors[`item_${item.id}_cantSalida`] = `Salida Pub. ${index + 1} inválida.`; |             currentErrors[`item_${item.id}_cantSalida`] = `Salida Pub. ${index + 1} inválida.`; | ||||||
|           } |           } | ||||||
| @@ -213,18 +251,11 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|           if (item.idPublicacion !== '') hasValidItemWithQuantityOrPub = true; |           if (item.idPublicacion !== '') hasValidItemWithQuantityOrPub = true; | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
|  |       const allItemsAreEmptyAndNoPubSelected = items.every(itm => itm.idPublicacion === '' && (itm.cantSalida.trim() === '' || parseInt(itm.cantSalida, 10) === 0) && (itm.cantEntrada.trim() === '' || parseInt(itm.cantEntrada, 10) === 0) && itm.observacion.trim() === ''); | ||||||
|       const allItemsAreEmptyAndNoPubSelected = items.every( |       if (!hasValidItemWithQuantityOrPub && !allItemsAreEmptyAndNoPubSelected) { | ||||||
|         itm => itm.idPublicacion === '' && |  | ||||||
|           (itm.cantSalida.trim() === '' || parseInt(itm.cantSalida, 10) === 0) && |  | ||||||
|           (itm.cantEntrada.trim() === '' || parseInt(itm.cantEntrada, 10) === 0) && |  | ||||||
|           itm.observacion.trim() === '' |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       if (!isEditing && items.length > 0 && !hasValidItemWithQuantityOrPub && !allItemsAreEmptyAndNoPubSelected) { |  | ||||||
|         currentErrors.general = "Debe seleccionar una publicación para los ítems con cantidades y/o observaciones."; |         currentErrors.general = "Debe seleccionar una publicación para los ítems con cantidades y/o observaciones."; | ||||||
|       } else if (!isEditing && items.length > 0 && !items.some(i => i.idPublicacion !== '' && (parseInt(i.cantSalida, 10) > 0 || parseInt(i.cantEntrada, 10) > 0)) && !allItemsAreEmptyAndNoPubSelected) { |       } else if (items.length > 0 && !items.some(i => i.idPublicacion !== '' && (parseInt(i.cantSalida, 10) > 0 || parseInt(i.cantEntrada, 10) > 0 || i.observacion.trim() !== '')) && !allItemsAreEmptyAndNoPubSelected) { | ||||||
|         currentErrors.general = "Debe ingresar cantidades para al menos una publicación con datos significativos."; |         currentErrors.general = "Debe ingresar datos significativos (cantidades u observación) para al menos una publicación seleccionada."; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     setLocalErrors(currentErrors); |     setLocalErrors(currentErrors); | ||||||
| @@ -232,6 +263,7 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleInputChange = (fieldName: string) => { |   const handleInputChange = (fieldName: string) => { | ||||||
|  |     // ... (sin cambios) | ||||||
|     if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null })); |     if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null })); | ||||||
|     if (parentErrorMessage) clearErrorMessage(); |     if (parentErrorMessage) clearErrorMessage(); | ||||||
|     if (modalSpecificApiError) setModalSpecificApiError(null); |     if (modalSpecificApiError) setModalSpecificApiError(null); | ||||||
| @@ -252,12 +284,13 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|           cantEntrada: entradaNum, |           cantEntrada: entradaNum, | ||||||
|           observacion: editObservacion.trim() || undefined, |           observacion: editObservacion.trim() || undefined, | ||||||
|         }; |         }; | ||||||
|         // Aquí se llama al onSubmit que viene de la página padre (GestionarEntradasSalidasCanillaPage) |         await onSubmitEdit(dataToSubmitSingle, initialData.idParte); | ||||||
|         // para la lógica de actualización. |       } else { // Modo Creación | ||||||
|         await onSubmit(dataToSubmitSingle, initialData.idParte); |         if (!displayIdCanilla || !displayFecha) { | ||||||
|         onClose(); // Cerrar el modal DESPUÉS de un submit de edición exitoso |             setModalSpecificApiError("Faltan datos del canillita o la fecha para crear los movimientos."); | ||||||
|       } else { |             setLoading(false); | ||||||
|         // Lógica de creación BULK (se maneja internamente en el modal) |             return; | ||||||
|  |         } | ||||||
|         const itemsToSubmit: EntradaSalidaCanillaItemDto[] = items |         const itemsToSubmit: EntradaSalidaCanillaItemDto[] = items | ||||||
|           .filter(item => |           .filter(item => | ||||||
|             item.idPublicacion && Number(item.idPublicacion) > 0 && |             item.idPublicacion && Number(item.idPublicacion) > 0 && | ||||||
| @@ -271,37 +304,30 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|           })); |           })); | ||||||
|  |  | ||||||
|         if (itemsToSubmit.length === 0) { |         if (itemsToSubmit.length === 0) { | ||||||
|           setLocalErrors(prev => ({ ...prev, general: "No hay movimientos válidos para registrar..." })); |           setLocalErrors(prev => ({ ...prev, general: "No hay movimientos válidos para registrar." })); | ||||||
|           setLoading(false); |           setLoading(false); | ||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         const bulkData: CreateBulkEntradaSalidaCanillaDto = { |         const bulkData: CreateBulkEntradaSalidaCanillaDto = { | ||||||
|           idCanilla: Number(idCanilla), |           idCanilla: Number(displayIdCanilla), // Usar el displayIdCanilla | ||||||
|           fecha, |           fecha: displayFecha,               // Usar el displayFecha | ||||||
|           items: itemsToSubmit, |           items: itemsToSubmit, | ||||||
|         }; |         }; | ||||||
|         await entradaSalidaCanillaService.createBulkEntradasSalidasCanilla(bulkData); |         await entradaSalidaCanillaService.createBulkEntradasSalidasCanilla(bulkData); | ||||||
|         onClose(); // Cerrar el modal DESPUÉS de un submit de creación bulk exitoso |  | ||||||
|       } |       } | ||||||
|       // onClose(); // Movido dentro de los bloques if/else para asegurar que solo se llama tras éxito |       onClose(); | ||||||
|     } catch (error: any) { |     } catch (error: any) { | ||||||
|       console.error("Error en submit de EntradaSalidaCanillaFormModal:", error); |       console.error("Error en submit de EntradaSalidaCanillaFormModal:", error); | ||||||
|       if (axios.isAxiosError(error) && error.response) { |       const message = axios.isAxiosError(error) && error.response?.data?.message  | ||||||
|         setModalSpecificApiError(error.response.data?.message || 'Error al procesar la solicitud.'); |         ? error.response.data.message  | ||||||
|       } else { |         : 'Ocurrió un error inesperado al procesar la solicitud.'; | ||||||
|         setModalSpecificApiError('Ocurrió un error inesperado.'); |       setModalSpecificApiError(message); | ||||||
|       } |  | ||||||
|       // NO llamar a onClose() aquí si hubo un error, para que el modal permanezca abierto |  | ||||||
|       // y muestre el modalSpecificApiError. |  | ||||||
|       // Si la edición (que usa el 'onSubmit' del padre) lanza un error, ese error se propagará |  | ||||||
|       // al padre y el padre decidirá si el modal se cierra o no (actualmente no lo cierra). |  | ||||||
|     } finally { |     } finally { | ||||||
|       setLoading(false); |       setLoading(false); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleAddRow = () => { |   const handleAddRow = () => { /* ... (sin cambios) ... */  | ||||||
|     if (items.length >= publicaciones.length) { |     if (items.length >= publicaciones.length) { | ||||||
|       setLocalErrors(prev => ({ ...prev, general: "No se pueden agregar más filas que publicaciones disponibles." })); |       setLocalErrors(prev => ({ ...prev, general: "No se pueden agregar más filas que publicaciones disponibles." })); | ||||||
|       return; |       return; | ||||||
| @@ -309,15 +335,13 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|     setItems([...items, { id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); |     setItems([...items, { id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); | ||||||
|     setLocalErrors(prev => ({ ...prev, general: null })); |     setLocalErrors(prev => ({ ...prev, general: null })); | ||||||
|   }; |   }; | ||||||
|  |   const handleRemoveRow = (idToRemove: string) => { /* ... (sin cambios) ... */  | ||||||
|   const handleRemoveRow = (idToRemove: string) => { |  | ||||||
|     if (items.length <= 1 && !isEditing) return; |     if (items.length <= 1 && !isEditing) return; | ||||||
|     setItems(items.filter(item => item.id !== idToRemove)); |     setItems(items.filter(item => item.id !== idToRemove)); | ||||||
|   }; |   }; | ||||||
|  |   const handleItemChange = (id: string, field: keyof Omit<FormRowItem, 'id'>, value: string | number) => { /* ... (sin cambios) ... */ | ||||||
|   const handleItemChange = (id: string, field: keyof Omit<FormRowItem, 'id'>, value: string | number) => { |     setItems(items.map(itemRow => itemRow.id === id ? { ...itemRow, [field]: value } : itemRow)); | ||||||
|     setItems(items.map(itemRow => itemRow.id === id ? { ...itemRow, [field]: value } : itemRow)); // CORREGIDO: item a itemRow para evitar conflicto de nombres de variable con el `item` del map en el JSX |     if (localErrors[`item_${id}_${field}`]) { | ||||||
|     if (localErrors[`item_${id}_${field}`]) { // Aquí item se refiere al id del item. |  | ||||||
|       setLocalErrors(prev => ({ ...prev, [`item_${id}_${field}`]: null })); |       setLocalErrors(prev => ({ ...prev, [`item_${id}_${field}`]: null })); | ||||||
|     } |     } | ||||||
|     if (localErrors.general) setLocalErrors(prev => ({ ...prev, general: null })); |     if (localErrors.general) setLocalErrors(prev => ({ ...prev, general: null })); | ||||||
| @@ -325,49 +349,45 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|     if (modalSpecificApiError) setModalSpecificApiError(null); |     if (modalSpecificApiError) setModalSpecificApiError(null); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Modal open={open} onClose={onClose}> |     <Modal open={open} onClose={onClose}> | ||||||
|       <Box sx={modalStyle}> |       <Box sx={modalStyle}> | ||||||
|         <Typography variant="h6" component="h2" gutterBottom> |         <Typography variant="h6" component="h2" gutterBottom> | ||||||
|           {isEditing ? 'Editar Movimiento Canillita' : 'Registrar Movimientos Canillita'} |           {isEditing ? `Editar Movimiento (ID: ${initialData?.idParte})` : 'Registrar Nuevos Movimientos'} | ||||||
|         </Typography> |         </Typography> | ||||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> |         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||||
|           <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> |           {/* --- MOSTRAR DATOS PRELLENADOS/FIJOS --- */} | ||||||
|             <FormControl fullWidth margin="dense" error={!!localErrors.idCanilla} required> |           <Paper variant="outlined" sx={{ p: 1.5, mb: 2, backgroundColor: 'grey.100' }}> | ||||||
|               <InputLabel id="canilla-esc-select-label">Canillita</InputLabel> |             <Typography variant="body1" component="div" sx={{ display: 'flex', justifyContent: 'space-between', flexWrap:'wrap' }}> | ||||||
|               <Select labelId="canilla-esc-select-label" label="Canillita" value={idCanilla} |                 <Box><strong>{isEditing ? "Canillita:" : "Para Canillita:"}</strong> {displayNombreCanilla || 'N/A'}</Box> | ||||||
|                 onChange={(e) => { setIdCanilla(e.target.value as number); handleInputChange('idCanilla'); }} |                 <Box><strong>{isEditing ? "Fecha Movimiento:" : "Para Fecha:"}</strong> {displayFecha ? new Date(displayFecha + 'T00:00:00Z').toLocaleDateString('es-AR', {timeZone: 'UTC'}) : 'N/A'}</Box> | ||||||
|                 disabled={loading || loadingDropdowns || isEditing} |             </Typography> | ||||||
|               > |             {localErrors.idCanilla && <Typography color="error" variant="caption" display="block">{localErrors.idCanilla}</Typography>} | ||||||
|                 <MenuItem value="" disabled><em>Seleccione un canillita</em></MenuItem> |             {localErrors.fecha && <Typography color="error" variant="caption" display="block">{localErrors.fecha}</Typography>} | ||||||
|                 {canillitas.map((c) => (<MenuItem key={c.idCanilla} value={c.idCanilla}>{`${c.nomApe} (Leg: ${c.legajo || 'S/L'})`}</MenuItem>))} |           </Paper> | ||||||
|               </Select> |           {/* --- FIN DATOS PRELLENADOS --- */} | ||||||
|               {localErrors.idCanilla && <FormHelperText>{localErrors.idCanilla}</FormHelperText>} |  | ||||||
|             </FormControl> |  | ||||||
|  |  | ||||||
|             <TextField label="Fecha Movimientos" type="date" value={fecha} required |           {/* El Select de Canillita y TextField de Fecha se eliminan de aquí si son fijos */} | ||||||
|               onChange={(e) => { setFecha(e.target.value); handleInputChange('fecha'); }} |  | ||||||
|               margin="dense" fullWidth error={!!localErrors.fecha} helperText={localErrors.fecha || ''} |  | ||||||
|               disabled={loading || isEditing} InputLabelProps={{ shrink: true }} |  | ||||||
|               autoFocus={!isEditing && !idCanilla} // AutoFocus si es nuevo y no hay canillita seleccionado |  | ||||||
|             /> |  | ||||||
|  |  | ||||||
|           {isEditing && initialData && ( |           {isEditing && initialData && ( | ||||||
|             <Paper elevation={1} sx={{ p: 1.5, mt: 1 }}> |             <Paper elevation={1} sx={{ p: 1.5, mt: 1 }}> | ||||||
|                 <Typography variant="body2" gutterBottom color="text.secondary">Editando para Publicación: {publicaciones.find(p => p.idPublicacion === editIdPublicacion)?.nombre || `ID ${editIdPublicacion}`}</Typography> |               <Typography variant="body2" gutterBottom color="text.secondary"> | ||||||
|                 <Box sx={{ display: 'flex', gap: 2, mt: 0.5 }}> |                 Editando para Publicación: {publicaciones.find(p => p.idPublicacion === editIdPublicacion)?.nombre || `ID ${editIdPublicacion}`} | ||||||
|  |               </Typography> | ||||||
|  |               <Box sx={{ display: 'flex', gap: 2, mt: 0.5, flexWrap: 'wrap' }}> | ||||||
|                 <TextField label="Cant. Salida" type="number" value={editCantSalida} |                 <TextField label="Cant. Salida" type="number" value={editCantSalida} | ||||||
|                   onChange={(e) => { setEditCantSalida(e.target.value); handleInputChange('editCantSalida'); }} |                   onChange={(e) => { setEditCantSalida(e.target.value); handleInputChange('editCantSalida'); }} | ||||||
|                     margin="dense" fullWidth error={!!localErrors.editCantSalida} helperText={localErrors.editCantSalida || ''} |                   margin="dense" error={!!localErrors.editCantSalida} helperText={localErrors.editCantSalida || ''} | ||||||
|                     disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1 }} |                   disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1, minWidth:'120px' }} | ||||||
|                 /> |                 /> | ||||||
|                 <TextField label="Cant. Entrada" type="number" value={editCantEntrada} |                 <TextField label="Cant. Entrada" type="number" value={editCantEntrada} | ||||||
|                   onChange={(e) => { setEditCantEntrada(e.target.value); handleInputChange('editCantEntrada'); }} |                   onChange={(e) => { setEditCantEntrada(e.target.value); handleInputChange('editCantEntrada'); }} | ||||||
|                     margin="dense" fullWidth error={!!localErrors.editCantEntrada} helperText={localErrors.editCantEntrada || ''} |                   margin="dense" error={!!localErrors.editCantEntrada} helperText={localErrors.editCantEntrada || ''} | ||||||
|                     disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1 }} |                   disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1, minWidth:'120px' }} | ||||||
|                 /> |                 /> | ||||||
|               </Box> |               </Box> | ||||||
|                 <TextField label="Observación (General)" value={editObservacion} |               <TextField label="Observación (Movimiento)" value={editObservacion} // Label cambiado | ||||||
|                 onChange={(e) => setEditObservacion(e.target.value)} |                 onChange={(e) => setEditObservacion(e.target.value)} | ||||||
|                 margin="dense" fullWidth multiline rows={2} disabled={loading} sx={{ mt: 1 }} |                 margin="dense" fullWidth multiline rows={2} disabled={loading} sx={{ mt: 1 }} | ||||||
|               /> |               /> | ||||||
| @@ -377,148 +397,54 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps | |||||||
|           {!isEditing && ( |           {!isEditing && ( | ||||||
|             <Box> |             <Box> | ||||||
|               <Typography variant="subtitle2" sx={{ mt: 1.5, mb: 0.5 }}>Movimientos por Publicación:</Typography> |               <Typography variant="subtitle2" sx={{ mt: 1.5, mb: 0.5 }}>Movimientos por Publicación:</Typography> | ||||||
|                 {/* Indicador de carga para los items */} |  | ||||||
|               {loadingItems && <Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}><CircularProgress size={20} /></Box>} |               {loadingItems && <Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}><CircularProgress size={20} /></Box>} | ||||||
|               {!loadingItems && items.map((itemRow, index) => ( |               {!loadingItems && items.map((itemRow, index) => ( | ||||||
|                   <Paper |                 // ... (Renderizado de la fila de items sin cambios significativos, | ||||||
|                     key={itemRow.id} |                 //      solo asegúrate que el Select de Publicación use `publicaciones` y no `canillitas`) | ||||||
|                     elevation={1} |                 <Paper key={itemRow.id} elevation={1} sx={{ p: 1.5, mb: 1, }}> | ||||||
|                     sx={{ |                     <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, }}> | ||||||
|                       p: 1.5, |                       <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flexGrow: 1, }}> | ||||||
|                       mb: 1, |                         <FormControl sx={{ minWidth: 160, flexBasis: 'calc(40% - 8px)', flexGrow: 1, minHeight: 0 }} size="small" error={!!localErrors[`item_${itemRow.id}_idPublicacion`]} > | ||||||
|                     }} |                           <InputLabel required={ parseInt(itemRow.cantSalida) > 0 || parseInt(itemRow.cantEntrada) > 0 || itemRow.observacion.trim() !== '' } > | ||||||
|                   > |  | ||||||
|                     {/* Nivel 1: contenedor “padre” sin wrap */} |  | ||||||
|                     <Box |  | ||||||
|                       sx={{ |  | ||||||
|                         display: 'flex', |  | ||||||
|                         alignItems: 'center', // centra ícono + campos |  | ||||||
|                         gap: 1, |  | ||||||
|                         // NOTA: aquí NO ponemos flexWrap, por defecto es 'nowrap' |  | ||||||
|                       }} |  | ||||||
|                     > |  | ||||||
|                       {/* Nivel 2: contenedor que agrupa solo los campos y sí puede hacer wrap */} |  | ||||||
|                       <Box |  | ||||||
|                         sx={{ |  | ||||||
|                           display: 'flex', |  | ||||||
|                           alignItems: 'center', |  | ||||||
|                           gap: 1, |  | ||||||
|                           flexWrap: 'wrap',        // los campos sí hacen wrap si no caben |  | ||||||
|                           flexGrow: 1,             // ocupa todo el espacio disponible antes del ícono |  | ||||||
|                         }} |  | ||||||
|                       > |  | ||||||
|                         <FormControl |  | ||||||
|                           sx={{ minWidth: 160, flexBasis: 'calc(40% - 8px)', flexGrow: 1, minHeight: 0 }} |  | ||||||
|                           size="small" |  | ||||||
|                           error={!!localErrors[`item_${itemRow.id}_idPublicacion`]} |  | ||||||
|                         > |  | ||||||
|                           <InputLabel |  | ||||||
|                             required={ |  | ||||||
|                               parseInt(itemRow.cantSalida) > 0 || |  | ||||||
|                               parseInt(itemRow.cantEntrada) > 0 || |  | ||||||
|                               itemRow.observacion.trim() !== '' |  | ||||||
|                             } |  | ||||||
|                           > |  | ||||||
|                             Pub. {index + 1} |                             Pub. {index + 1} | ||||||
|                           </InputLabel> |                           </InputLabel> | ||||||
|                           <Select |                           <Select value={itemRow.idPublicacion} label={`Publicación ${index + 1}`} | ||||||
|                             value={itemRow.idPublicacion} |                             onChange={(e) => handleItemChange(itemRow.id, 'idPublicacion', e.target.value as number)} | ||||||
|                             label={`Publicación ${index + 1}`} |                             disabled={loading || loadingDropdowns} sx={{ minWidth: 0 }} > | ||||||
|                             onChange={(e) => |                             <MenuItem value="" disabled> <em>Seleccione</em> </MenuItem> | ||||||
|                               handleItemChange(itemRow.id, 'idPublicacion', e.target.value as number) |                             {publicaciones.map((p) => ( <MenuItem key={p.idPublicacion} value={p.idPublicacion}> {p.nombre} </MenuItem> ))} | ||||||
|                             } |  | ||||||
|                             disabled={loading || loadingDropdowns} |  | ||||||
|                             sx={{ minWidth: 0 }} // permite que shrink si hace falta |  | ||||||
|                           > |  | ||||||
|                             <MenuItem value="" disabled> |  | ||||||
|                               <em>Seleccione</em> |  | ||||||
|                             </MenuItem> |  | ||||||
|                             {publicaciones.map((p) => ( |  | ||||||
|                               <MenuItem key={p.idPublicacion} value={p.idPublicacion}> |  | ||||||
|                                 {p.nombre} |  | ||||||
|                               </MenuItem> |  | ||||||
|                             ))} |  | ||||||
|                           </Select> |                           </Select> | ||||||
|                           {localErrors[`item_${itemRow.id}_idPublicacion`] && ( |                           {localErrors[`item_${itemRow.id}_idPublicacion`] && ( <FormHelperText> {localErrors[`item_${itemRow.id}_idPublicacion`]} </FormHelperText> )} | ||||||
|                             <FormHelperText> |  | ||||||
|                               {localErrors[`item_${itemRow.id}_idPublicacion`]} |  | ||||||
|                             </FormHelperText> |  | ||||||
|                           )} |  | ||||||
|                         </FormControl> |                         </FormControl> | ||||||
|  |                         <TextField label="Llevados" type="number" size="small" value={itemRow.cantSalida} | ||||||
|                         <TextField |  | ||||||
|                           label="Llevados" |  | ||||||
|                           type="number" |  | ||||||
|                           size="small" |  | ||||||
|                           value={itemRow.cantSalida} |  | ||||||
|                           onChange={(e) => handleItemChange(itemRow.id, 'cantSalida', e.target.value)} |                           onChange={(e) => handleItemChange(itemRow.id, 'cantSalida', e.target.value)} | ||||||
|                           error={!!localErrors[`item_${itemRow.id}_cantSalida`]} |                           error={!!localErrors[`item_${itemRow.id}_cantSalida`]} helperText={localErrors[`item_${itemRow.id}_cantSalida`]} | ||||||
|                           helperText={localErrors[`item_${itemRow.id}_cantSalida`]} |                           inputProps={{ min: 0 }} sx={{ flexBasis: 'calc(15% - 8px)', minWidth: '80px', minHeight: 0, }} | ||||||
|                           inputProps={{ min: 0 }} |  | ||||||
|                           sx={{ |  | ||||||
|                             flexBasis: 'calc(15% - 8px)', |  | ||||||
|                             minWidth: '80px', |  | ||||||
|                             minHeight: 0, |  | ||||||
|                           }} |  | ||||||
|                         /> |                         /> | ||||||
|  |                         <TextField label="Devueltos" type="number" size="small" value={itemRow.cantEntrada} | ||||||
|                         <TextField |  | ||||||
|                           label="Devueltos" |  | ||||||
|                           type="number" |  | ||||||
|                           size="small" |  | ||||||
|                           value={itemRow.cantEntrada} |  | ||||||
|                           onChange={(e) => handleItemChange(itemRow.id, 'cantEntrada', e.target.value)} |                           onChange={(e) => handleItemChange(itemRow.id, 'cantEntrada', e.target.value)} | ||||||
|                           error={!!localErrors[`item_${itemRow.id}_cantEntrada`]} |                           error={!!localErrors[`item_${itemRow.id}_cantEntrada`]} helperText={localErrors[`item_${itemRow.id}_cantEntrada`]} | ||||||
|                           helperText={localErrors[`item_${itemRow.id}_cantEntrada`]} |                           inputProps={{ min: 0 }} sx={{ flexBasis: 'calc(15% - 8px)', minWidth: '80px', minHeight: 0, }} | ||||||
|                           inputProps={{ min: 0 }} |  | ||||||
|                           sx={{ |  | ||||||
|                             flexBasis: 'calc(15% - 8px)', |  | ||||||
|                             minWidth: '80px', |  | ||||||
|                             minHeight: 0, |  | ||||||
|                           }} |  | ||||||
|                         /> |                         /> | ||||||
|  |                         <TextField label="Obs." value={itemRow.observacion} onChange={(e) => handleItemChange(itemRow.id, 'observacion', e.target.value)} | ||||||
|                         <TextField |                           size="small" sx={{ flexGrow: 1, flexBasis: 'calc(25% - 8px)', minWidth: '120px', minHeight: 0, }} | ||||||
|                           label="Obs." |                           multiline maxRows={1} | ||||||
|                           value={itemRow.observacion} |  | ||||||
|                           onChange={(e) => handleItemChange(itemRow.id, 'observacion', e.target.value)} |  | ||||||
|                           size="small" |  | ||||||
|                           sx={{ |  | ||||||
|                             flexGrow: 1, |  | ||||||
|                             flexBasis: 'calc(25% - 8px)', |  | ||||||
|                             minWidth: '120px', |  | ||||||
|                             minHeight: 0, |  | ||||||
|                           }} |  | ||||||
|                           multiline |  | ||||||
|                           maxRows={1} |  | ||||||
|                         /> |                         /> | ||||||
|                       </Box> |                       </Box> | ||||||
|  |  | ||||||
|                       {/* Ícono de eliminar: siempre en la misma línea */} |  | ||||||
|                       {items.length > 1 && ( |                       {items.length > 1 && ( | ||||||
|                         <IconButton |                         <IconButton onClick={() => handleRemoveRow(itemRow.id)} color="error" aria-label="Quitar fila" sx={{ alignSelf: 'center', }} > | ||||||
|                           onClick={() => handleRemoveRow(itemRow.id)} |  | ||||||
|                           color="error" |  | ||||||
|                           aria-label="Quitar fila" |  | ||||||
|                           sx={{ |  | ||||||
|                             alignSelf: 'center', // mantén centrado verticalmente |  | ||||||
|                             // No necesita flexShrink, porque el padre no hace wrap |  | ||||||
|                           }} |  | ||||||
|                         > |  | ||||||
|                           <DeleteIcon fontSize="medium" /> |                           <DeleteIcon fontSize="medium" /> | ||||||
|                         </IconButton> |                         </IconButton> | ||||||
|                       )} |                       )} | ||||||
|                     </Box> |                     </Box> | ||||||
|                   </Paper> |                   </Paper> | ||||||
|               ))} |               ))} | ||||||
|  |  | ||||||
|               {localErrors.general && <Alert severity="error" sx={{ mt: 1 }}>{localErrors.general}</Alert>} |               {localErrors.general && <Alert severity="error" sx={{ mt: 1 }}>{localErrors.general}</Alert>} | ||||||
|               <Button onClick={handleAddRow} startIcon={<AddIcon />} sx={{ mt: 1, alignSelf: 'flex-start' }} disabled={loading || loadingDropdowns || items.length >= publicaciones.length}> |               <Button onClick={handleAddRow} startIcon={<AddIcon />} sx={{ mt: 1, alignSelf: 'flex-start' }} disabled={loading || loadingDropdowns || items.length >= publicaciones.length}> | ||||||
|                 Agregar Publicación |                 Agregar Publicación | ||||||
|               </Button> |               </Button> | ||||||
|             </Box> |             </Box> | ||||||
|           )} |           )} | ||||||
|           </Box> |  | ||||||
|  |  | ||||||
|           {parentErrorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{parentErrorMessage}</Alert>} |           {parentErrorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{parentErrorMessage}</Alert>} | ||||||
|           {modalSpecificApiError && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{modalSpecificApiError}</Alert>} |           {modalSpecificApiError && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{modalSpecificApiError}</Alert>} | ||||||
|   | |||||||
| @@ -0,0 +1,165 @@ | |||||||
|  | import React, { useState, useEffect } from 'react'; | ||||||
|  | import { | ||||||
|  |     Modal, Box, Typography, TextField, Button, CircularProgress, Alert | ||||||
|  | } from '@mui/material'; | ||||||
|  | import type { NovedadCanillaDto } from '../../../models/dtos/Distribucion/NovedadCanillaDto'; | ||||||
|  | import type { CreateNovedadCanillaDto } from '../../../models/dtos/Distribucion/CreateNovedadCanillaDto'; | ||||||
|  | import type { UpdateNovedadCanillaDto } from '../../../models/dtos/Distribucion/UpdateNovedadCanillaDto'; | ||||||
|  |  | ||||||
|  | const modalStyle = { | ||||||
|  |     position: 'absolute' as 'absolute', | ||||||
|  |     top: '50%', | ||||||
|  |     left: '50%', | ||||||
|  |     transform: 'translate(-50%, -50%)', | ||||||
|  |     width: { xs: '90%', sm: 500 }, | ||||||
|  |     bgcolor: 'background.paper', | ||||||
|  |     border: '2px solid #000', | ||||||
|  |     boxShadow: 24, | ||||||
|  |     p: 4, | ||||||
|  |     maxHeight: '90vh', | ||||||
|  |     overflowY: 'auto' | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | interface NovedadCanillaFormModalProps { | ||||||
|  |   open: boolean; | ||||||
|  |   onClose: () => void; | ||||||
|  |   onSubmit: (data: CreateNovedadCanillaDto | UpdateNovedadCanillaDto, idNovedad?: number) => Promise<void>; | ||||||
|  |   // Props para pasar datos necesarios: | ||||||
|  |   idCanilla: number | null; // Necesario para crear una nueva novedad | ||||||
|  |   nombreCanilla?: string;   // Para mostrar en el título | ||||||
|  |   initialData?: NovedadCanillaDto | null; // Para editar | ||||||
|  |   errorMessage?: string | null; | ||||||
|  |   clearErrorMessage: () => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const NovedadCanillaFormModal: React.FC<NovedadCanillaFormModalProps> = ({ | ||||||
|  |   open, | ||||||
|  |   onClose, | ||||||
|  |   onSubmit, | ||||||
|  |   idCanilla, | ||||||
|  |   nombreCanilla, | ||||||
|  |   initialData, | ||||||
|  |   errorMessage, | ||||||
|  |   clearErrorMessage | ||||||
|  | }) => { | ||||||
|  |   const [fecha, setFecha] = useState<string>(''); | ||||||
|  |   const [detalle, setDetalle] = useState(''); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|  |  | ||||||
|  |   const isEditing = Boolean(initialData && initialData.idNovedad); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (open) { | ||||||
|  |       setFecha(initialData?.fecha ? initialData.fecha.split('T')[0] : new Date().toISOString().split('T')[0]); | ||||||
|  |       setDetalle(initialData?.detalle || ''); | ||||||
|  |       setLocalErrors({}); | ||||||
|  |       clearErrorMessage(); | ||||||
|  |     } | ||||||
|  |   }, [open, initialData, clearErrorMessage]); | ||||||
|  |  | ||||||
|  |   const validate = (): boolean => { | ||||||
|  |     const errors: { [key: string]: string | null } = {}; | ||||||
|  |     if (!fecha.trim()) errors.fecha = 'La fecha es obligatoria.'; | ||||||
|  |     else if (!/^\d{4}-\d{2}-\d{2}$/.test(fecha)) errors.fecha = 'Formato de fecha inválido (YYYY-MM-DD).'; | ||||||
|  |      | ||||||
|  |     if (!detalle.trim()) errors.detalle = 'El detalle es obligatorio.'; | ||||||
|  |     else if (detalle.trim().length > 250) errors.detalle = 'El detalle no puede exceder los 250 caracteres.'; | ||||||
|  |  | ||||||
|  |     setLocalErrors(errors); | ||||||
|  |     return Object.keys(errors).length === 0; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleInputChange = (fieldName: 'fecha' | 'detalle') => { | ||||||
|  |     if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null })); | ||||||
|  |     if (errorMessage) clearErrorMessage(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||||
|  |     event.preventDefault(); | ||||||
|  |     clearErrorMessage(); | ||||||
|  |     if (!validate()) return; | ||||||
|  |  | ||||||
|  |     setLoading(true); | ||||||
|  |     try { | ||||||
|  |       if (isEditing && initialData) { | ||||||
|  |         const dataToSubmit: UpdateNovedadCanillaDto = { detalle }; | ||||||
|  |         await onSubmit(dataToSubmit, initialData.idNovedad); | ||||||
|  |       } else if (idCanilla) { // Asegurarse que idCanilla esté disponible para creación | ||||||
|  |         const dataToSubmit: CreateNovedadCanillaDto = { | ||||||
|  |             idCanilla: idCanilla, // Tomado de props | ||||||
|  |             fecha, | ||||||
|  |             detalle, | ||||||
|  |         }; | ||||||
|  |         await onSubmit(dataToSubmit); | ||||||
|  |       } else { | ||||||
|  |         // Esto no debería pasar si la lógica de la página que llama al modal es correcta | ||||||
|  |         setLocalErrors(prev => ({...prev, general: "No se proporcionó ID de Canillita para crear la novedad."})) | ||||||
|  |         setLoading(false); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       onClose(); | ||||||
|  |     } catch (error: any) { | ||||||
|  |       // El error de API es manejado por la página padre a través de 'errorMessage' | ||||||
|  |       console.error("Error en submit de NovedadCanillaFormModal:", error); | ||||||
|  |     } finally { | ||||||
|  |        setLoading(false); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Modal open={open} onClose={onClose}> | ||||||
|  |       <Box sx={modalStyle}> | ||||||
|  |         <Typography variant="h6" component="h2" gutterBottom> | ||||||
|  |           {isEditing ? 'Editar Novedad' : `Agregar Novedad para ${nombreCanilla || 'Canillita'}`} | ||||||
|  |         </Typography> | ||||||
|  |         <Typography variant="body2" color="text.secondary" gutterBottom> | ||||||
|  |           {isEditing && initialData ? `Editando Novedad ID: ${initialData.idNovedad}` : `Canillita ID: ${idCanilla || 'N/A'}`} | ||||||
|  |         </Typography> | ||||||
|  |         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||||
|  |             <TextField | ||||||
|  |                 label="Fecha Novedad" | ||||||
|  |                 type="date" | ||||||
|  |                 value={fecha} | ||||||
|  |                 required | ||||||
|  |                 onChange={(e) => {setFecha(e.target.value); handleInputChange('fecha');}} | ||||||
|  |                 margin="normal" | ||||||
|  |                 fullWidth | ||||||
|  |                 error={!!localErrors.fecha} | ||||||
|  |                 helperText={localErrors.fecha || ''} | ||||||
|  |                 disabled={loading || isEditing} // Fecha no se edita | ||||||
|  |                 InputLabelProps={{ shrink: true }} | ||||||
|  |                 autoFocus={!isEditing} | ||||||
|  |             /> | ||||||
|  |             <TextField | ||||||
|  |                 label="Detalle Novedad" | ||||||
|  |                 value={detalle} | ||||||
|  |                 required | ||||||
|  |                 onChange={(e) => {setDetalle(e.target.value); handleInputChange('detalle');}} | ||||||
|  |                 margin="normal" | ||||||
|  |                 fullWidth | ||||||
|  |                 multiline | ||||||
|  |                 rows={4} | ||||||
|  |                 error={!!localErrors.detalle} | ||||||
|  |                 helperText={localErrors.detalle || (detalle ? `${250 - detalle.length} caracteres restantes` : '')} | ||||||
|  |                 disabled={loading} | ||||||
|  |                 inputProps={{ maxLength: 250 }} | ||||||
|  |             /> | ||||||
|  |  | ||||||
|  |           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||||
|  |           {localErrors.general && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.general}</Alert>} | ||||||
|  |  | ||||||
|  |  | ||||||
|  |           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||||
|  |             <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> | ||||||
|  |             <Button type="submit" variant="contained" disabled={loading}> | ||||||
|  |               {loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Agregar Novedad')} | ||||||
|  |             </Button> | ||||||
|  |           </Box> | ||||||
|  |         </Box> | ||||||
|  |       </Box> | ||||||
|  |     </Modal> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default NovedadCanillaFormModal; | ||||||
| @@ -1,67 +1,225 @@ | |||||||
| import React from 'react'; | import React, { useState } from 'react'; | ||||||
| import { Box, Checkbox, FormControlLabel, FormGroup, Typography, Paper } from '@mui/material'; // Quitar Grid | import { Box, Checkbox, FormControlLabel, FormGroup, Typography, Paper, Divider, TextField } from '@mui/material'; | ||||||
| import type { PermisoAsignadoDto } from '../../../models/dtos/Usuarios/PermisoAsignadoDto'; | import type { PermisoAsignadoDto } from '../../../models/dtos/Usuarios/PermisoAsignadoDto'; | ||||||
|  |  | ||||||
| interface PermisosChecklistProps { | interface PermisosChecklistProps { | ||||||
|   permisosDisponibles: PermisoAsignadoDto[]; |   permisosDisponibles: PermisoAsignadoDto[]; | ||||||
|   permisosSeleccionados: Set<number>; |   permisosSeleccionados: Set<number>; | ||||||
|   onPermisoChange: (permisoId: number, asignado: boolean) => void; |   onPermisoChange: (permisoId: number, asignado: boolean, esPermisoSeccion?: boolean, moduloHijo?: string) => void; | ||||||
|   disabled?: boolean; |   disabled?: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | const SECCION_PERMISSIONS_PREFIX = "SS"; | ||||||
|  |  | ||||||
|  | // Mapeo de codAcc de sección a su módulo conceptual | ||||||
|  | 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; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Función para determinar el módulo conceptual de un permiso individual | ||||||
|  | const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | ||||||
|  |     const moduloLower = permisoModulo.toLowerCase(); | ||||||
|  |  | ||||||
|  |     if (moduloLower.includes("distribuidores") || | ||||||
|  |         moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas" | ||||||
|  |         moduloLower.includes("publicaciones distribución") || | ||||||
|  |         moduloLower.includes("zonas distribuidores") || | ||||||
|  |         moduloLower.includes("movimientos distribuidores") || | ||||||
|  |         moduloLower.includes("empresas") || // Módulo "Empresas" | ||||||
|  |         moduloLower.includes("otros destinos") || // Cubre "Otros Destinos" y "Salidas Otros Destinos" | ||||||
|  |         moduloLower.includes("ctrl. devoluciones")) { | ||||||
|  |         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") || // Cubre "Impresión Bobinas" y "Tipos Bobinas" | ||||||
|  |         moduloLower.includes("impresión plantas") || | ||||||
|  |         moduloLower.includes("tipos bobinas")) { // Añadido explícitamente | ||||||
|  |         return "Impresión"; | ||||||
|  |     } | ||||||
|  |     if (moduloLower.includes("radios")) { // Asumiendo que los permisos de radios tendrán "Radios" en su módulo | ||||||
|  |         return "Radios"; | ||||||
|  |     } | ||||||
|  |     if (moduloLower.includes("usuarios") || // Cubre "Usuarios" y "Perfiles" | ||||||
|  |         moduloLower.includes("perfiles")) { | ||||||
|  |         return "Usuarios"; | ||||||
|  |     } | ||||||
|  |     if (moduloLower.includes("reportes")) { // Para los permisos RRxxx | ||||||
|  |         return "Reportes"; | ||||||
|  |     } | ||||||
|  |     if (moduloLower.includes("permisos")) { // Para "Permisos (Definición)" | ||||||
|  |       return "Permisos (Definición)"; | ||||||
|  |     } | ||||||
|  |     return permisoModulo; // Fallback al nombre original si no coincide | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
| const PermisosChecklist: React.FC<PermisosChecklistProps> = ({ | const PermisosChecklist: React.FC<PermisosChecklistProps> = ({ | ||||||
|   permisosDisponibles, |   permisosDisponibles, | ||||||
|   permisosSeleccionados, |   permisosSeleccionados, | ||||||
|   onPermisoChange, |   onPermisoChange, | ||||||
|   disabled = false, |   disabled = false, | ||||||
| }) => { | }) => { | ||||||
|   const permisosAgrupados = permisosDisponibles.reduce((acc, permiso) => { |   const [filtrosModulo, setFiltrosModulo] = useState<Record<string, string>>({}); | ||||||
|     const modulo = permiso.modulo || 'Otros'; |  | ||||||
|     if (!acc[modulo]) { |   const handleFiltroChange = (moduloConceptual: string, texto: string) => { | ||||||
|       acc[modulo] = []; |     setFiltrosModulo(prev => ({ ...prev, [moduloConceptual]: texto.toLowerCase() })); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const permisosDeSeccion = permisosDisponibles.filter(p => p.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX)); | ||||||
|  |   const permisosNormales = permisosDisponibles.filter(p => !p.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX)); | ||||||
|  |  | ||||||
|  |   const permisosAgrupadosConceptualmente = permisosNormales.reduce((acc, permiso) => { | ||||||
|  |     const moduloConceptual = getModuloConceptualDelPermiso(permiso.modulo); | ||||||
|  |     if (!acc[moduloConceptual]) { | ||||||
|  |       acc[moduloConceptual] = []; | ||||||
|     } |     } | ||||||
|     acc[modulo].push(permiso); |     acc[moduloConceptual].push(permiso); | ||||||
|     return acc; |     return acc; | ||||||
|   }, {} as Record<string, PermisoAsignadoDto[]>); |   }, {} as Record<string, PermisoAsignadoDto[]>); | ||||||
|  |  | ||||||
|  |   const ordenModulosPrincipales = ["Distribución", "Contables", "Impresión", "Radios", "Usuarios", "Reportes", "Permisos (Definición)"]; | ||||||
|  |   // Añadir módulos que solo tienen permiso de sección (como Radios) pero no hijos (aún) | ||||||
|  |   permisosDeSeccion.forEach(ps => { | ||||||
|  |       const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc); | ||||||
|  |       if (moduloConceptual && !ordenModulosPrincipales.includes(moduloConceptual)) { | ||||||
|  |           // Insertar después de un módulo conocido o al final si no hay un orden específico para él | ||||||
|  |           const indexReportes = ordenModulosPrincipales.indexOf("Reportes"); | ||||||
|  |           if (indexReportes !== -1) { | ||||||
|  |             ordenModulosPrincipales.splice(indexReportes, 0, moduloConceptual); | ||||||
|  |           } else { | ||||||
|  |             ordenModulosPrincipales.push(moduloConceptual); | ||||||
|  |           } | ||||||
|  |       } | ||||||
|  |       if (moduloConceptual && !permisosAgrupadosConceptualmente[moduloConceptual]) { | ||||||
|  |           permisosAgrupadosConceptualmente[moduloConceptual] = []; // Asegurar que el grupo exista para Radios | ||||||
|  |       } | ||||||
|  |   }); | ||||||
|  |   // Eliminar duplicados del orden por si acaso | ||||||
|  |   const ordenFinalModulos = [...new Set(ordenModulosPrincipales)]; | ||||||
|  |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}> {/* Contenedor Flexbox */} |     <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5, justifyContent: 'center' }}> | ||||||
|       {Object.entries(permisosAgrupados).map(([modulo, permisosDelModulo]) => ( |       {ordenFinalModulos.map(moduloConceptual => { // Usar ordenFinalModulos | ||||||
|         <Box |         const permisoSeccionAsociado = permisosDeSeccion.find( | ||||||
|           key={modulo} |           ps => getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptual | ||||||
|           sx={{ |         ); | ||||||
|             flexGrow: 1, // Para que las columnas crezcan |         const permisosDelModuloHijosOriginales = permisosAgrupadosConceptualmente[moduloConceptual] || []; | ||||||
|             flexBasis: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(33.333% - 16px)' }, // Simula xs, sm, md |  | ||||||
|             // El '-16px' es por el gap (si el gap es 2 = 16px). Ajustar si el gap es diferente. |         // Condición para renderizar la sección | ||||||
|             // Alternativamente, usar porcentajes más simples y dejar que el flexWrap maneje el layout. |         if (!permisoSeccionAsociado && permisosDelModuloHijosOriginales.length === 0 && moduloConceptual !== "Permisos (Definición)") { | ||||||
|             // flexBasis: '300px', // Un ancho base y dejar que flexWrap haga el resto |             // No renderizar si no hay permiso de sección Y no hay hijos, EXCEPTO para "Permisos (Definición)" que es especial | ||||||
|             minWidth: '280px', // Ancho mínimo para cada columna |             return null; | ||||||
|             maxWidth: { xs: '100%', sm: '50%', md: '33.333%' }, // Máximo ancho |         } | ||||||
|           }} |          if (moduloConceptual === "Permisos (Definición)" && permisosDelModuloHijosOriginales.length === 0) { | ||||||
|         > |              // No renderizar "Permisos (Definición)" si no tiene hijos | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         const esSeccionSeleccionada = permisoSeccionAsociado ? permisosSeleccionados.has(permisoSeccionAsociado.id) : false; | ||||||
|  |         const todosHijosSeleccionados = permisosDelModuloHijosOriginales.length > 0 && permisosDelModuloHijosOriginales.every(p => permisosSeleccionados.has(p.id)); | ||||||
|  |         const ningunHijoSeleccionado = permisosDelModuloHijosOriginales.every(p => !permisosSeleccionados.has(p.id)); | ||||||
|  |         const algunosHijosSeleccionados = !todosHijosSeleccionados && !ningunHijoSeleccionado && permisosDelModuloHijosOriginales.length > 0; | ||||||
|  |  | ||||||
|  |         const textoFiltro = filtrosModulo[moduloConceptual] || ''; | ||||||
|  |         const permisosDelModuloHijosFiltrados = textoFiltro | ||||||
|  |             ? permisosDelModuloHijosOriginales.filter(permiso => | ||||||
|  |                 permiso.descPermiso.toLowerCase().includes(textoFiltro) || | ||||||
|  |                 permiso.codAcc.toLowerCase().includes(textoFiltro) | ||||||
|  |               ) | ||||||
|  |             : permisosDelModuloHijosOriginales; | ||||||
|  |  | ||||||
|  |         return ( | ||||||
|  |           <Box key={moduloConceptual} sx={{ /* ... estilos del Box ... */  | ||||||
|  |                 flexGrow: 1, | ||||||
|  |                 flexBasis: { xs: '100%', sm: 'calc(50% - 20px)', md: 'calc(33.333% - 20px)' }, | ||||||
|  |                 minWidth: '320px', // Aumentar un poco para el filtro | ||||||
|  |                 maxWidth: { xs: '100%', sm: 'calc(50% - 10px)', md: 'calc(33.333% - 10px)'}, | ||||||
|  |            }}> | ||||||
|             <Paper elevation={2} sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}> |             <Paper elevation={2} sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}> | ||||||
|               <Typography variant="subtitle1" gutterBottom component="div" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 1 }}> |               <Typography variant="subtitle1" gutterBottom component="div" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 1 }}> | ||||||
|               {modulo} |                 {moduloConceptual} | ||||||
|               </Typography> |               </Typography> | ||||||
|             <FormGroup sx={{ flexGrow: 1}}> {/* Para que ocupe el espacio vertical */} |  | ||||||
|               {permisosDelModulo.map((permiso) => ( |               {permisosDelModuloHijosOriginales.length > 3 && ( // Mostrar filtro si hay más de 3 permisos | ||||||
|  |                  <TextField | ||||||
|  |                     label={`Buscar en ${moduloConceptual}...`} | ||||||
|  |                     variant="standard" | ||||||
|  |                     size="small" | ||||||
|  |                     fullWidth | ||||||
|  |                     value={filtrosModulo[moduloConceptual] || ''} | ||||||
|  |                     onChange={(e) => handleFiltroChange(moduloConceptual, e.target.value)} | ||||||
|  |                     sx={{ mb: 1, mt: 0.5 }} | ||||||
|  |                     disabled={disabled} | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|  |  | ||||||
|  |               {permisoSeccionAsociado && ( | ||||||
|  |                 <> | ||||||
|  |                   <FormControlLabel | ||||||
|  |                     label={`Acceso a Sección ${moduloConceptual}`} // Cambiado el Label | ||||||
|  |                     labelPlacement="end" | ||||||
|  |                     sx={{mb:1, '& .MuiFormControlLabel-label': { fontWeight: 'medium'}}} | ||||||
|  |                     control={ | ||||||
|  |                       <Checkbox | ||||||
|  |                         checked={esSeccionSeleccionada && (permisosDelModuloHijosOriginales.length === 0 || todosHijosSeleccionados)} | ||||||
|  |                         indeterminate={esSeccionSeleccionada && (algunosHijosSeleccionados || (ningunHijoSeleccionado && permisosDelModuloHijosOriginales.length > 0))} | ||||||
|  |                         onChange={() => onPermisoChange(permisoSeccionAsociado.id, false, true, moduloConceptual)} | ||||||
|  |                         disabled={disabled} | ||||||
|  |                         size="small" | ||||||
|  |                       /> | ||||||
|  |                     } | ||||||
|  |                   /> | ||||||
|  |                   {/* Mostrar Divider solo si hay hijos o es la sección Radios (que queremos que aparezca aunque esté vacía) */} | ||||||
|  |                   {(permisosDelModuloHijosOriginales.length > 0 || moduloConceptual === "Radios") && <Divider sx={{mb:1.5}}/> } | ||||||
|  |                 </> | ||||||
|  |               )} | ||||||
|  |                | ||||||
|  |               <Box sx={{ maxHeight: '280px', overflowY: 'auto', flexGrow: 1 }}> {/* Aumentar un poco maxHeight */} | ||||||
|  |                   <FormGroup sx={{ pl: permisoSeccionAsociado ? 2 : 0 }}> | ||||||
|  |                     {permisosDelModuloHijosFiltrados.map((permiso) => ( | ||||||
|                       <FormControlLabel |                       <FormControlLabel | ||||||
|                         key={permiso.id} |                         key={permiso.id} | ||||||
|                         control={ |                         control={ | ||||||
|                           <Checkbox |                           <Checkbox | ||||||
|                             checked={permisosSeleccionados.has(permiso.id)} |                             checked={permisosSeleccionados.has(permiso.id)} | ||||||
|                       onChange={(e) => onPermisoChange(permiso.id, e.target.checked)} |                             onChange={(e) => onPermisoChange(permiso.id, e.target.checked, false, moduloConceptual)} | ||||||
|                       disabled={disabled} |                             disabled={disabled || (permisoSeccionAsociado && !esSeccionSeleccionada)} | ||||||
|                             size="small" |                             size="small" | ||||||
|                           /> |                           /> | ||||||
|                         } |                         } | ||||||
|                         label={<Typography variant="body2">{`${permiso.descPermiso} (${permiso.codAcc})`}</Typography>} |                         label={<Typography variant="body2">{`${permiso.descPermiso} (${permiso.codAcc})`}</Typography>} | ||||||
|                       /> |                       /> | ||||||
|                     ))} |                     ))} | ||||||
|  |                     {textoFiltro && permisosDelModuloHijosFiltrados.length === 0 && permisosDelModuloHijosOriginales.length > 0 && ( | ||||||
|  |                         <Typography variant="caption" sx={{p:1, fontStyle: 'italic', textAlign: 'center'}}> | ||||||
|  |                             No hay permisos que coincidan con "{textoFiltro}". | ||||||
|  |                         </Typography> | ||||||
|  |                     )} | ||||||
|  |                     {/* Mensaje si no hay hijos en general (y no es por filtro) */} | ||||||
|  |                     {permisosDelModuloHijosOriginales.length === 0 && !textoFiltro && moduloConceptual !== "Permisos (Definición)" && ( | ||||||
|  |                          <Typography variant="caption" sx={{p:1, fontStyle: 'italic', textAlign: 'center'}}> | ||||||
|  |                             No hay permisos específicos en esta sección. | ||||||
|  |                         </Typography> | ||||||
|  |                     )} | ||||||
|                   </FormGroup> |                   </FormGroup> | ||||||
|  |               </Box> | ||||||
|             </Paper> |             </Paper> | ||||||
|           </Box> |           </Box> | ||||||
|       ))} |         ); | ||||||
|  |       })} | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,32 +1,37 @@ | |||||||
| import React, { type ReactNode, useState, useEffect } from 'react'; | // src/layouts/MainLayout.tsx | ||||||
|  | import React, { type ReactNode, useState, useEffect, useMemo } // << AÑADIR useMemo | ||||||
|  |     from 'react'; | ||||||
| import { | import { | ||||||
|     Box, AppBar, Toolbar, Typography, Tabs, Tab, Paper, |     Box, AppBar, Toolbar, Typography, Tabs, Tab, Paper, | ||||||
|     IconButton, Menu, MenuItem, ListItemIcon, ListItemText, Divider // Nuevas importaciones |     IconButton, Menu, MenuItem, ListItemIcon, ListItemText, Divider, | ||||||
|  |     Button | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import AccountCircle from '@mui/icons-material/AccountCircle'; // Icono de usuario | import AccountCircle from '@mui/icons-material/AccountCircle'; | ||||||
| import LockResetIcon from '@mui/icons-material/LockReset'; // Icono para cambiar contraseña | import LockResetIcon from '@mui/icons-material/LockReset'; | ||||||
| import LogoutIcon from '@mui/icons-material/Logout'; // Icono para cerrar sesión | import LogoutIcon from '@mui/icons-material/Logout'; | ||||||
| import { useAuth } from '../contexts/AuthContext'; | import { useAuth } from '../contexts/AuthContext'; | ||||||
| import ChangePasswordModal from '../components/Modals/Usuarios/ChangePasswordModal'; | import ChangePasswordModal from '../components/Modals/Usuarios/ChangePasswordModal'; | ||||||
| import { useNavigate, useLocation } from 'react-router-dom'; | import { useNavigate, useLocation } from 'react-router-dom'; | ||||||
|  | import { usePermissions } from '../hooks/usePermissions'; // <<--- AÑADIR ESTA LÍNEA | ||||||
|  |  | ||||||
| interface MainLayoutProps { | interface MainLayoutProps { | ||||||
|     children: ReactNode; |     children: ReactNode; | ||||||
| } | } | ||||||
|  |  | ||||||
| const modules = [ | // Definición original de módulos | ||||||
|     { label: 'Inicio', path: '/' }, | const allAppModules = [ | ||||||
|     { label: 'Distribución', path: '/distribucion' }, |     { label: 'Inicio', path: '/', requiredPermission: null }, // Inicio siempre visible | ||||||
|     { label: 'Contables', path: '/contables' }, |     { label: 'Distribución', path: '/distribucion', requiredPermission: 'SS001' }, | ||||||
|     { label: 'Impresión', path: '/impresion' }, |     { label: 'Contables', path: '/contables', requiredPermission: 'SS002' }, | ||||||
|     { label: 'Reportes', path: '/reportes' }, |     { label: 'Impresión', path: '/impresion', requiredPermission: 'SS003' }, | ||||||
|     { label: 'Radios', path: '/radios' }, |     { label: 'Reportes', path: '/reportes', requiredPermission: 'SS004' }, | ||||||
|     { label: 'Usuarios', path: '/usuarios' }, |     { label: 'Radios', path: '/radios', requiredPermission: 'SS005' }, | ||||||
|  |     { label: 'Usuarios', path: '/usuarios', requiredPermission: 'SS006' }, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { | const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { | ||||||
|     const { |     const { | ||||||
|         user, |         user, // user ya está disponible aquí | ||||||
|         logout, |         logout, | ||||||
|         isAuthenticated, |         isAuthenticated, | ||||||
|         isPasswordChangeForced, |         isPasswordChangeForced, | ||||||
| @@ -35,24 +40,40 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { | |||||||
|         passwordChangeCompleted |         passwordChangeCompleted | ||||||
|     } = useAuth(); |     } = useAuth(); | ||||||
|  |  | ||||||
|  |     const { tienePermiso, isSuperAdmin } = usePermissions(); // <<--- OBTENER HOOK DE PERMISOS | ||||||
|     const navigate = useNavigate(); |     const navigate = useNavigate(); | ||||||
|     const location = useLocation(); |     const location = useLocation(); | ||||||
|  |  | ||||||
|     const [selectedTab, setSelectedTab] = useState<number | false>(false); |     const [selectedTab, setSelectedTab] = useState<number | false>(false); | ||||||
|     const [anchorElUserMenu, setAnchorElUserMenu] = useState<null | HTMLElement>(null); // Estado para el menú de usuario |     const [anchorElUserMenu, setAnchorElUserMenu] = useState<null | HTMLElement>(null); | ||||||
|  |  | ||||||
|  |     // --- INICIO DE CAMBIO: Filtrar módulos basados en permisos --- | ||||||
|  |     const accessibleModules = useMemo(() => { | ||||||
|  |         if (!isAuthenticated) return []; // Si no está autenticado, ningún módulo excepto quizás login (que no está aquí) | ||||||
|  |         return allAppModules.filter(module => { | ||||||
|  |             if (module.requiredPermission === null) return true; // Inicio siempre accesible | ||||||
|  |             return isSuperAdmin || tienePermiso(module.requiredPermission); | ||||||
|  |         }); | ||||||
|  |     }, [isAuthenticated, isSuperAdmin, tienePermiso]); | ||||||
|  |     // --- FIN DE CAMBIO --- | ||||||
|  |  | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         const currentModulePath = modules.findIndex(module => |         // --- INICIO DE CAMBIO: Usar accessibleModules para encontrar el tab --- | ||||||
|  |         const currentModulePath = accessibleModules.findIndex(module => | ||||||
|             location.pathname === module.path || (module.path !== '/' && location.pathname.startsWith(module.path + '/')) |             location.pathname === module.path || (module.path !== '/' && location.pathname.startsWith(module.path + '/')) | ||||||
|         ); |         ); | ||||||
|         if (currentModulePath !== -1) { |         if (currentModulePath !== -1) { | ||||||
|             setSelectedTab(currentModulePath); |             setSelectedTab(currentModulePath); | ||||||
|         } else if (location.pathname === '/') { |         } else if (location.pathname === '/') { | ||||||
|             setSelectedTab(0); // Asegurar que la pestaña de Inicio se seleccione para la ruta raíz |             // Asegurar que Inicio se seleccione si es accesible | ||||||
|  |             const inicioIndex = accessibleModules.findIndex(m => m.path === '/'); | ||||||
|  |             if (inicioIndex !== -1) setSelectedTab(inicioIndex); | ||||||
|  |             else setSelectedTab(false); | ||||||
|         } else { |         } else { | ||||||
|             setSelectedTab(false); // Ninguna pestaña seleccionada si no coincide |             setSelectedTab(false); | ||||||
|         } |         } | ||||||
|     }, [location.pathname]); |         // --- FIN DE CAMBIO --- | ||||||
|  |     }, [location.pathname, accessibleModules]); // << CAMBIO: dependencia a accessibleModules | ||||||
|  |  | ||||||
|     const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => { |     const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => { | ||||||
|         setAnchorElUserMenu(event.currentTarget); |         setAnchorElUserMenu(event.currentTarget); | ||||||
| @@ -69,7 +90,7 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { | |||||||
|  |  | ||||||
|     const handleLogoutClick = () => { |     const handleLogoutClick = () => { | ||||||
|         logout(); |         logout(); | ||||||
|         handleCloseUserMenu(); // Cierra el menú antes de desloguear completamente |         handleCloseUserMenu(); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     const handleModalClose = (passwordChangedSuccessfully: boolean) => { |     const handleModalClose = (passwordChangedSuccessfully: boolean) => { | ||||||
| @@ -77,22 +98,26 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { | |||||||
|             passwordChangeCompleted(); |             passwordChangeCompleted(); | ||||||
|         } else { |         } else { | ||||||
|             if (isPasswordChangeForced) { |             if (isPasswordChangeForced) { | ||||||
|                 logout(); |                 logout(); // Si es forzado y cancela/falla, desloguear | ||||||
|             } else { |             } else { | ||||||
|                 setShowForcedPasswordChangeModal(false); |                  setShowForcedPasswordChangeModal(false); // Si no es forzado, solo cerrar modal | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { |     const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { | ||||||
|  |         // --- INICIO DE CAMBIO: Navegar usando accessibleModules --- | ||||||
|  |         if (accessibleModules[newValue]) { | ||||||
|             setSelectedTab(newValue); |             setSelectedTab(newValue); | ||||||
|         navigate(modules[newValue].path); |             navigate(accessibleModules[newValue].path); | ||||||
|  |         } | ||||||
|  |         // --- FIN DE CAMBIO --- | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Determinar si el módulo actual es el de Reportes |  | ||||||
|     const isReportesModule = location.pathname.startsWith('/reportes'); |     const isReportesModule = location.pathname.startsWith('/reportes'); | ||||||
|  |  | ||||||
|     if (showForcedPasswordChangeModal && isPasswordChangeForced) { |     if (showForcedPasswordChangeModal && isPasswordChangeForced) { | ||||||
|  |         // ... (sin cambios) | ||||||
|          return ( |          return ( | ||||||
|             <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}> |             <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}> | ||||||
|                 <ChangePasswordModal |                 <ChangePasswordModal | ||||||
| @@ -104,17 +129,31 @@ const MainLayout: React.FC<MainLayoutProps> = ({ 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". | ||||||
|  |     if (isAuthenticated && !isPasswordChangeForced && accessibleModules.length === 0) { | ||||||
|  |         return ( | ||||||
|  |             <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh' }}> | ||||||
|  |                 <Typography variant="h6">No tiene acceso a ninguna sección del sistema.</Typography> | ||||||
|  |                 <Button onClick={logout} sx={{ mt: 2 }}>Cerrar Sesión</Button> | ||||||
|  |             </Box> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}> |         <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}> | ||||||
|             <AppBar position="sticky" elevation={1} /* Elevation sutil para AppBar */> |             <AppBar position="sticky" elevation={1}> | ||||||
|                 <Toolbar sx={{ display: 'flex', justifyContent: 'space-between' }}> |                 <Toolbar sx={{ display: 'flex', justifyContent: 'space-between' }}> | ||||||
|                     <Typography variant="h6" component="div" noWrap sx={{ cursor: 'pointer' }} onClick={() => navigate('/')}> |                     <Typography variant="h6" component="div" noWrap sx={{ cursor: 'pointer' }} onClick={() => navigate('/')}> | ||||||
|                         Sistema de Gestión - El Día |                         Sistema de Gestión - El Día | ||||||
|                     </Typography> |                     </Typography> | ||||||
|  |  | ||||||
|                     <Box sx={{ display: 'flex', alignItems: 'center' }}> |                     <Box sx={{ display: 'flex', alignItems: 'center' }}> | ||||||
|  |                         {/* ... (Menú de usuario sin cambios) ... */} | ||||||
|                          {user && ( |                          {user && ( | ||||||
|                             <Typography sx={{ mr: 2, display: { xs: 'none', sm: 'block' } }} /* Ocultar en pantallas muy pequeñas */> |                             <Typography sx={{ mr: 2, display: { xs: 'none', sm: 'block' } }} > | ||||||
|                                 Hola, {user.nombreCompleto} |                                 Hola, {user.nombreCompleto} | ||||||
|                             </Typography> |                             </Typography> | ||||||
|                         )} |                         )} | ||||||
| @@ -125,9 +164,7 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { | |||||||
|                                     aria-label="Cuenta del usuario" |                                     aria-label="Cuenta del usuario" | ||||||
|                                     aria-controls="menu-appbar" |                                     aria-controls="menu-appbar" | ||||||
|                                     aria-haspopup="true" |                                     aria-haspopup="true" | ||||||
|                                     sx={{ |                                     sx={{ padding: '15px' }} | ||||||
|                                         padding: '15px', |  | ||||||
|                                     }} |  | ||||||
|                                     onClick={handleOpenUserMenu} |                                     onClick={handleOpenUserMenu} | ||||||
|                                     color="inherit" |                                     color="inherit" | ||||||
|                                 > |                                 > | ||||||
| @@ -143,15 +180,14 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { | |||||||
|                                     onClose={handleCloseUserMenu} |                                     onClose={handleCloseUserMenu} | ||||||
|                                     sx={{ '& .MuiPaper-root': { minWidth: 220, marginTop: '8px' } }} |                                     sx={{ '& .MuiPaper-root': { minWidth: 220, marginTop: '8px' } }} | ||||||
|                                 > |                                 > | ||||||
|                                     {user && ( // Mostrar info del usuario en el menú |                                     {user && ( | ||||||
|                                         <Box sx={{ px: 2, py: 1.5, pointerEvents: 'none' /* Para que no sea clickeable */ }}> |                                         <Box sx={{ px: 2, py: 1.5, pointerEvents: 'none' }}> | ||||||
|                                             <Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>{user.nombreCompleto}</Typography> |                                             <Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>{user.nombreCompleto}</Typography> | ||||||
|                                             <Typography variant="body2" color="text.secondary">{user.username}</Typography> |                                             <Typography variant="body2" color="text.secondary">{user.username}</Typography> | ||||||
|                                         </Box> |                                         </Box> | ||||||
|                                     )} |                                     )} | ||||||
|                                     {user && <Divider sx={{ mb: 1 }} />} |                                     {user && <Divider sx={{ mb: 1 }} />} | ||||||
|  |                                     {!isPasswordChangeForced && ( | ||||||
|                                     {!isPasswordChangeForced && ( // No mostrar si ya está forzado a cambiarla |  | ||||||
|                                         <MenuItem onClick={handleChangePasswordClick}> |                                         <MenuItem onClick={handleChangePasswordClick}> | ||||||
|                                             <ListItemIcon><LockResetIcon fontSize="small" /></ListItemIcon> |                                             <ListItemIcon><LockResetIcon fontSize="small" /></ListItemIcon> | ||||||
|                                             <ListItemText>Cambiar Contraseña</ListItemText> |                                             <ListItemText>Cambiar Contraseña</ListItemText> | ||||||
| @@ -166,48 +202,45 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { | |||||||
|                         )} |                         )} | ||||||
|                     </Box> |                     </Box> | ||||||
|                 </Toolbar> |                 </Toolbar> | ||||||
|  |                 {/* --- INICIO DE CAMBIO: Renderizar Tabs solo si hay módulos accesibles y está autenticado --- */} | ||||||
|  |                 {isAuthenticated && accessibleModules.length > 0 && ( | ||||||
|                     <Paper square elevation={0} > |                     <Paper square elevation={0} > | ||||||
|                         <Tabs |                         <Tabs | ||||||
|                             value={selectedTab} |                             value={selectedTab} | ||||||
|                             onChange={handleTabChange} |                             onChange={handleTabChange} | ||||||
|                         indicatorColor="secondary" // O 'primary' si prefieres el mismo color que el fondo |                             indicatorColor="secondary" | ||||||
|                         textColor="inherit" // El texto de la pestaña hereda el color (blanco sobre fondo oscuro) |                             textColor="inherit" | ||||||
|                             variant="scrollable" |                             variant="scrollable" | ||||||
|                             scrollButtons="auto" |                             scrollButtons="auto" | ||||||
|                             allowScrollButtonsMobile |                             allowScrollButtonsMobile | ||||||
|                             aria-label="módulos principales" |                             aria-label="módulos principales" | ||||||
|                             sx={{ |                             sx={{ | ||||||
|                             backgroundColor: 'primary.main', // Color de fondo de las pestañas |                                 backgroundColor: 'primary.main', | ||||||
|                             color: 'white', // Color del texto de las pestañas |                                 color: 'white', | ||||||
|                             '& .MuiTabs-indicator': { |                                 '& .MuiTabs-indicator': { height: 3 }, | ||||||
|                                 height: 3, // Un indicador un poco más grueso |                                 '& .MuiTab-root': { | ||||||
|                             }, |                                     minWidth: 100, textTransform: 'none', | ||||||
|                             '& .MuiTab-root': { // Estilo para cada pestaña |                                     fontWeight: 'normal', opacity: 0.85, | ||||||
|                                 minWidth: 100, // Ancho mínimo para cada pestaña |                                     '&.Mui-selected': { fontWeight: 'bold', opacity: 1 }, | ||||||
|                                 textTransform: 'none', // Evitar MAYÚSCULAS por defecto |  | ||||||
|                                 fontWeight: 'normal', |  | ||||||
|                                 opacity: 0.85, // Ligeramente transparentes si no están seleccionadas |  | ||||||
|                                 '&.Mui-selected': { |  | ||||||
|                                     fontWeight: 'bold', |  | ||||||
|                                     opacity: 1, |  | ||||||
|                                     // color: 'secondary.main' // Opcional: color diferente para la pestaña seleccionada |  | ||||||
|                                 }, |  | ||||||
|                                 } |                                 } | ||||||
|                             }} |                             }} | ||||||
|                         > |                         > | ||||||
|                         {modules.map((module) => ( |                             {/* Mapear sobre accessibleModules en lugar de allAppModules */} | ||||||
|  |                             {accessibleModules.map((module) => ( | ||||||
|                                 <Tab key={module.path} label={module.label} /> |                                 <Tab key={module.path} label={module.label} /> | ||||||
|                             ))} |                             ))} | ||||||
|                         </Tabs> |                         </Tabs> | ||||||
|                     </Paper> |                     </Paper> | ||||||
|  |                 )} | ||||||
|  |                 {/* --- FIN DE CAMBIO --- */} | ||||||
|             </AppBar> |             </AppBar> | ||||||
|  |  | ||||||
|             <Box |             <Box | ||||||
|                 component="main" |                 component="main" | ||||||
|                 sx={{ |                 sx={{ /* ... (estilos sin cambios) ... */ | ||||||
|                     flexGrow: 1, |                     flexGrow: 1, | ||||||
|                     py: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 }, // Padding vertical responsivo |                     py: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 }, | ||||||
|                     px: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 }, // Padding horizontal responsivo |                     px: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 }, | ||||||
|                     display: 'flex', |                     display: 'flex', | ||||||
|                     flexDirection: 'column' |                     flexDirection: 'column' | ||||||
|                 }} |                 }} | ||||||
| @@ -215,17 +248,19 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => { | |||||||
|                 {children} |                 {children} | ||||||
|             </Box> |             </Box> | ||||||
|  |  | ||||||
|             <Box component="footer" sx={{ p: 1, backgroundColor: 'grey.200' /* Un gris más claro */, color: 'text.secondary', textAlign: 'left', borderTop: (theme) => `1px solid ${theme.palette.divider}` }}> |             <Box component="footer" sx={{ /* ... (estilos sin cambios) ... */  | ||||||
|  |                  p: 1, backgroundColor: 'grey.200', color: 'text.secondary',  | ||||||
|  |                  textAlign: 'left', borderTop: (theme) => `1px solid ${theme.palette.divider}` | ||||||
|  |             }}> | ||||||
|                 <Typography variant="caption"> |                 <Typography variant="caption"> | ||||||
|                     {/* Puedes usar caption para un texto más pequeño en el footer */} |  | ||||||
|                     Usuario: {user?.username} | Acceso: {user?.esSuperAdmin ? 'Super Administrador' : (user?.perfil || `ID ${user?.idPerfil}`)} |                     Usuario: {user?.username} | Acceso: {user?.esSuperAdmin ? 'Super Administrador' : (user?.perfil || `ID ${user?.idPerfil}`)} | ||||||
|                 </Typography> |                 </Typography> | ||||||
|             </Box> |             </Box> | ||||||
|  |  | ||||||
|             <ChangePasswordModal |             <ChangePasswordModal | ||||||
|                 open={showForcedPasswordChangeModal && !isPasswordChangeForced} // Solo mostrar si no es el forzado inicial |                 open={showForcedPasswordChangeModal && !isPasswordChangeForced} | ||||||
|                 onClose={() => handleModalClose(false)} // Asumir que si se cierra sin cambiar, no fue exitoso |                 onClose={() => handleModalClose(false)} | ||||||
|                 isFirstLogin={false} // Este modal no es para el primer login forzado |                 isFirstLogin={false} | ||||||
|             /> |             /> | ||||||
|         </Box> |         </Box> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -0,0 +1,7 @@ | |||||||
|  | export interface AjusteSaldoRequestDto { | ||||||
|  |   destino: 'Distribuidores' | 'Canillas'; | ||||||
|  |   idDestino: number; | ||||||
|  |   idEmpresa: number; | ||||||
|  |   montoAjuste: number; | ||||||
|  |   justificacion: string; | ||||||
|  | } | ||||||
							
								
								
									
										10
									
								
								Frontend/src/models/dtos/Contables/SaldoGestionDto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								Frontend/src/models/dtos/Contables/SaldoGestionDto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | export interface SaldoGestionDto { | ||||||
|  |   idSaldo: number; | ||||||
|  |   destino: string; // "Distribuidores" o "Canillas" | ||||||
|  |   idDestino: number; | ||||||
|  |   nombreDestinatario: string; | ||||||
|  |   idEmpresa: number; | ||||||
|  |   nombreEmpresa: string; | ||||||
|  |   monto: number; | ||||||
|  |   fechaUltimaModificacion: string; // "yyyy-MM-ddTHH:mm:ss" o similar | ||||||
|  | } | ||||||
| @@ -0,0 +1,5 @@ | |||||||
|  | export interface CreateNovedadCanillaDto { | ||||||
|  |   idCanilla: number; | ||||||
|  |   fecha: string; // string dd/MM/yyyy | ||||||
|  |   detalle?: string | null; | ||||||
|  | } | ||||||
| @@ -0,0 +1,4 @@ | |||||||
|  | export interface DistribuidorDropdownDto { | ||||||
|  |   idDistribuidor: number; | ||||||
|  |   nombre: string; | ||||||
|  | } | ||||||
| @@ -0,0 +1,4 @@ | |||||||
|  | export interface DistribuidorLookupDto { | ||||||
|  |   idDistribuidor: number; | ||||||
|  |   nombre: string; | ||||||
|  | } | ||||||
| @@ -0,0 +1,4 @@ | |||||||
|  | export interface EmpresaDropdownDto { | ||||||
|  |   idEmpresa: number; | ||||||
|  |   nombre: string; | ||||||
|  | } | ||||||
| @@ -0,0 +1,4 @@ | |||||||
|  | export interface EmpresaLookupDto { | ||||||
|  |   idEmpresa: number; | ||||||
|  |   nombre: string; | ||||||
|  | } | ||||||
| @@ -0,0 +1,7 @@ | |||||||
|  | export interface NovedadCanillaDto { | ||||||
|  |   idNovedad: number; | ||||||
|  |   idCanilla: number; | ||||||
|  |   nombreCanilla: string; | ||||||
|  |   fecha: string; // string dd/MM/yyyy | ||||||
|  |   detalle?: string | null; | ||||||
|  | } | ||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | export interface UpdateNovedadCanillaDto { | ||||||
|  |   detalle?: string | null; | ||||||
|  | } | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | export interface CanillaGananciaReporteDto { | ||||||
|  |   canilla: string; // NomApe del canillita | ||||||
|  |   legajo?: number | null; | ||||||
|  |   francos?: number | null; | ||||||
|  |   faltas?: number | null; | ||||||
|  |   totalRendir?: number | null; | ||||||
|  |   id?: string; // Para el DataGrid | ||||||
|  | } | ||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | export interface ListadoDistCanMensualDiariosDto { | ||||||
|  |   canilla: string; | ||||||
|  |   elDia: number | null; | ||||||
|  |   elPlata: number | null; | ||||||
|  |   vendidos: number | null; | ||||||
|  |   importeElDia: number | null; | ||||||
|  |   importeElPlata: number | null; | ||||||
|  |   importeTotal: number | null; | ||||||
|  |   id?: string; // Para DataGrid | ||||||
|  | } | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | export interface ListadoDistCanMensualPubDto { | ||||||
|  |   publicacion: string; | ||||||
|  |   canilla: string; | ||||||
|  |   totalCantSalida: number | null; | ||||||
|  |   totalCantEntrada: number | null; | ||||||
|  |   totalRendir: number | null; | ||||||
|  |   id?: string; // Para DataGrid | ||||||
|  | } | ||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | export interface NovedadesCanillasReporteDto { | ||||||
|  |   nomApe: string; | ||||||
|  |   fecha: string; | ||||||
|  |   detalle?: string | null; | ||||||
|  |   id?: string; // Para el DataGrid | ||||||
|  | } | ||||||
| @@ -7,6 +7,7 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom'; | |||||||
| const contablesSubModules = [   | const contablesSubModules = [   | ||||||
|   { label: 'Pagos Distribuidores', path: 'pagos-distribuidores' }, |   { label: 'Pagos Distribuidores', path: 'pagos-distribuidores' }, | ||||||
|   { label: 'Notas Crédito/Débito', path: 'notas-cd' }, |   { label: 'Notas Crédito/Débito', path: 'notas-cd' }, | ||||||
|  |   { label: 'Gestión de Saldos', path: 'gestion-saldos' }, | ||||||
|   { label: 'Tipos de Pago', path: 'tipos-pago' },   |   { label: 'Tipos de Pago', path: 'tipos-pago' },   | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -56,7 +56,6 @@ const GestionarNotasCDPage: React.FC = () => { | |||||||
|   const [selectedRow, setSelectedRow] = useState<NotaCreditoDebitoDto | null>(null); |   const [selectedRow, setSelectedRow] = useState<NotaCreditoDebitoDto | null>(null); | ||||||
|  |  | ||||||
|   const { tienePermiso, isSuperAdmin } = usePermissions(); |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|   // CN001 (Ver), CN002 (Crear), CN003 (Modificar), CN004 (Eliminar) |  | ||||||
|   const puedeVer = isSuperAdmin || tienePermiso("CN001"); |   const puedeVer = isSuperAdmin || tienePermiso("CN001"); | ||||||
|   const puedeCrear = isSuperAdmin || tienePermiso("CN002"); |   const puedeCrear = isSuperAdmin || tienePermiso("CN002"); | ||||||
|   const puedeModificar = isSuperAdmin || tienePermiso("CN003"); |   const puedeModificar = isSuperAdmin || tienePermiso("CN003"); | ||||||
|   | |||||||
							
								
								
									
										257
									
								
								Frontend/src/pages/Contables/GestionarSaldosPage.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								Frontend/src/pages/Contables/GestionarSaldosPage.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,257 @@ | |||||||
|  | import React, { useState, useEffect, useCallback } from 'react'; | ||||||
|  | import { | ||||||
|  |   Box, Typography, Paper, IconButton, MenuItem, | ||||||
|  |   Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, | ||||||
|  |   CircularProgress, Alert, FormControl, InputLabel, Select | ||||||
|  | } from '@mui/material'; | ||||||
|  | import FilterListIcon from '@mui/icons-material/FilterList'; | ||||||
|  | import EditNoteIcon from '@mui/icons-material/EditNote'; // Icono para ajustar saldo | ||||||
|  |  | ||||||
|  | import saldoService from '../../services/Contables/saldoService'; | ||||||
|  | import empresaService from '../../services/Distribucion/empresaService'; | ||||||
|  | import distribuidorService from '../../services/Distribucion/distribuidorService'; | ||||||
|  | import canillaService from '../../services/Distribucion/canillaService'; | ||||||
|  |  | ||||||
|  | import type { SaldoGestionDto } from '../../models/dtos/Contables/SaldoGestionDto'; | ||||||
|  | import type { AjusteSaldoRequestDto } from '../../models/dtos/Contables/AjusteSaldoRequestDto'; | ||||||
|  | import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto'; | ||||||
|  | import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto'; | ||||||
|  | import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto'; | ||||||
|  |  | ||||||
|  | import AjusteSaldoModal from '../../components/Modals/Contables/AjusteSaldoModal'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
|  | import axios from 'axios'; | ||||||
|  |  | ||||||
|  | type TipoDestinoFiltro = 'Distribuidores' | 'Canillas' | ''; | ||||||
|  |  | ||||||
|  | const GestionarSaldosPage: React.FC = () => { | ||||||
|  |   const [saldos, setSaldos] = useState<SaldoGestionDto[]>([]); | ||||||
|  |   const [loading, setLoading] = useState(true); | ||||||
|  |   const [error, setError] = useState<string | null>(null); | ||||||
|  |   const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); // Para modal | ||||||
|  |  | ||||||
|  |   // Filtros | ||||||
|  |   const [filtroTipoDestino, setFiltroTipoDestino] = useState<TipoDestinoFiltro>(''); | ||||||
|  |   const [filtroIdDestino, setFiltroIdDestino] = useState<number | string>(''); | ||||||
|  |   const [filtroIdEmpresa, setFiltroIdEmpresa] = useState<number | string>(''); | ||||||
|  |  | ||||||
|  |   const [destinatariosDropdown, setDestinatariosDropdown] = useState<(DistribuidorDto | CanillaDto)[]>([]); | ||||||
|  |   const [empresas, setEmpresas] = useState<EmpresaDto[]>([]); | ||||||
|  |   const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false); | ||||||
|  |  | ||||||
|  |   const [modalAjusteOpen, setModalAjusteOpen] = useState(false); | ||||||
|  |   const [saldoParaAjustar, setSaldoParaAjustar] = useState<SaldoGestionDto | null>(null); | ||||||
|  |  | ||||||
|  |   const [page, setPage] = useState(0); | ||||||
|  |   const [rowsPerPage, setRowsPerPage] = useState(25); | ||||||
|  |   // No necesitamos menú de acciones por fila si el ajuste es la única acción | ||||||
|  |  | ||||||
|  |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |   const puedeVerSaldos = isSuperAdmin || tienePermiso("CS001"); // Permiso para ver | ||||||
|  |   const puedeAjustarSaldos = isSuperAdmin || tienePermiso("CS002"); // Permiso para ajustar | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   const fetchDropdownData = useCallback(async () => { | ||||||
|  |     setLoadingFiltersDropdown(true); | ||||||
|  |     setError(null); | ||||||
|  |     try { | ||||||
|  |         const empData = await empresaService.getAllEmpresas(); | ||||||
|  |         setEmpresas(empData); | ||||||
|  |  | ||||||
|  |         if (filtroTipoDestino === 'Distribuidores') { | ||||||
|  |             const distData = await distribuidorService.getAllDistribuidores(); | ||||||
|  |             setDestinatariosDropdown(distData); | ||||||
|  |         } else if (filtroTipoDestino === 'Canillas') { | ||||||
|  |             const canData = await canillaService.getAllCanillas(undefined, undefined, true); // Solo activos | ||||||
|  |             setDestinatariosDropdown(canData); | ||||||
|  |         } else { | ||||||
|  |             setDestinatariosDropdown([]); | ||||||
|  |         } | ||||||
|  |     } catch (err) { | ||||||
|  |       console.error("Error cargando datos para filtros:", err); | ||||||
|  |       setError("Error al cargar opciones de filtro."); | ||||||
|  |     } finally { | ||||||
|  |       setLoadingFiltersDropdown(false); | ||||||
|  |     } | ||||||
|  |   }, [filtroTipoDestino]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     fetchDropdownData(); | ||||||
|  |   }, [fetchDropdownData]); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   const cargarSaldos = useCallback(async () => { | ||||||
|  |     if (!puedeVerSaldos) { | ||||||
|  |       setError("No tiene permiso para ver saldos."); setLoading(false); return; | ||||||
|  |     } | ||||||
|  |     setLoading(true); setError(null); setApiErrorMessage(null); | ||||||
|  |     try { | ||||||
|  |       const params = { | ||||||
|  |         destino: filtroTipoDestino || undefined, // Enviar undefined si está vacío | ||||||
|  |         idDestino: filtroIdDestino ? Number(filtroIdDestino) : undefined, | ||||||
|  |         idEmpresa: filtroIdEmpresa ? Number(filtroIdEmpresa) : undefined, | ||||||
|  |       }; | ||||||
|  |       const data = await saldoService.getAllSaldosGestion(params); | ||||||
|  |       setSaldos(data); | ||||||
|  |     } catch (err) { | ||||||
|  |       console.error("Error al cargar saldos:", err); | ||||||
|  |       setError('Error al cargar los saldos.'); | ||||||
|  |       setSaldos([]); | ||||||
|  |     } finally { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }, [puedeVerSaldos, filtroTipoDestino, filtroIdDestino, filtroIdEmpresa]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     cargarSaldos(); | ||||||
|  |   }, [cargarSaldos]); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   const handleOpenAjusteModal = (saldo: SaldoGestionDto) => { | ||||||
|  |     if (!puedeAjustarSaldos) { | ||||||
|  |         setApiErrorMessage("No tiene permiso para ajustar saldos."); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |     setSaldoParaAjustar(saldo); | ||||||
|  |     setApiErrorMessage(null); | ||||||
|  |     setModalAjusteOpen(true); | ||||||
|  |   }; | ||||||
|  |   const handleCloseAjusteModal = () => { | ||||||
|  |     setModalAjusteOpen(false); | ||||||
|  |     setSaldoParaAjustar(null); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSubmitAjusteModal = async (data: AjusteSaldoRequestDto) => { | ||||||
|  |     if (!puedeAjustarSaldos) return; | ||||||
|  |     setApiErrorMessage(null); | ||||||
|  |     try { | ||||||
|  |       await saldoService.ajustarSaldo(data); | ||||||
|  |       cargarSaldos(); // Recargar lista para ver el saldo actualizado | ||||||
|  |     } catch (err: any) { | ||||||
|  |       const message = axios.isAxiosError(err) && err.response?.data?.message  | ||||||
|  |                         ? err.response.data.message  | ||||||
|  |                         : 'Error al aplicar el ajuste de saldo.'; | ||||||
|  |       setApiErrorMessage(message);  | ||||||
|  |       throw err; // Para que el modal sepa que hubo error | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); | ||||||
|  |   const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|  |     setRowsPerPage(parseInt(event.target.value, 10)); setPage(0); | ||||||
|  |   }; | ||||||
|  |    | ||||||
|  |   const displayData = saldos.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); | ||||||
|  |   const formatDate = (dateString?: string | null) => dateString ? new Date(dateString).toLocaleString('es-AR', {timeZone:'UTC'}) : '-'; | ||||||
|  |   const formatCurrency = (value: number) => value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' }); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   if (!loading && !puedeVerSaldos) { | ||||||
|  |       return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado a esta sección."}</Alert></Box>; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box sx={{ p: 1 }}> | ||||||
|  |       <Typography variant="h5" gutterBottom>Gestión de Saldos</Typography> | ||||||
|  |       <Paper sx={{ p: 2, mb: 2 }}> | ||||||
|  |         <Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography> | ||||||
|  |         <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center', mb: 2 }}> | ||||||
|  |             <FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }}> | ||||||
|  |                 <InputLabel>Tipo Destinatario</InputLabel> | ||||||
|  |                 <Select value={filtroTipoDestino} label="Tipo Destinatario"  | ||||||
|  |                     onChange={(e) => { | ||||||
|  |                         setFiltroTipoDestino(e.target.value as TipoDestinoFiltro); | ||||||
|  |                         setFiltroIdDestino(''); // Resetear destinatario al cambiar tipo | ||||||
|  |                     }}> | ||||||
|  |                     <MenuItem value=""><em>Todos</em></MenuItem> | ||||||
|  |                     <MenuItem value="Distribuidores">Distribuidores</MenuItem> | ||||||
|  |                     <MenuItem value="Canillas">Canillitas</MenuItem> | ||||||
|  |                 </Select> | ||||||
|  |             </FormControl> | ||||||
|  |             <FormControl size="small" sx={{ minWidth: 220, flexGrow: 1 }} disabled={loadingFiltersDropdown || !filtroTipoDestino}> | ||||||
|  |                 <InputLabel>Destinatario Específico</InputLabel> | ||||||
|  |                 <Select value={filtroIdDestino} label="Destinatario Específico"  | ||||||
|  |                     onChange={(e) => setFiltroIdDestino(e.target.value as number | string)}> | ||||||
|  |                     <MenuItem value=""><em>Todos</em></MenuItem> | ||||||
|  |                     {destinatariosDropdown.map(d => ( | ||||||
|  |                         <MenuItem key={'idDistribuidor' in d ? d.idDistribuidor : d.idCanilla} value={'idDistribuidor' in d ? d.idDistribuidor : d.idCanilla}> | ||||||
|  |                             {'nomApe' in d ? d.nomApe : d.nombre} | ||||||
|  |                         </MenuItem> | ||||||
|  |                     ))} | ||||||
|  |                 </Select> | ||||||
|  |             </FormControl> | ||||||
|  |             <FormControl size="small" sx={{ minWidth: 200, flexGrow: 1 }} disabled={loadingFiltersDropdown}> | ||||||
|  |                 <InputLabel>Empresa</InputLabel> | ||||||
|  |                 <Select value={filtroIdEmpresa} label="Empresa"  | ||||||
|  |                     onChange={(e) => setFiltroIdEmpresa(e.target.value as number | string)}> | ||||||
|  |                     <MenuItem value=""><em>Todas</em></MenuItem> | ||||||
|  |                     {empresas.map(e => <MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>)} | ||||||
|  |                 </Select> | ||||||
|  |             </FormControl> | ||||||
|  |         </Box> | ||||||
|  |         {/* No hay botón de "Agregar Saldo", se crean automáticamente */} | ||||||
|  |       </Paper> | ||||||
|  |  | ||||||
|  |       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} | ||||||
|  |       {error && !loading && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} | ||||||
|  |       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} | ||||||
|  |  | ||||||
|  |       {!loading && !error && puedeVerSaldos && ( | ||||||
|  |          <TableContainer component={Paper}> | ||||||
|  |            <Table size="small"> | ||||||
|  |              <TableHead><TableRow> | ||||||
|  |                  <TableCell sx={{fontWeight:'bold'}}>Destinatario</TableCell> | ||||||
|  |                  <TableCell sx={{fontWeight:'bold'}}>Tipo</TableCell> | ||||||
|  |                  <TableCell sx={{fontWeight:'bold'}}>Empresa</TableCell> | ||||||
|  |                  <TableCell align="right" sx={{fontWeight:'bold'}}>Monto Saldo</TableCell> | ||||||
|  |                  <TableCell sx={{fontWeight:'bold'}}>Últ. Modificación</TableCell> | ||||||
|  |                  {puedeAjustarSaldos && <TableCell align="right" sx={{fontWeight:'bold'}}>Acciones</TableCell>} | ||||||
|  |              </TableRow></TableHead> | ||||||
|  |              <TableBody> | ||||||
|  |                {displayData.length === 0 ? ( | ||||||
|  |                   <TableRow><TableCell colSpan={puedeAjustarSaldos ? 6 : 5} align="center">No se encontraron saldos.</TableCell></TableRow> | ||||||
|  |                ) : ( | ||||||
|  |                  displayData.map((s) => ( | ||||||
|  |                      <TableRow key={s.idSaldo} hover | ||||||
|  |                         sx={{ backgroundColor: s.monto < 0 ? 'rgba(255, 0, 0, 0.05)' : (s.monto > 0 ? 'rgba(0, 255, 0, 0.05)' : 'inherit')}} | ||||||
|  |                      > | ||||||
|  |                         <TableCell>{s.nombreDestinatario}</TableCell> | ||||||
|  |                         <TableCell>{s.destino}</TableCell> | ||||||
|  |                         <TableCell>{s.nombreEmpresa}</TableCell> | ||||||
|  |                         <TableCell align="right" sx={{fontWeight:500}}>{formatCurrency(s.monto)}</TableCell> | ||||||
|  |                         <TableCell>{formatDate(s.fechaUltimaModificacion)}</TableCell> | ||||||
|  |                         {puedeAjustarSaldos && ( | ||||||
|  |                             <TableCell align="right"> | ||||||
|  |                                 <IconButton size="small" onClick={() => handleOpenAjusteModal(s)} color="primary"> | ||||||
|  |                                     <EditNoteIcon fontSize="small"/> | ||||||
|  |                                 </IconButton> | ||||||
|  |                             </TableCell> | ||||||
|  |                         )} | ||||||
|  |                      </TableRow> | ||||||
|  |                  )))} | ||||||
|  |              </TableBody> | ||||||
|  |            </Table> | ||||||
|  |            <TablePagination | ||||||
|  |              rowsPerPageOptions={[25, 50, 100]} component="div" count={saldos.length} | ||||||
|  |              rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} | ||||||
|  |              onRowsPerPageChange={handleChangeRowsPerPage} labelRowsPerPage="Filas por página:" | ||||||
|  |            /> | ||||||
|  |          </TableContainer> | ||||||
|  |        )} | ||||||
|  |  | ||||||
|  |       {saldoParaAjustar && | ||||||
|  |         <AjusteSaldoModal | ||||||
|  |             open={modalAjusteOpen} | ||||||
|  |             onClose={handleCloseAjusteModal} | ||||||
|  |             onSubmit={handleSubmitAjusteModal} | ||||||
|  |             saldoParaAjustar={saldoParaAjustar} | ||||||
|  |             errorMessage={apiErrorMessage} | ||||||
|  |             clearErrorMessage={() => setApiErrorMessage(null)} | ||||||
|  |         /> | ||||||
|  |       } | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default GestionarSaldosPage; | ||||||
| @@ -1,4 +1,3 @@ | |||||||
| // src/pages/configuracion/GestionarTiposPagoPage.tsx |  | ||||||
| import React, { useState, useEffect, useCallback } from 'react'; | import React, { useState, useEffect, useCallback } from 'react'; | ||||||
| import { | import { | ||||||
|   Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, |   Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, | ||||||
| @@ -7,10 +6,10 @@ import { | |||||||
|   ListItemIcon, |   ListItemIcon, | ||||||
|   ListItemText |   ListItemText | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import AddIcon from '@mui/icons-material/Add'; // Icono para agregar | import AddIcon from '@mui/icons-material/Add'; | ||||||
| import MoreVertIcon from '@mui/icons-material/MoreVert'; // Icono para más opciones | import MoreVertIcon from '@mui/icons-material/MoreVert'; | ||||||
| import EditIcon from '@mui/icons-material/Edit'; // Icono para modificar | import EditIcon from '@mui/icons-material/Edit'; | ||||||
| import DeleteIcon from '@mui/icons-material/Delete'; // Icono para eliminar | import DeleteIcon from '@mui/icons-material/Delete'; | ||||||
| import tipoPagoService from '../../services/Contables/tipoPagoService'; | import tipoPagoService from '../../services/Contables/tipoPagoService'; | ||||||
| import type { TipoPago } from '../../models/Entities/TipoPago'; | import type { TipoPago } from '../../models/Entities/TipoPago'; | ||||||
| import type { CreateTipoPagoDto } from '../../models/dtos/Contables/CreateTipoPagoDto'; | import type { CreateTipoPagoDto } from '../../models/dtos/Contables/CreateTipoPagoDto'; | ||||||
| @@ -30,20 +29,26 @@ const GestionarTiposPagoPage: React.FC = () => { | |||||||
|   const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); |   const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||||
|  |  | ||||||
|   const [page, setPage] = useState(0); |   const [page, setPage] = useState(0); | ||||||
|   const [rowsPerPage, setRowsPerPage] = useState(5); |   const [rowsPerPage, setRowsPerPage] = useState(25); // Cambiado a un valor más común | ||||||
|  |  | ||||||
|   // Para el menú contextual de cada fila |  | ||||||
|   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); |   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||||
|   const [selectedTipoPagoRow, setSelectedTipoPagoRow] = useState<TipoPago | null>(null); |   const [selectedTipoPagoRow, setSelectedTipoPagoRow] = useState<TipoPago | null>(null); | ||||||
|  |  | ||||||
|   const { tienePermiso, isSuperAdmin } = usePermissions(); // Obtener también isSuperAdmin |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |  | ||||||
|  |   const puedeVer = isSuperAdmin || tienePermiso("CT001");       // << AÑADIR ESTA LÍNEA | ||||||
|   const puedeCrear = isSuperAdmin || tienePermiso("CT002"); |   const puedeCrear = isSuperAdmin || tienePermiso("CT002"); | ||||||
|   const puedeModificar = isSuperAdmin || tienePermiso("CT003"); |   const puedeModificar = isSuperAdmin || tienePermiso("CT003"); | ||||||
|   const puedeEliminar = isSuperAdmin || tienePermiso("CT004"); |   const puedeEliminar = isSuperAdmin || tienePermiso("CT004"); | ||||||
|  |  | ||||||
|  |  | ||||||
|   const cargarTiposPago = useCallback(async () => { |   const cargarTiposPago = useCallback(async () => { | ||||||
|  |     if (!puedeVer) { // << AÑADIR CHEQUEO DE PERMISO AQUÍ | ||||||
|  |       setError("No tiene permiso para ver los tipos de pago."); | ||||||
|  |       setLoading(false); | ||||||
|  |       setTiposPago([]); // Asegurar que no se muestren datos previos | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     setError(null); |     setError(null); | ||||||
|     try { |     try { | ||||||
| @@ -55,7 +60,7 @@ const GestionarTiposPagoPage: React.FC = () => { | |||||||
|     } finally { |     } finally { | ||||||
|       setLoading(false); |       setLoading(false); | ||||||
|     } |     } | ||||||
|   }, [filtroNombre]); |   }, [filtroNombre, puedeVer]); // << AÑADIR puedeVer A LAS DEPENDENCIAS | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     cargarTiposPago(); |     cargarTiposPago(); | ||||||
| @@ -73,15 +78,15 @@ const GestionarTiposPagoPage: React.FC = () => { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleSubmitModal = async (data: CreateTipoPagoDto | UpdateTipoPagoDto) => { |   const handleSubmitModal = async (data: CreateTipoPagoDto | UpdateTipoPagoDto) => { | ||||||
|     setApiErrorMessage(null); // Limpiar error previo |     setApiErrorMessage(null); | ||||||
|     try { |     try { | ||||||
|       if (editingTipoPago && 'idTipoPago' in data) { // Es Update |       if (editingTipoPago && editingTipoPago.idTipoPago) { | ||||||
|         await tipoPagoService.updateTipoPago(editingTipoPago.idTipoPago, data as UpdateTipoPagoDto); |         await tipoPagoService.updateTipoPago(editingTipoPago.idTipoPago, data as UpdateTipoPagoDto); | ||||||
|       } else { // Es Create |       } else { | ||||||
|         await tipoPagoService.createTipoPago(data as CreateTipoPagoDto); |         await tipoPagoService.createTipoPago(data as CreateTipoPagoDto); | ||||||
|       } |       } | ||||||
|       cargarTiposPago(); // Recargar lista |       cargarTiposPago(); | ||||||
|       // onClose se llama desde el modal en caso de éxito |       // onClose se llama desde el modal si todo va bien | ||||||
|     } catch (err: any) { |     } catch (err: any) { | ||||||
|       console.error("Error en submit modal (padre):", err); |       console.error("Error en submit modal (padre):", err); | ||||||
|       if (axios.isAxiosError(err) && err.response) { |       if (axios.isAxiosError(err) && err.response) { | ||||||
| @@ -89,11 +94,12 @@ const GestionarTiposPagoPage: React.FC = () => { | |||||||
|       } else { |       } else { | ||||||
|         setApiErrorMessage('Ocurrió un error inesperado al guardar.'); |         setApiErrorMessage('Ocurrió un error inesperado al guardar.'); | ||||||
|       } |       } | ||||||
|       throw err; // Re-lanzar para que el modal sepa que hubo error y no se cierre |       throw err; | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleDelete = async (id: number) => { |   const handleDelete = async (id: number) => { | ||||||
|  |     // ... (sin cambios) | ||||||
|     if (window.confirm('¿Está seguro de que desea eliminar este tipo de pago?')) { |     if (window.confirm('¿Está seguro de que desea eliminar este tipo de pago?')) { | ||||||
|       setApiErrorMessage(null); |       setApiErrorMessage(null); | ||||||
|       try { |       try { | ||||||
| @@ -126,12 +132,24 @@ const GestionarTiposPagoPage: React.FC = () => { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => { |   const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|     setRowsPerPage(parseInt(event.target.value, 25)); |     setRowsPerPage(parseInt(event.target.value, 10)); // << CORREGIDO: base 10, no 25 | ||||||
|     setPage(0); |     setPage(0); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const displayData = tiposPago.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); |   const displayData = tiposPago.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); | ||||||
|  |  | ||||||
|  |   // Renderizado condicional si no tiene permiso para ver | ||||||
|  |   if (!loading && !puedeVer) { // << AÑADIR ESTE BLOQUE | ||||||
|  |     return ( | ||||||
|  |       <Box sx={{ p: 1 }}> | ||||||
|  |         <Typography variant="h5" gutterBottom> | ||||||
|  |           Gestionar Tipos de Pago | ||||||
|  |         </Typography> | ||||||
|  |         <Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert> | ||||||
|  |       </Box> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Box sx={{ p: 1 }}> |     <Box sx={{ p: 1 }}> | ||||||
|       <Typography variant="h5" gutterBottom> |       <Typography variant="h5" gutterBottom> | ||||||
| @@ -146,10 +164,8 @@ const GestionarTiposPagoPage: React.FC = () => { | |||||||
|             size="small" |             size="small" | ||||||
|             value={filtroNombre} |             value={filtroNombre} | ||||||
|             onChange={(e) => setFiltroNombre(e.target.value)} |             onChange={(e) => setFiltroNombre(e.target.value)} | ||||||
|           // sx={{ flexGrow: 1 }} // Opcional, para que ocupe más espacio |             disabled={!puedeVer || loading} // Deshabilitar si no puede ver o está cargando | ||||||
|           /> |           /> | ||||||
|           {/* El botón de búsqueda se activa al cambiar el texto, pero puedes añadir uno explícito */} |  | ||||||
|           {/* <Button variant="contained" onClick={cargarTiposPago}>Buscar</Button> */} |  | ||||||
|         </Box> |         </Box> | ||||||
|         {puedeCrear && ( |         {puedeCrear && ( | ||||||
|             <Button |             <Button | ||||||
| @@ -164,28 +180,34 @@ const GestionarTiposPagoPage: React.FC = () => { | |||||||
|       </Paper> |       </Paper> | ||||||
|  |  | ||||||
|       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} |       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} | ||||||
|       {error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} |       {/* Mostrar error de carga si no es un error de "sin permiso" y no hay error de API */} | ||||||
|  |       {error && !loading && puedeVer && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} | ||||||
|       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} |       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} | ||||||
|  |  | ||||||
|  |  | ||||||
|       {!loading && !error && ( |       {!loading && !error && puedeVer && ( // Asegurar que puedeVer sea true para mostrar la tabla | ||||||
|         <TableContainer component={Paper}> |         <TableContainer component={Paper}> | ||||||
|           <Table> |           <Table> | ||||||
|             <TableHead> |             <TableHead> | ||||||
|               <TableRow> |               <TableRow> | ||||||
|                 <TableCell>Nombre</TableCell> |                 <TableCell>Nombre</TableCell> | ||||||
|                 <TableCell>Detalle</TableCell> |                 <TableCell>Detalle</TableCell> | ||||||
|                 <TableCell align="right">Acciones</TableCell> |                 {/* Mostrar columna de acciones solo si tiene algún permiso de acción */} | ||||||
|  |                 {(puedeModificar || puedeEliminar) && <TableCell align="right">Acciones</TableCell>} | ||||||
|               </TableRow> |               </TableRow> | ||||||
|             </TableHead> |             </TableHead> | ||||||
|             <TableBody> |             <TableBody> | ||||||
|               {displayData.length === 0 && !loading ? ( |               {displayData.length === 0 && !loading ? ( | ||||||
|                 <TableRow><TableCell colSpan={3} align="center">No se encontraron tipos de pago.</TableCell></TableRow> |                 <TableRow><TableCell colSpan={(puedeModificar || puedeEliminar) ? 3 : 2} align="center"> | ||||||
|  |                     No se encontraron tipos de pago. | ||||||
|  |                     </TableCell> | ||||||
|  |                 </TableRow> | ||||||
|               ) : ( |               ) : ( | ||||||
|                 displayData.map((tipo) => ( |                 displayData.map((tipo) => ( | ||||||
|                   <TableRow key={tipo.idTipoPago}> |                   <TableRow key={tipo.idTipoPago}> | ||||||
|                     <TableCell>{tipo.nombre}</TableCell> |                     <TableCell>{tipo.nombre}</TableCell> | ||||||
|                     <TableCell>{tipo.detalle || '-'}</TableCell> |                     <TableCell>{tipo.detalle || '-'}</TableCell> | ||||||
|  |                     {(puedeModificar || puedeEliminar) && ( | ||||||
|                         <TableCell align="right"> |                         <TableCell align="right"> | ||||||
|                         <IconButton |                         <IconButton | ||||||
|                             onClick={(e) => handleMenuOpen(e, tipo)} |                             onClick={(e) => handleMenuOpen(e, tipo)} | ||||||
| @@ -194,13 +216,14 @@ const GestionarTiposPagoPage: React.FC = () => { | |||||||
|                             <MoreVertIcon /> |                             <MoreVertIcon /> | ||||||
|                         </IconButton> |                         </IconButton> | ||||||
|                         </TableCell> |                         </TableCell> | ||||||
|  |                     )} | ||||||
|                   </TableRow> |                   </TableRow> | ||||||
|                 )) |                 )) | ||||||
|               )} |               )} | ||||||
|             </TableBody> |             </TableBody> | ||||||
|           </Table> |           </Table> | ||||||
|           <TablePagination |           <TablePagination | ||||||
|             rowsPerPageOptions={[25, 50, 100]} |             rowsPerPageOptions={[25, 50, 100]} // Opciones más estándar | ||||||
|             component="div" |             component="div" | ||||||
|             count={tiposPago.length} |             count={tiposPago.length} | ||||||
|             rowsPerPage={rowsPerPage} |             rowsPerPage={rowsPerPage} | ||||||
| @@ -217,20 +240,19 @@ const GestionarTiposPagoPage: React.FC = () => { | |||||||
|         open={Boolean(anchorEl)} |         open={Boolean(anchorEl)} | ||||||
|         onClose={handleMenuClose} |         onClose={handleMenuClose} | ||||||
|       > |       > | ||||||
|         {puedeModificar && ( |         {puedeModificar && selectedTipoPagoRow && ( // Asegurar que selectedTipoPagoRow no sea null | ||||||
|           <MenuItem onClick={() => { handleOpenModal(selectedTipoPagoRow!); handleMenuClose(); }}> |           <MenuItem onClick={() => { handleOpenModal(selectedTipoPagoRow); handleMenuClose(); }}> | ||||||
|             <ListItemIcon><EditIcon fontSize="small" /></ListItemIcon> |             <ListItemIcon><EditIcon fontSize="small" /></ListItemIcon> | ||||||
|             <ListItemText>Modificar</ListItemText> |             <ListItemText>Modificar</ListItemText> | ||||||
|           </MenuItem> |           </MenuItem> | ||||||
|         )} |         )} | ||||||
|         {puedeEliminar && ( |         {puedeEliminar && selectedTipoPagoRow && ( // Asegurar que selectedTipoPagoRow no sea null | ||||||
|           <MenuItem onClick={() => handleDelete(selectedTipoPagoRow!.idTipoPago)}> |           <MenuItem onClick={() => handleDelete(selectedTipoPagoRow.idTipoPago)}> | ||||||
|             <ListItemIcon><DeleteIcon fontSize="small" /></ListItemIcon> |             <ListItemIcon><DeleteIcon fontSize="small" /></ListItemIcon> | ||||||
|             <ListItemText>Eliminar</ListItemText> |             <ListItemText>Eliminar</ListItemText> | ||||||
|           </MenuItem> |           </MenuItem> | ||||||
|         )} |         )} | ||||||
|         {/* Si no tiene ningún permiso, el menú podría estar vacío o no mostrarse */} |         {selectedTipoPagoRow && (!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>} | ||||||
|         {(!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>} |  | ||||||
|       </Menu> |       </Menu> | ||||||
|  |  | ||||||
|       <TipoPagoFormModal |       <TipoPagoFormModal | ||||||
|   | |||||||
| @@ -1,4 +1,3 @@ | |||||||
| // src/pages/distribucion/DistribucionIndexPage.tsx |  | ||||||
| import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import { Box, Tabs, Tab, Paper, Typography } from '@mui/material'; | import { Box, Tabs, Tab, Paper, Typography } from '@mui/material'; | ||||||
| import { Outlet, useNavigate, useLocation } from 'react-router-dom'; | import { Outlet, useNavigate, useLocation } from 'react-router-dom'; | ||||||
|   | |||||||
| @@ -2,13 +2,16 @@ import React, { useState, useEffect, useCallback } from 'react'; | |||||||
| import { | import { | ||||||
|   Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch, |   Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch, | ||||||
|   Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, |   Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, | ||||||
|     CircularProgress, Alert, Chip, FormControlLabel |   CircularProgress, Alert, Chip, FormControlLabel, ListItemIcon, ListItemText // << AÑADIR ListItemIcon, ListItemText | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import AddIcon from '@mui/icons-material/Add'; | import AddIcon from '@mui/icons-material/Add'; | ||||||
| import MoreVertIcon from '@mui/icons-material/MoreVert'; | import MoreVertIcon from '@mui/icons-material/MoreVert'; | ||||||
| import ToggleOnIcon from '@mui/icons-material/ToggleOn'; | import ToggleOnIcon from '@mui/icons-material/ToggleOn'; | ||||||
| import ToggleOffIcon from '@mui/icons-material/ToggleOff'; | import ToggleOffIcon from '@mui/icons-material/ToggleOff'; | ||||||
| import EditIcon from '@mui/icons-material/Edit'; | import EditIcon from '@mui/icons-material/Edit'; | ||||||
|  | import EventNoteIcon from '@mui/icons-material/EventNote'; // << AÑADIR IMPORTACIÓN DEL ICONO | ||||||
|  | import { useNavigate } from 'react-router-dom'; // << AÑADIR IMPORTACIÓN DE useNavigate | ||||||
|  |  | ||||||
| import canillaService from '../../services/Distribucion/canillaService'; | import canillaService from '../../services/Distribucion/canillaService'; | ||||||
| import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto'; | import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto'; | ||||||
| import type { CreateCanillaDto } from '../../models/dtos/Distribucion/CreateCanillaDto'; | import type { CreateCanillaDto } from '../../models/dtos/Distribucion/CreateCanillaDto'; | ||||||
| @@ -31,17 +34,24 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|   const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); |   const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||||
|  |  | ||||||
|   const [page, setPage] = useState(0); |   const [page, setPage] = useState(0); | ||||||
|   const [rowsPerPage, setRowsPerPage] = useState(5); |   const [rowsPerPage, setRowsPerPage] = useState(25); // << CAMBIADO DE 5 a 25 (valor más común) | ||||||
|   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); |   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||||
|   const [selectedCanillitaRow, setSelectedCanillitaRow] = useState<CanillaDto | null>(null); |   const [selectedCanillitaRow, setSelectedCanillitaRow] = useState<CanillaDto | null>(null); | ||||||
|  |  | ||||||
|  |   const navigate = useNavigate(); // << INICIALIZAR useNavigate | ||||||
|  |  | ||||||
|   const { tienePermiso, isSuperAdmin } = usePermissions(); |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |  | ||||||
|   const puedeVer = isSuperAdmin || tienePermiso("CG001"); |   const puedeVer = isSuperAdmin || tienePermiso("CG001"); | ||||||
|   const puedeCrear = isSuperAdmin || tienePermiso("CG002"); |   const puedeCrear = isSuperAdmin || tienePermiso("CG002"); | ||||||
|   const puedeModificar = isSuperAdmin || tienePermiso("CG003"); |   const puedeModificar = isSuperAdmin || tienePermiso("CG003"); | ||||||
|   // CG004 para Porcentajes/Montos, se gestionará por separado. |  | ||||||
|   const puedeDarBaja = isSuperAdmin || tienePermiso("CG005"); |   const puedeDarBaja = isSuperAdmin || tienePermiso("CG005"); | ||||||
|  |   // Permisos para Novedades | ||||||
|  |   const puedeGestionarNovedades = isSuperAdmin || tienePermiso("CG006"); // << DEFINIR PERMISO | ||||||
|  |   // Para la opción "Ver Novedades", podemos usar el permiso de ver canillitas (CG001) | ||||||
|  |   // O si solo se quiere mostrar si puede gestionarlas, usar puedeGestionarNovedades | ||||||
|  |   const puedeVerNovedadesCanilla = puedeVer || puedeGestionarNovedades; // << LÓGICA PARA MOSTRAR LA OPCIÓN | ||||||
|  |  | ||||||
|  |  | ||||||
|   const cargarCanillitas = useCallback(async () => { |   const cargarCanillitas = useCallback(async () => { | ||||||
|     if (!puedeVer) { |     if (!puedeVer) { | ||||||
| @@ -51,10 +61,10 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|     } |     } | ||||||
|     setLoading(true); setError(null); setApiErrorMessage(null); |     setLoading(true); setError(null); setApiErrorMessage(null); | ||||||
|     try { |     try { | ||||||
|       const legajoNum = filtroLegajo ? parseInt(filtroLegajo, 25) : undefined; |       const legajoNum = filtroLegajo ? parseInt(filtroLegajo, 10) : undefined; // << CORREGIDO: parseInt con base 10 | ||||||
|       if (filtroLegajo && isNaN(legajoNum!)) { |       if (filtroLegajo && isNaN(legajoNum!)) { | ||||||
|         setApiErrorMessage("Legajo debe ser un número."); |         setApiErrorMessage("Legajo debe ser un número."); | ||||||
|           setCanillitas([]); // Limpiar resultados si el filtro es inválido |         setCanillitas([]); | ||||||
|         setLoading(false); |         setLoading(false); | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
| @@ -83,6 +93,7 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|         await canillaService.createCanilla(data as CreateCanillaDto); |         await canillaService.createCanilla(data as CreateCanillaDto); | ||||||
|       } |       } | ||||||
|       cargarCanillitas(); |       cargarCanillitas(); | ||||||
|  |       // No es necesario llamar a handleCloseModal aquí si el modal se cierra solo en éxito | ||||||
|     } catch (err: any) { |     } catch (err: any) { | ||||||
|       const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el canillita.'; |       const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el canillita.'; | ||||||
|       setApiErrorMessage(message); throw err; |       setApiErrorMessage(message); throw err; | ||||||
| @@ -104,6 +115,11 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|     handleMenuClose(); |     handleMenuClose(); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   const handleOpenNovedades = (idCan: number) => { | ||||||
|  |     navigate(`/distribucion/canillas/${idCan}/novedades`); | ||||||
|  |     handleMenuClose(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, canillita: CanillaDto) => { |   const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, canillita: CanillaDto) => { | ||||||
|     setAnchorEl(event.currentTarget); setSelectedCanillitaRow(canillita); |     setAnchorEl(event.currentTarget); setSelectedCanillitaRow(canillita); | ||||||
|   }; |   }; | ||||||
| @@ -118,7 +134,7 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|   const displayData = canillitas.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); |   const displayData = canillitas.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); | ||||||
|  |  | ||||||
|   if (!loading && !puedeVer) { |   if (!loading && !puedeVer) { | ||||||
|       return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso."}</Alert></Box>; |     return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert></Box>; // Mensaje más genérico | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
| @@ -132,11 +148,11 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|             size="small" |             size="small" | ||||||
|             value={filtroNomApe} |             value={filtroNomApe} | ||||||
|             onChange={(e) => setFiltroNomApe(e.target.value)} |             onChange={(e) => setFiltroNomApe(e.target.value)} | ||||||
|                 sx={{ flex: 2, minWidth: '250px' }} // Dar más espacio al nombre |             sx={{ flex: 2, minWidth: '250px' }} | ||||||
|           /> |           /> | ||||||
|           <TextField |           <TextField | ||||||
|             label="Filtrar por Legajo" |             label="Filtrar por Legajo" | ||||||
|                 type="number" |             type="number" // Mantener como number para el input, la conversión se hace al usarlo | ||||||
|             variant="outlined" |             variant="outlined" | ||||||
|             size="small" |             size="small" | ||||||
|             value={filtroLegajo} |             value={filtroLegajo} | ||||||
| @@ -146,15 +162,14 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|           <FormControlLabel |           <FormControlLabel | ||||||
|             control={ |             control={ | ||||||
|               <Switch |               <Switch | ||||||
|                         checked={filtroSoloActivos === undefined ? true : filtroSoloActivos} |                 checked={filtroSoloActivos === undefined ? true : filtroSoloActivos} // Default a true | ||||||
|                 onChange={(e) => setFiltroSoloActivos(e.target.checked)} |                 onChange={(e) => setFiltroSoloActivos(e.target.checked)} | ||||||
|                 size="small" |                 size="small" | ||||||
|               /> |               /> | ||||||
|             } |             } | ||||||
|                 label="Ver Activos" |             label="Ver Activos" // Cambiado el label para más claridad | ||||||
|                 sx={{ flexShrink: 0 }} // Para que el label no se comprima demasiado |             sx={{ flexShrink: 0 }} | ||||||
|           /> |           /> | ||||||
|             {/* <Button variant="contained" onClick={cargarCanillitas} size="small">Buscar</Button> */} |  | ||||||
|         </Box> |         </Box> | ||||||
|         {puedeCrear && ( |         {puedeCrear && ( | ||||||
|           <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: 2 }}>Agregar Canillita</Button> |           <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: 2 }}>Agregar Canillita</Button> | ||||||
| @@ -162,21 +177,23 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|       </Paper> |       </Paper> | ||||||
|  |  | ||||||
|       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} |       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} | ||||||
|       {error && !loading && <Alert severity="error" sx={{my: 2}}>{error}</Alert>} |       {/* Mostrar error general si no hay error de API específico */} | ||||||
|  |       {error && !apiErrorMessage && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} | ||||||
|       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} |       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} | ||||||
|  |  | ||||||
|       {!loading && !error && puedeVer && ( |       {!loading && !error && puedeVer && ( // Asegurar que puedeVer sea true también aquí | ||||||
|         <TableContainer component={Paper}> |         <TableContainer component={Paper}> | ||||||
|           <Table size="small"> |           <Table size="small"> | ||||||
|             <TableHead><TableRow> |             <TableHead><TableRow> | ||||||
|               <TableCell>Legajo</TableCell><TableCell>Nombre y Apellido</TableCell> |               <TableCell>Legajo</TableCell><TableCell>Nombre y Apellido</TableCell> | ||||||
|               <TableCell>Zona</TableCell><TableCell>Empresa</TableCell> |               <TableCell>Zona</TableCell><TableCell>Empresa</TableCell> | ||||||
|               <TableCell>Accionista</TableCell><TableCell>Estado</TableCell> |               <TableCell>Accionista</TableCell><TableCell>Estado</TableCell> | ||||||
|                  <TableCell align="right">Acciones</TableCell> |               {/* Mostrar acciones solo si tiene algún permiso para el menú */} | ||||||
|  |               {(puedeModificar || puedeDarBaja || puedeVerNovedadesCanilla) && <TableCell align="right">Acciones</TableCell>} | ||||||
|             </TableRow></TableHead> |             </TableRow></TableHead> | ||||||
|             <TableBody> |             <TableBody> | ||||||
|               {displayData.length === 0 ? ( |               {displayData.length === 0 ? ( | ||||||
|                   <TableRow><TableCell colSpan={7} align="center">No se encontraron canillitas.</TableCell></TableRow> |                 <TableRow><TableCell colSpan={(puedeModificar || puedeDarBaja || puedeVerNovedadesCanilla) ? 7 : 6} align="center">No se encontraron canillitas.</TableCell></TableRow> | ||||||
|               ) : ( |               ) : ( | ||||||
|                 displayData.map((c) => ( |                 displayData.map((c) => ( | ||||||
|                   <TableRow key={c.idCanilla} hover sx={{ backgroundColor: c.baja ? '#ffebee' : 'inherit' }}> |                   <TableRow key={c.idCanilla} hover sx={{ backgroundColor: c.baja ? '#ffebee' : 'inherit' }}> | ||||||
| @@ -184,11 +201,16 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|                     <TableCell>{c.nombreZona}</TableCell><TableCell>{c.empresa === 0 ? '-' : c.nombreEmpresa}</TableCell> |                     <TableCell>{c.nombreZona}</TableCell><TableCell>{c.empresa === 0 ? '-' : c.nombreEmpresa}</TableCell> | ||||||
|                     <TableCell>{c.accionista ? <Chip label="Sí" color="success" size="small" variant="outlined" /> : <Chip label="No" color="default" size="small" variant="outlined" />}</TableCell> |                     <TableCell>{c.accionista ? <Chip label="Sí" color="success" size="small" variant="outlined" /> : <Chip label="No" color="default" size="small" variant="outlined" />}</TableCell> | ||||||
|                     <TableCell>{c.baja ? <Chip label="Baja" color="error" size="small" /> : <Chip label="Activo" color="success" size="small" />}</TableCell> |                     <TableCell>{c.baja ? <Chip label="Baja" color="error" size="small" /> : <Chip label="Activo" color="success" size="small" />}</TableCell> | ||||||
|  |                     {(puedeModificar || puedeDarBaja || puedeVerNovedadesCanilla) && ( | ||||||
|                       <TableCell align="right"> |                       <TableCell align="right"> | ||||||
|                         <IconButton onClick={(e) => handleMenuOpen(e, c)} disabled={!puedeModificar && !puedeDarBaja}> |                         <IconButton onClick={(e) => handleMenuOpen(e, c)} | ||||||
|  |                           // Deshabilitar si NO tiene NINGUNO de los permisos para las acciones del menú | ||||||
|  |                           disabled={!puedeModificar && !puedeDarBaja && !puedeVerNovedadesCanilla} | ||||||
|  |                         > | ||||||
|                           <MoreVertIcon /> |                           <MoreVertIcon /> | ||||||
|                         </IconButton> |                         </IconButton> | ||||||
|                       </TableCell> |                       </TableCell> | ||||||
|  |                     )} | ||||||
|                   </TableRow> |                   </TableRow> | ||||||
|                 )))} |                 )))} | ||||||
|             </TableBody> |             </TableBody> | ||||||
| @@ -202,14 +224,30 @@ const GestionarCanillitasPage: React.FC = () => { | |||||||
|       )} |       )} | ||||||
|  |  | ||||||
|       <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> |       <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> | ||||||
|         {puedeModificar && (<MenuItem onClick={() => { handleOpenModal(selectedCanillitaRow!); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} /> Modificar</MenuItem>)} |         {/* Mostrar opción de Novedades si tiene permiso de ver canillitas o gestionar novedades */} | ||||||
|         {puedeDarBaja && selectedCanillitaRow && ( |         {puedeVerNovedadesCanilla && selectedCanillitaRow && ( | ||||||
|             <MenuItem onClick={() => handleToggleBaja(selectedCanillitaRow)}> |           <MenuItem onClick={() => handleOpenNovedades(selectedCanillitaRow.idCanilla)}> | ||||||
|                 {selectedCanillitaRow.baja ? <ToggleOnIcon sx={{mr:1}}/> : <ToggleOffIcon sx={{mr:1}}/>} |             <ListItemIcon><EventNoteIcon /></ListItemIcon> | ||||||
|                 {selectedCanillitaRow.baja ? 'Reactivar' : 'Dar de Baja'} |             <ListItemText>Novedades</ListItemText> | ||||||
|           </MenuItem> |           </MenuItem> | ||||||
|         )} |         )} | ||||||
|         {(!puedeModificar && !puedeDarBaja) && <MenuItem disabled>Sin acciones</MenuItem>} |         {puedeModificar && selectedCanillitaRow && ( // Asegurar que selectedCanillitaRow existe | ||||||
|  |           <MenuItem onClick={() => { handleOpenModal(selectedCanillitaRow); handleMenuClose(); }}> | ||||||
|  |             <ListItemIcon><EditIcon fontSize="small" /></ListItemIcon> | ||||||
|  |             <ListItemText>Modificar</ListItemText> | ||||||
|  |           </MenuItem> | ||||||
|  |         )} | ||||||
|  |         {puedeDarBaja && selectedCanillitaRow && ( | ||||||
|  |           <MenuItem onClick={() => handleToggleBaja(selectedCanillitaRow)}> | ||||||
|  |             <ListItemIcon>{selectedCanillitaRow.baja ? <ToggleOnIcon /> : <ToggleOffIcon />}</ListItemIcon> | ||||||
|  |             <ListItemText>{selectedCanillitaRow.baja ? 'Reactivar' : 'Dar de Baja'}</ListItemText> | ||||||
|  |           </MenuItem> | ||||||
|  |         )} | ||||||
|  |          | ||||||
|  |         {/* Mostrar "Sin acciones" si no hay ninguna acción permitida para la fila seleccionada */} | ||||||
|  |         {selectedCanillitaRow && !puedeModificar && !puedeDarBaja && !puedeVerNovedadesCanilla && ( | ||||||
|  |           <MenuItem disabled>Sin acciones</MenuItem> | ||||||
|  |         )} | ||||||
|       </Menu> |       </Menu> | ||||||
|  |  | ||||||
|       <CanillaFormModal |       <CanillaFormModal | ||||||
|   | |||||||
| @@ -146,8 +146,8 @@ const GestionarEmpresasPage: React.FC = () => { | |||||||
|   // Si no tiene permiso para ver, mostrar mensaje y salir |   // Si no tiene permiso para ver, mostrar mensaje y salir | ||||||
|   if (!loading && !puedeVer) { |   if (!loading && !puedeVer) { | ||||||
|     return ( |     return ( | ||||||
|       <Box sx={{ p: 2 }}> |       <Box sx={{ p: 1 }}> | ||||||
|         <Typography variant="h4" gutterBottom>Gestionar Empresas</Typography> |         <Typography variant="h5" gutterBottom>Gestionar Empresas</Typography> | ||||||
|         <Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert> |         <Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert> | ||||||
|       </Box> |       </Box> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -3,7 +3,8 @@ import { | |||||||
|   Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Chip, |   Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Chip, | ||||||
|   Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, |   Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, | ||||||
|   CircularProgress, Alert, FormControl, InputLabel, Select, Checkbox, Tooltip, |   CircularProgress, Alert, FormControl, InputLabel, Select, Checkbox, Tooltip, | ||||||
|   Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle |   Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, | ||||||
|  |   ToggleButtonGroup, ToggleButton | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import AddIcon from '@mui/icons-material/Add'; | import AddIcon from '@mui/icons-material/Add'; | ||||||
| import PrintIcon from '@mui/icons-material/Print'; | import PrintIcon from '@mui/icons-material/Print'; | ||||||
| @@ -19,7 +20,7 @@ import canillaService from '../../services/Distribucion/canillaService'; | |||||||
|  |  | ||||||
| import type { EntradaSalidaCanillaDto } from '../../models/dtos/Distribucion/EntradaSalidaCanillaDto'; | import type { EntradaSalidaCanillaDto } from '../../models/dtos/Distribucion/EntradaSalidaCanillaDto'; | ||||||
| import type { UpdateEntradaSalidaCanillaDto } from '../../models/dtos/Distribucion/UpdateEntradaSalidaCanillaDto'; | import type { UpdateEntradaSalidaCanillaDto } from '../../models/dtos/Distribucion/UpdateEntradaSalidaCanillaDto'; | ||||||
| import type { PublicacionDto } from '../../models/dtos/Distribucion/PublicacionDto'; | import type { PublicacionDropdownDto } from '../../models/dtos/Distribucion/PublicacionDropdownDto'; | ||||||
| import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto'; | import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto'; | ||||||
| import type { LiquidarMovimientosCanillaRequestDto } from '../../models/dtos/Distribucion/LiquidarMovimientosCanillaDto'; | import type { LiquidarMovimientosCanillaRequestDto } from '../../models/dtos/Distribucion/LiquidarMovimientosCanillaDto'; | ||||||
|  |  | ||||||
| @@ -28,25 +29,32 @@ import { usePermissions } from '../../hooks/usePermissions'; | |||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| import reportesService from '../../services/Reportes/reportesService'; | import reportesService from '../../services/Reportes/reportesService'; | ||||||
|  |  | ||||||
|  | type TipoDestinatarioFiltro = 'canillitas' | 'accionistas'; | ||||||
|  |  | ||||||
| const GestionarEntradasSalidasCanillaPage: React.FC = () => { | const GestionarEntradasSalidasCanillaPage: React.FC = () => { | ||||||
|   const [movimientos, setMovimientos] = useState<EntradaSalidaCanillaDto[]>([]); |   const [movimientos, setMovimientos] = useState<EntradaSalidaCanillaDto[]>([]); | ||||||
|   const [loading, setLoading] = useState(true); |   const [loading, setLoading] = useState(true); // Para carga principal de movimientos | ||||||
|   const [error, setError] = useState<string | null>(null); |   const [error, setError] = useState<string | null>(null); // Error general o de carga | ||||||
|   const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); |   const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); // Para errores de modal/API | ||||||
|  |  | ||||||
|   const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]); |   const [filtroFecha, setFiltroFecha] = useState<string>(new Date().toISOString().split('T')[0]); | ||||||
|   const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]); |  | ||||||
|   const [filtroIdPublicacion, setFiltroIdPublicacion] = useState<number | string>(''); |   const [filtroIdPublicacion, setFiltroIdPublicacion] = useState<number | string>(''); | ||||||
|   const [filtroIdCanilla, setFiltroIdCanilla] = useState<number | string>(''); |   const [filtroIdCanillitaSeleccionado, setFiltroIdCanillitaSeleccionado] = useState<number | string>(''); | ||||||
|   const [filtroEstadoLiquidacion, setFiltroEstadoLiquidacion] = useState<'todos' | 'liquidados' | 'noLiquidados'>('noLiquidados'); |   const [filtroTipoDestinatario, setFiltroTipoDestinatario] = useState<TipoDestinatarioFiltro>('canillitas'); | ||||||
|   const [loadingTicketPdf, setLoadingTicketPdf] = useState(false); |  | ||||||
|  |  | ||||||
|   const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]); |   const [loadingTicketPdf, setLoadingTicketPdf] = useState(false); | ||||||
|   const [canillitas, setCanillitas] = useState<CanillaDto[]>([]); |   const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]); | ||||||
|  |   const [destinatariosDropdown, setDestinatariosDropdown] = useState<CanillaDto[]>([]); | ||||||
|   const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false); |   const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false); | ||||||
|  |  | ||||||
|   const [modalOpen, setModalOpen] = useState(false); |   const [modalOpen, setModalOpen] = useState(false); | ||||||
|   const [editingMovimiento, setEditingMovimiento] = useState<EntradaSalidaCanillaDto | null>(null); |   const [editingMovimiento, setEditingMovimiento] = useState<EntradaSalidaCanillaDto | null>(null); | ||||||
|  |   const [prefillModalData, setPrefillModalData] = useState<{ | ||||||
|  |     fecha?: string; | ||||||
|  |     idCanilla?: number | string; | ||||||
|  |     nombreCanilla?: string; // << AÑADIDO PARA PASAR AL MODAL | ||||||
|  |     idPublicacion?: number | string; | ||||||
|  |   } | null>(null); | ||||||
|  |  | ||||||
|   const [page, setPage] = useState(0); |   const [page, setPage] = useState(0); | ||||||
|   const [rowsPerPage, setRowsPerPage] = useState(25); |   const [rowsPerPage, setRowsPerPage] = useState(25); | ||||||
| @@ -64,70 +72,123 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|   const puedeLiquidar = isSuperAdmin || tienePermiso("MC005"); |   const puedeLiquidar = isSuperAdmin || tienePermiso("MC005"); | ||||||
|   const puedeEliminarLiquidados = isSuperAdmin || tienePermiso("MC006"); |   const puedeEliminarLiquidados = isSuperAdmin || tienePermiso("MC006"); | ||||||
|  |  | ||||||
|   // Función para formatear fechas YYYY-MM-DD a DD/MM/YYYY |  | ||||||
|   const formatDate = (dateString?: string | null): string => { |   const formatDate = (dateString?: string | null): string => { | ||||||
|     if (!dateString) return '-'; |     if (!dateString) return '-'; | ||||||
|     const datePart = dateString.split('T')[0]; |     const datePart = dateString.split('T')[0]; | ||||||
|     const parts = datePart.split('-'); |     const parts = datePart.split('-'); | ||||||
|     if (parts.length === 3) { |     if (parts.length === 3) { return `${parts[2]}/${parts[1]}/${parts[0]}`; } | ||||||
|       return `${parts[2]}/${parts[1]}/${parts[0]}`; |  | ||||||
|     } |  | ||||||
|     return datePart; |     return datePart; | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const fetchFiltersDropdownData = useCallback(async () => { |   useEffect(() => { | ||||||
|     setLoadingFiltersDropdown(true); |     const fetchPublicaciones = async () => { | ||||||
|  |       setLoadingFiltersDropdown(true); // Mover al inicio de la carga de pubs | ||||||
|       try { |       try { | ||||||
|       const [pubsData, canData] = await Promise.all([ |         const pubsData = await publicacionService.getPublicacionesForDropdown(true); | ||||||
|         publicacionService.getAllPublicaciones(undefined, undefined, true), |  | ||||||
|         canillaService.getAllCanillas(undefined, undefined, true) |  | ||||||
|       ]); |  | ||||||
|         setPublicaciones(pubsData); |         setPublicaciones(pubsData); | ||||||
|       setCanillitas(canData); |  | ||||||
|       } catch (err) { |       } catch (err) { | ||||||
|       console.error(err); setError("Error al cargar opciones de filtro."); |         console.error("Error cargando publicaciones para filtro:",err); | ||||||
|     } finally { setLoadingFiltersDropdown(false); } |         setError("Error al cargar publicaciones."); // Usar error general | ||||||
|  |       } finally { | ||||||
|  |         // No poner setLoadingFiltersDropdown(false) aquí, esperar a que ambas cargas terminen | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |     fetchPublicaciones(); | ||||||
|   }, []); |   }, []); | ||||||
|  |  | ||||||
|   useEffect(() => { fetchFiltersDropdownData(); }, [fetchFiltersDropdownData]); |   const fetchDestinatariosParaDropdown = useCallback(async () => { | ||||||
|  |     setLoadingFiltersDropdown(true); // Poner al inicio de esta carga también | ||||||
|  |     setFiltroIdCanillitaSeleccionado(''); | ||||||
|  |     setDestinatariosDropdown([]); | ||||||
|  |     setError(null); // Limpiar errores de carga de dropdowns previos | ||||||
|  |     try { | ||||||
|  |       const esAccionistaFilter = filtroTipoDestinatario === 'accionistas'; | ||||||
|  |       const data = await canillaService.getAllCanillas(undefined, undefined, true, esAccionistaFilter); | ||||||
|  |       setDestinatariosDropdown(data); | ||||||
|  |     } catch (err) { | ||||||
|  |       console.error("Error cargando destinatarios para filtro:", err); | ||||||
|  |       setError("Error al cargar canillitas/accionistas."); // Usar error general | ||||||
|  |     } finally { | ||||||
|  |       setLoadingFiltersDropdown(false); // Poner al final de AMBAS cargas de dropdown | ||||||
|  |     } | ||||||
|  |   }, [filtroTipoDestinatario]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     fetchDestinatariosParaDropdown(); | ||||||
|  |   }, [fetchDestinatariosParaDropdown]); | ||||||
|  |  | ||||||
|  |  | ||||||
|   const cargarMovimientos = useCallback(async () => { |   const cargarMovimientos = useCallback(async () => { | ||||||
|     if (!puedeVer) { setError("No tiene permiso."); 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); |     setLoading(true); setError(null); setApiErrorMessage(null); | ||||||
|     try { |     try { | ||||||
|       let liquidadosFilter: boolean | null = null; |  | ||||||
|       let incluirNoLiquidadosFilter: boolean | null = true; // Por defecto mostrar no liquidados |  | ||||||
|  |  | ||||||
|       if (filtroEstadoLiquidacion === 'liquidados') { |  | ||||||
|         liquidadosFilter = true; |  | ||||||
|         incluirNoLiquidadosFilter = false; |  | ||||||
|       } else if (filtroEstadoLiquidacion === 'noLiquidados') { |  | ||||||
|         liquidadosFilter = false; |  | ||||||
|         incluirNoLiquidadosFilter = true; |  | ||||||
|       } // Si es 'todos', ambos son null o true y false respectivamente (backend debe manejarlo) |  | ||||||
|  |  | ||||||
|  |  | ||||||
|       const params = { |       const params = { | ||||||
|         fechaDesde: filtroFechaDesde || null, fechaHasta: filtroFechaHasta || null, |         fechaDesde: filtroFecha, | ||||||
|  |         fechaHasta: filtroFecha, | ||||||
|         idPublicacion: filtroIdPublicacion ? Number(filtroIdPublicacion) : null, |         idPublicacion: filtroIdPublicacion ? Number(filtroIdPublicacion) : null, | ||||||
|         idCanilla: filtroIdCanilla ? Number(filtroIdCanilla) : null, |         idCanilla: Number(filtroIdCanillitaSeleccionado), | ||||||
|         liquidados: liquidadosFilter, |         liquidados: null, | ||||||
|         incluirNoLiquidados: filtroEstadoLiquidacion === 'todos' ? null : incluirNoLiquidadosFilter, |         incluirNoLiquidados: null, | ||||||
|       }; |       }; | ||||||
|       const data = await entradaSalidaCanillaService.getAllEntradasSalidasCanilla(params); |       const data = await entradaSalidaCanillaService.getAllEntradasSalidasCanilla(params); | ||||||
|       setMovimientos(data); |       setMovimientos(data); | ||||||
|       setSelectedIdsParaLiquidar(new Set()); // Limpiar selección al recargar |       setSelectedIdsParaLiquidar(new Set()); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       console.error(err); setError('Error al cargar movimientos.'); |       console.error("Error al cargar movimientos:", err); | ||||||
|     } finally { setLoading(false); } |       setError('Error al cargar movimientos.'); | ||||||
|   }, [puedeVer, filtroFechaDesde, filtroFechaHasta, filtroIdPublicacion, filtroIdCanilla, filtroEstadoLiquidacion]); |       setMovimientos([]); | ||||||
|  |     } finally { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }, [puedeVer, filtroFecha, filtroIdPublicacion, filtroIdCanillitaSeleccionado]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (filtroFecha && filtroIdCanillitaSeleccionado) { | ||||||
|  |         cargarMovimientos(); | ||||||
|  |     } else { | ||||||
|  |         setMovimientos([]); | ||||||
|  |         if (loading) setLoading(false); // Asegurar que no se quede en loading si los filtros se limpian | ||||||
|  |     } | ||||||
|  |   }, [cargarMovimientos, filtroFecha, filtroIdCanillitaSeleccionado]); // `cargarMovimientos` ya tiene sus dependencias | ||||||
|  |  | ||||||
|   useEffect(() => { cargarMovimientos(); }, [cargarMovimientos]); |  | ||||||
|  |  | ||||||
|   const handleOpenModal = (item?: EntradaSalidaCanillaDto) => { |   const handleOpenModal = (item?: EntradaSalidaCanillaDto) => { | ||||||
|     setEditingMovimiento(item || null); setApiErrorMessage(null); setModalOpen(true); |     if (!puedeCrear && !item) { | ||||||
|  |         setApiErrorMessage("No tiene permiso para registrar nuevos movimientos."); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |     if (item && !puedeModificar) { | ||||||
|  |         setApiErrorMessage("No tiene permiso para modificar movimientos."); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (item) { | ||||||
|  |         setEditingMovimiento(item); | ||||||
|  |         setPrefillModalData(null); | ||||||
|  |     } else { | ||||||
|  |         // --- CAMBIO: Obtener nombre del canillita seleccionado para prefill --- | ||||||
|  |         const canillitaSeleccionado = destinatariosDropdown.find( | ||||||
|  |             c => c.idCanilla === Number(filtroIdCanillitaSeleccionado) | ||||||
|  |         ); | ||||||
|  |         setEditingMovimiento(null); | ||||||
|  |         setPrefillModalData({ | ||||||
|  |             fecha: filtroFecha, | ||||||
|  |             idCanilla: filtroIdCanillitaSeleccionado, | ||||||
|  |             nombreCanilla: canillitaSeleccionado?.nomApe, // << AÑADIR NOMBRE | ||||||
|  |             idPublicacion: filtroIdPublicacion | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |     setApiErrorMessage(null); | ||||||
|  |     setModalOpen(true); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   // ... handleDelete, handleMenuOpen, handleMenuClose, handleSelectRowForLiquidar, handleSelectAllForLiquidar, handleOpenLiquidarDialog, handleCloseLiquidarDialog sin cambios ... | ||||||
|   const handleDelete = async (idParte: number) => { |   const handleDelete = async (idParte: number) => { | ||||||
|     if (window.confirm(`¿Seguro de eliminar este movimiento (ID: ${idParte})?`)) { |     if (window.confirm(`¿Seguro de eliminar este movimiento (ID: ${idParte})?`)) { | ||||||
|       setApiErrorMessage(null); |       setApiErrorMessage(null); | ||||||
| @@ -138,7 +199,6 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, item: EntradaSalidaCanillaDto) => { |   const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, item: EntradaSalidaCanillaDto) => { | ||||||
|     // Almacenar el idParte en el propio elemento del menú para referencia |  | ||||||
|     event.currentTarget.setAttribute('data-rowid', item.idParte.toString()); |     event.currentTarget.setAttribute('data-rowid', item.idParte.toString()); | ||||||
|     setAnchorEl(event.currentTarget); |     setAnchorEl(event.currentTarget); | ||||||
|     setSelectedRow(item); |     setSelectedRow(item); | ||||||
| @@ -170,64 +230,55 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|     setOpenLiquidarDialog(true); |     setOpenLiquidarDialog(true); | ||||||
|   }; |   }; | ||||||
|   const handleCloseLiquidarDialog = () => setOpenLiquidarDialog(false); |   const handleCloseLiquidarDialog = () => setOpenLiquidarDialog(false); | ||||||
|  |  | ||||||
|  |  | ||||||
|   const handleConfirmLiquidar = async () => { |   const handleConfirmLiquidar = async () => { | ||||||
|     if (selectedIdsParaLiquidar.size === 0) { |     if (selectedIdsParaLiquidar.size === 0) { /* ... */ return; } | ||||||
|         setApiErrorMessage("No hay movimientos seleccionados para liquidar."); |     if (!fechaLiquidacionDialog) { /* ... */ return; } | ||||||
|         return; |     // ... (validación de fecha sin cambios) | ||||||
|     } |     const fechaLiquidacionDate = new Date(fechaLiquidacionDialog + 'T00:00:00Z'); | ||||||
|     if (!fechaLiquidacionDialog) { |  | ||||||
|         setApiErrorMessage("Debe seleccionar una fecha de liquidación."); |  | ||||||
|         return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // --- VALIDACIÓN DE FECHA --- |  | ||||||
|     const fechaLiquidacionDate = new Date(fechaLiquidacionDialog + 'T00:00:00Z'); // Usar Z para consistencia con formatDate si es necesario, o T00:00:00 para local |  | ||||||
|  |  | ||||||
|     let fechaMovimientoMasReciente: Date | null = null; |     let fechaMovimientoMasReciente: Date | null = null; | ||||||
|  |  | ||||||
|     selectedIdsParaLiquidar.forEach(idParte => { |     selectedIdsParaLiquidar.forEach(idParte => { | ||||||
|         const movimiento = movimientos.find(m => m.idParte === idParte); |         const movimiento = movimientos.find(m => m.idParte === idParte); | ||||||
|         if (movimiento && movimiento.fecha) { // Asegurarse que movimiento.fecha existe |         if (movimiento && movimiento.fecha) { | ||||||
|             const movFecha = new Date(movimiento.fecha.split('T')[0] + 'T00:00:00Z'); // Consistencia con Z |             const movFecha = new Date(movimiento.fecha.split('T')[0] + 'T00:00:00Z'); | ||||||
|             if (fechaMovimientoMasReciente === null || movFecha.getTime() > (fechaMovimientoMasReciente as Date).getTime()) { // Comparar usando getTime() |             if (fechaMovimientoMasReciente === null || movFecha.getTime() > (fechaMovimientoMasReciente as Date).getTime()) { | ||||||
|                 fechaMovimientoMasReciente = movFecha; |                 fechaMovimientoMasReciente = movFecha; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|  |     if (fechaMovimientoMasReciente !== null && fechaLiquidacionDate.getTime() < (fechaMovimientoMasReciente as Date).getTime()) { | ||||||
|     if (fechaMovimientoMasReciente !== null && fechaLiquidacionDate.getTime() < (fechaMovimientoMasReciente as Date).getTime()) { // Comparar usando getTime() |  | ||||||
|         setApiErrorMessage(`La fecha de liquidación (${fechaLiquidacionDate.toLocaleDateString('es-AR', {timeZone: 'UTC'})}) no puede ser inferior a la fecha del movimiento más reciente a liquidar (${(fechaMovimientoMasReciente as Date).toLocaleDateString('es-AR', {timeZone: 'UTC'})}).`); |         setApiErrorMessage(`La fecha de liquidación (${fechaLiquidacionDate.toLocaleDateString('es-AR', {timeZone: 'UTC'})}) no puede ser inferior a la fecha del movimiento más reciente a liquidar (${(fechaMovimientoMasReciente as Date).toLocaleDateString('es-AR', {timeZone: 'UTC'})}).`); | ||||||
|         return; |         return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     setApiErrorMessage(null); |     setApiErrorMessage(null); | ||||||
|     setLoading(true); // Usar el loading general para la operación de liquidar |     setLoading(true); | ||||||
|  |  | ||||||
|     const liquidarDto: LiquidarMovimientosCanillaRequestDto = { |     const liquidarDto: LiquidarMovimientosCanillaRequestDto = { | ||||||
|       idsPartesALiquidar: Array.from(selectedIdsParaLiquidar), |       idsPartesALiquidar: Array.from(selectedIdsParaLiquidar), | ||||||
|       fechaLiquidacion: fechaLiquidacionDialog // El backend espera YYYY-MM-DD |       fechaLiquidacion: fechaLiquidacionDialog | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       await entradaSalidaCanillaService.liquidarMovimientos(liquidarDto); |       await entradaSalidaCanillaService.liquidarMovimientos(liquidarDto); | ||||||
|       setOpenLiquidarDialog(false); |       setOpenLiquidarDialog(false); | ||||||
|        |  | ||||||
|       const primerIdParteLiquidado = Array.from(selectedIdsParaLiquidar)[0]; |       const primerIdParteLiquidado = Array.from(selectedIdsParaLiquidar)[0]; | ||||||
|       const movimientoParaTicket = movimientos.find(m => m.idParte === primerIdParteLiquidado); |       const movimientoParaTicket = movimientos.find(m => m.idParte === primerIdParteLiquidado); | ||||||
|  |  | ||||||
|       await cargarMovimientos(); |       await cargarMovimientos(); | ||||||
|  |  | ||||||
|       if (movimientoParaTicket) { |       // --- CAMBIO: NO IMPRIMIR TICKET SI ES ACCIONISTA --- | ||||||
|         console.log("Liquidación exitosa, intentando generar ticket para canillita:", movimientoParaTicket.idCanilla); |       if (movimientoParaTicket && !movimientoParaTicket.canillaEsAccionista) { | ||||||
|  |         console.log("Liquidación exitosa, generando ticket para canillita NO accionista:", movimientoParaTicket.idCanilla); | ||||||
|         await handleImprimirTicketLiquidacion( |         await handleImprimirTicketLiquidacion( | ||||||
|             movimientoParaTicket.idCanilla, |             movimientoParaTicket.idCanilla, | ||||||
|             fechaLiquidacionDialog, |             fechaLiquidacionDialog, | ||||||
|             movimientoParaTicket.canillaEsAccionista |             false // esAccionista = false | ||||||
|         ); |         ); | ||||||
|  |       } else if (movimientoParaTicket && movimientoParaTicket.canillaEsAccionista) { | ||||||
|  |         console.log("Liquidación exitosa para accionista. No se genera ticket automáticamente."); | ||||||
|       } else { |       } else { | ||||||
|         console.warn("No se pudo encontrar información del movimiento para generar el ticket post-liquidación."); |         console.warn("No se pudo encontrar información del movimiento para ticket post-liquidación."); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|     } catch (err: any) { |     } catch (err: any) { | ||||||
|       const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al liquidar.'; |       const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al liquidar.'; | ||||||
|       setApiErrorMessage(msg); |       setApiErrorMessage(msg); | ||||||
| @@ -236,8 +287,8 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   // Esta función se pasa al modal para que la invoque al hacer submit en MODO EDICIÓN |  | ||||||
|   const handleModalEditSubmit = async (data: UpdateEntradaSalidaCanillaDto, idParte: number) => { |   const handleModalEditSubmit = async (data: UpdateEntradaSalidaCanillaDto, idParte: number) => { | ||||||
|  |     // ... (sin cambios) | ||||||
|     setApiErrorMessage(null); |     setApiErrorMessage(null); | ||||||
|     try { |     try { | ||||||
|       await entradaSalidaCanillaService.updateEntradaSalidaCanilla(idParte, data); |       await entradaSalidaCanillaService.updateEntradaSalidaCanilla(idParte, data); | ||||||
| @@ -251,32 +302,21 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|   const handleCloseModal = () => { |   const handleCloseModal = () => { | ||||||
|     setModalOpen(false); |     setModalOpen(false); | ||||||
|     setEditingMovimiento(null); |     setEditingMovimiento(null); | ||||||
|     // Recargar siempre que se cierre el modal y no haya un error pendiente a nivel de página |     setPrefillModalData(null); | ||||||
|     // Opcionalmente, podrías tener una bandera ' cambiosGuardados' que el modal active |  | ||||||
|     // para ser más selectivo con la recarga. |  | ||||||
|     if (!apiErrorMessage) { |     if (!apiErrorMessage) { | ||||||
|       cargarMovimientos(); |       cargarMovimientos(); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleImprimirTicketLiquidacion = useCallback(async ( |   const handleImprimirTicketLiquidacion = useCallback(async ( | ||||||
|     // Parámetros necesarios para el ticket |     idCanilla: number, fecha: string, esAccionista: boolean | ||||||
|     idCanilla: number, |  | ||||||
|     fecha: string, // Fecha para la que se genera el ticket (probablemente fechaLiquidacionDialog) |  | ||||||
|     esAccionista: boolean |  | ||||||
|   ) => { |   ) => { | ||||||
|  |     // ... (sin cambios) | ||||||
|     setLoadingTicketPdf(true); |     setLoadingTicketPdf(true); | ||||||
|     setApiErrorMessage(null); |     setApiErrorMessage(null); | ||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       const params = { |       const params = { fecha: fecha.split('T')[0], idCanilla, esAccionista }; | ||||||
|         fecha: fecha.split('T')[0], // Asegurar formato YYYY-MM-DD |  | ||||||
|         idCanilla: idCanilla, |  | ||||||
|         esAccionista: esAccionista, |  | ||||||
|       }; |  | ||||||
|  |  | ||||||
|       const blob = await reportesService.getTicketLiquidacionCanillaPdf(params); |       const blob = await reportesService.getTicketLiquidacionCanillaPdf(params); | ||||||
|  |  | ||||||
|       if (blob.type === "application/json") { |       if (blob.type === "application/json") { | ||||||
|         const text = await blob.text(); |         const text = await blob.text(); | ||||||
|         const msg = JSON.parse(text).message ?? "Error inesperado al generar el ticket PDF."; |         const msg = JSON.parse(text).message ?? "Error inesperado al generar el ticket PDF."; | ||||||
| @@ -287,16 +327,11 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|         if (!w) alert("Permita popups para ver el PDF del ticket."); |         if (!w) alert("Permita popups para ver el PDF del ticket."); | ||||||
|       } |       } | ||||||
|     } catch (error: any) { |     } catch (error: any) { | ||||||
|       console.error("Error al generar ticket de liquidación:", error); |       console.error("Error al generar ticket:", error); | ||||||
|       const message = axios.isAxiosError(error) && error.response?.data?.message |       const message = axios.isAxiosError(error) && error.response?.data?.message ? error.response.data.message : 'Error al generar ticket.'; | ||||||
|         ? error.response.data.message |  | ||||||
|         : 'Ocurrió un error al generar el ticket.'; |  | ||||||
|       setApiErrorMessage(message); |       setApiErrorMessage(message); | ||||||
|     } finally { |     } finally { setLoadingTicketPdf(false); } | ||||||
|       setLoadingTicketPdf(false); |   }, []); | ||||||
|       // No cerramos el menú aquí si se llama desde handleConfirmLiquidar |  | ||||||
|     } |  | ||||||
|   }, []); // Dependencias vacías si no usa nada del scope exterior que cambie, o añadir si es necesario |  | ||||||
|  |  | ||||||
|  |  | ||||||
|   const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); |   const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); | ||||||
| @@ -305,47 +340,77 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|   }; |   }; | ||||||
|   const displayData = movimientos.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); |   const displayData = movimientos.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); | ||||||
|  |  | ||||||
|   if (!loading && !puedeVer && !loadingFiltersDropdown) return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado."}</Alert></Box>; |   if (!loading && !puedeVer && !loadingFiltersDropdown && movimientos.length === 0 && !filtroFecha && !filtroIdCanillitaSeleccionado ) { // Modificado para solo mostrar si no hay filtros y no puede ver | ||||||
|  |      return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado."}</Alert></Box>; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|   const numSelectedToLiquidate = selectedIdsParaLiquidar.size; |   const numSelectedToLiquidate = selectedIdsParaLiquidar.size; | ||||||
|   // Corregido: numNotLiquidatedOnPage debe calcularse sobre 'movimientos' filtrados, no solo 'displayData' |  | ||||||
|   // O, si la selección es solo por página, displayData está bien. Asumamos selección por página por ahora. |  | ||||||
|   const numNotLiquidatedOnPage = displayData.filter(m => !m.liquidado).length; |   const numNotLiquidatedOnPage = displayData.filter(m => !m.liquidado).length; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Box sx={{ p: 1 }}> |     <Box sx={{ p: 1 }}> | ||||||
|       <Typography variant="h5" gutterBottom>Entradas/Salidas Canillitas</Typography> |       <Typography variant="h5" gutterBottom>Entradas/Salidas Canillitas & Accionistas</Typography> | ||||||
|       <Paper sx={{ p: 2, mb: 2 }}> |       <Paper sx={{ p: 2, mb: 2 }}> | ||||||
|         <Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography> |         <Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography> | ||||||
|         <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center', mb: 2 }}> |         <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center', mb: 2 }}> | ||||||
|           <TextField label="Fecha Desde" type="date" size="small" value={filtroFechaDesde} onChange={(e) => setFiltroFechaDesde(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} /> |           <TextField label="Fecha" type="date" size="small" value={filtroFecha} | ||||||
|           <TextField label="Fecha Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} /> |             onChange={(e) => setFiltroFecha(e.target.value)} | ||||||
|  |             InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} | ||||||
|  |             required | ||||||
|  |             error={!filtroFecha} // Se marca error si está vacío | ||||||
|  |             helperText={!filtroFecha ? "Fecha es obligatoria" : ""} | ||||||
|  |           /> | ||||||
|  |           <ToggleButtonGroup | ||||||
|  |             color="primary" | ||||||
|  |             value={filtroTipoDestinatario} | ||||||
|  |             exclusive | ||||||
|  |             onChange={(_, newValue: TipoDestinatarioFiltro | null) => { | ||||||
|  |               if (newValue !== null) { | ||||||
|  |                 setFiltroTipoDestinatario(newValue); | ||||||
|  |               } | ||||||
|  |             }} | ||||||
|  |             aria-label="Tipo de Destinatario" | ||||||
|  |             size="small" | ||||||
|  |           > | ||||||
|  |             <ToggleButton value="canillitas">Canillitas</ToggleButton> | ||||||
|  |             <ToggleButton value="accionistas">Accionistas</ToggleButton> | ||||||
|  |           </ToggleButtonGroup> | ||||||
|  |  | ||||||
|  |           <FormControl size="small" sx={{ minWidth: 220, flexGrow: 1 }} disabled={loadingFiltersDropdown} required error={!filtroIdCanillitaSeleccionado}> | ||||||
|  |             <InputLabel>{filtroTipoDestinatario === 'canillitas' ? 'Canillita' : 'Accionista'}</InputLabel> | ||||||
|  |             <Select | ||||||
|  |               value={filtroIdCanillitaSeleccionado} | ||||||
|  |               label={filtroTipoDestinatario === 'canillitas' ? 'Canillita' : 'Accionista'} | ||||||
|  |               onChange={(e) => setFiltroIdCanillitaSeleccionado(e.target.value as number | string)} | ||||||
|  |             > | ||||||
|  |               <MenuItem value=""><em>Seleccione uno</em></MenuItem> | ||||||
|  |               {destinatariosDropdown.map(c => <MenuItem key={c.idCanilla} value={c.idCanilla}>{c.nomApe} {c.legajo ? `(Leg: ${c.legajo})`: ''}</MenuItem>)} | ||||||
|  |             </Select> | ||||||
|  |             {!filtroIdCanillitaSeleccionado && <Typography component="p" color="error" variant="caption" sx={{ml:1.5, fontSize:'0.65rem'}}>Selección obligatoria</Typography>} | ||||||
|  |           </FormControl> | ||||||
|  |  | ||||||
|           <FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }} disabled={loadingFiltersDropdown}> |           <FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }} disabled={loadingFiltersDropdown}> | ||||||
|             <InputLabel>Publicación</InputLabel> |             <InputLabel>Publicación (Opcional)</InputLabel> | ||||||
|             <Select value={filtroIdPublicacion} label="Publicación" onChange={(e) => setFiltroIdPublicacion(e.target.value as number | string)}> |             <Select value={filtroIdPublicacion} label="Publicación (Opcional)" onChange={(e) => setFiltroIdPublicacion(e.target.value as number | string)}> | ||||||
|               <MenuItem value=""><em>Todas</em></MenuItem> |               <MenuItem value=""><em>Todas</em></MenuItem> | ||||||
|               {publicaciones.map(p => <MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre}</MenuItem>)} |               {publicaciones.map(p => <MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre}</MenuItem>)} | ||||||
|             </Select> |             </Select> | ||||||
|           </FormControl> |           </FormControl> | ||||||
|           <FormControl size="small" sx={{ minWidth: 200, flexGrow: 1 }} disabled={loadingFiltersDropdown}> |  | ||||||
|             <InputLabel>Canillita</InputLabel> |  | ||||||
|             <Select value={filtroIdCanilla} label="Canillita" onChange={(e) => setFiltroIdCanilla(e.target.value as number | string)}> |  | ||||||
|               <MenuItem value=""><em>Todos</em></MenuItem> |  | ||||||
|               {canillitas.map(c => <MenuItem key={c.idCanilla} value={c.idCanilla}>{c.nomApe}</MenuItem>)} |  | ||||||
|             </Select> |  | ||||||
|           </FormControl> |  | ||||||
|           <FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }}> |  | ||||||
|             <InputLabel>Estado Liquidación</InputLabel> |  | ||||||
|             <Select value={filtroEstadoLiquidacion} label="Estado Liquidación" onChange={(e) => setFiltroEstadoLiquidacion(e.target.value as 'todos' | 'liquidados' | 'noLiquidados')}> |  | ||||||
|               <MenuItem value="noLiquidados">No Liquidados</MenuItem> |  | ||||||
|               <MenuItem value="liquidados">Liquidados</MenuItem> |  | ||||||
|               <MenuItem value="todos">Todos</MenuItem> |  | ||||||
|             </Select> |  | ||||||
|           </FormControl> |  | ||||||
|         </Box> |         </Box> | ||||||
|         <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> |         <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | ||||||
|           {puedeCrear && (<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Registrar Movimiento</Button>)} |           {/* --- CAMBIO: DESHABILITAR BOTÓN SI FILTROS OBLIGATORIOS NO ESTÁN --- */} | ||||||
|           {puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' && numSelectedToLiquidate > 0 && ( |           {puedeCrear && ( | ||||||
|  |             <Button  | ||||||
|  |                 variant="contained"  | ||||||
|  |                 startIcon={<AddIcon />}  | ||||||
|  |                 onClick={() => handleOpenModal()} | ||||||
|  |                 disabled={!filtroFecha || !filtroIdCanillitaSeleccionado} // <<-- AÑADIDO | ||||||
|  |             > | ||||||
|  |                 Registrar Movimiento | ||||||
|  |             </Button> | ||||||
|  |           )} | ||||||
|  |           {puedeLiquidar && numSelectedToLiquidate > 0 && movimientos.some(m => selectedIdsParaLiquidar.has(m.idParte) && !m.liquidado) && ( | ||||||
|             <Button variant="contained" color="success" startIcon={<PlaylistAddCheckIcon />} onClick={handleOpenLiquidarDialog}> |             <Button variant="contained" color="success" startIcon={<PlaylistAddCheckIcon />} onClick={handleOpenLiquidarDialog}> | ||||||
|               Liquidar Seleccionados ({numSelectedToLiquidate}) |               Liquidar Seleccionados ({numSelectedToLiquidate}) | ||||||
|             </Button> |             </Button> | ||||||
| @@ -353,8 +418,12 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|         </Box> |         </Box> | ||||||
|       </Paper> |       </Paper> | ||||||
|  |  | ||||||
|  |       {!filtroFecha && <Alert severity="info" sx={{my:1}}>Por favor, seleccione una fecha.</Alert>} | ||||||
|  |       {filtroFecha && !filtroIdCanillitaSeleccionado && <Alert severity="info" sx={{my:1}}>Por favor, seleccione un {filtroTipoDestinatario === 'canillitas' ? 'canillita' : 'accionista'}.</Alert>} | ||||||
|  |  | ||||||
|       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} |       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} | ||||||
|       {error && !loading && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} |       {/* Mostrar error general si no hay error de API específico y no está cargando filtros */} | ||||||
|  |       {error && !loading && !apiErrorMessage && !loadingFiltersDropdown && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} | ||||||
|       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} |       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} | ||||||
|       {loadingTicketPdf && |       {loadingTicketPdf && | ||||||
|         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', my: 2 }}> |         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', my: 2 }}> | ||||||
| @@ -364,12 +433,13 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|  |  | ||||||
|       {!loading && !error && puedeVer && ( |       {!loading && !error && puedeVer && filtroFecha && filtroIdCanillitaSeleccionado && ( | ||||||
|  |         // ... (Tabla y Paginación sin cambios) | ||||||
|          <TableContainer component={Paper}> |          <TableContainer component={Paper}> | ||||||
|           <Table size="small"> |           <Table size="small"> | ||||||
|             <TableHead> |             <TableHead> | ||||||
|               <TableRow> |               <TableRow> | ||||||
|                 {puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' && ( |                 {puedeLiquidar && ( | ||||||
|                   <TableCell padding="checkbox"> |                   <TableCell padding="checkbox"> | ||||||
|                     <Checkbox |                     <Checkbox | ||||||
|                       indeterminate={numSelectedToLiquidate > 0 && numSelectedToLiquidate < numNotLiquidatedOnPage && numNotLiquidatedOnPage > 0} |                       indeterminate={numSelectedToLiquidate > 0 && numSelectedToLiquidate < numNotLiquidatedOnPage && numNotLiquidatedOnPage > 0} | ||||||
| @@ -381,7 +451,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|                 )} |                 )} | ||||||
|                 <TableCell>Fecha</TableCell> |                 <TableCell>Fecha</TableCell> | ||||||
|                 <TableCell>Publicación</TableCell> |                 <TableCell>Publicación</TableCell> | ||||||
|                 <TableCell>Canillita</TableCell> |                 <TableCell>{filtroTipoDestinatario === 'canillitas' ? 'Canillita' : 'Accionista'}</TableCell> | ||||||
|                 <TableCell align="right">Salida</TableCell> |                 <TableCell align="right">Salida</TableCell> | ||||||
|                 <TableCell align="right">Entrada</TableCell> |                 <TableCell align="right">Entrada</TableCell> | ||||||
|                 <TableCell align="right">Vendidos</TableCell> |                 <TableCell align="right">Vendidos</TableCell> | ||||||
| @@ -397,19 +467,17 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|                 <TableRow> |                 <TableRow> | ||||||
|                   <TableCell |                   <TableCell | ||||||
|                     colSpan={ |                     colSpan={ | ||||||
|                       (puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' ? 1 : 0) + |                       (puedeLiquidar ? 1 : 0) + 9 + ((puedeModificar || puedeEliminar || puedeLiquidar) ? 1 : 0) | ||||||
|                       9 + |  | ||||||
|                       ((puedeModificar || puedeEliminar || puedeLiquidar) ? 1 : 0) |  | ||||||
|                     } |                     } | ||||||
|                     align="center" |                     align="center" | ||||||
|                   > |                   > | ||||||
|                     No se encontraron movimientos. |                     No se encontraron movimientos con los filtros aplicados. | ||||||
|                   </TableCell> |                   </TableCell> | ||||||
|                 </TableRow> |                 </TableRow> | ||||||
|               ) : ( |               ) : ( | ||||||
|                 displayData.map((m) => ( |                 displayData.map((m) => ( | ||||||
|                   <TableRow key={m.idParte} hover selected={selectedIdsParaLiquidar.has(m.idParte)}> |                   <TableRow key={m.idParte} hover selected={selectedIdsParaLiquidar.has(m.idParte)}> | ||||||
|                     {puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' && ( |                     {puedeLiquidar && ( | ||||||
|                       <TableCell padding="checkbox"> |                       <TableCell padding="checkbox"> | ||||||
|                         <Checkbox |                         <Checkbox | ||||||
|                           checked={selectedIdsParaLiquidar.has(m.idParte)} |                           checked={selectedIdsParaLiquidar.has(m.idParte)} | ||||||
| @@ -440,8 +508,8 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|                       <TableCell align="right"> |                       <TableCell align="right"> | ||||||
|                         <IconButton |                         <IconButton | ||||||
|                           onClick={(e) => handleMenuOpen(e, m)} |                           onClick={(e) => handleMenuOpen(e, m)} | ||||||
|                           data-rowid={m.idParte.toString()} // Guardar el id de la fila aquí |                           data-rowid={m.idParte.toString()} | ||||||
|                           disabled={m.liquidado && !puedeEliminarLiquidados && !puedeLiquidar} // Lógica simplificada, refinar si es necesario |                           disabled={m.liquidado && !puedeEliminarLiquidados && !puedeLiquidar} | ||||||
|                         > |                         > | ||||||
|                           <MoreVertIcon /> |                           <MoreVertIcon /> | ||||||
|                         </IconButton> |                         </IconButton> | ||||||
| @@ -462,19 +530,17 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|       <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> |       <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> | ||||||
|         {puedeModificar && selectedRow && !selectedRow.liquidado && ( |         {puedeModificar && selectedRow && !selectedRow.liquidado && ( | ||||||
|           <MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} /> Modificar</MenuItem>)} |           <MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} /> Modificar</MenuItem>)} | ||||||
|  |         {/* --- CAMBIO: MOSTRAR REIMPRIMIR TICKET SIEMPRE SI ESTÁ LIQUIDADO --- */} | ||||||
|         {/* Opción de Imprimir Ticket Liq. */} |         {selectedRow && selectedRow.liquidado && puedeLiquidar && ( // Usar puedeLiquidar para consistencia | ||||||
|         {selectedRow && selectedRow.liquidado && ( // Solo mostrar si ya está liquidado (para reimprimir) |  | ||||||
|           <MenuItem |           <MenuItem | ||||||
|             onClick={() => { |             onClick={() => { | ||||||
|               if (selectedRow) { // selectedRow no será null aquí debido a la condición anterior |               if (selectedRow) { | ||||||
|                 handleImprimirTicketLiquidacion( |                 handleImprimirTicketLiquidacion( | ||||||
|                   selectedRow.idCanilla, |                   selectedRow.idCanilla, | ||||||
|                   selectedRow.fechaLiquidado || selectedRow.fecha, // Usar fechaLiquidado si existe, sino la fecha del movimiento |                   selectedRow.fechaLiquidado || selectedRow.fecha, | ||||||
|                   selectedRow.canillaEsAccionista |                   selectedRow.canillaEsAccionista // Pasar si es accionista | ||||||
|                 ); |                 ); | ||||||
|               } |               } | ||||||
|               // handleMenuClose() es llamado por handleImprimirTicketLiquidacion |  | ||||||
|             }} |             }} | ||||||
|             disabled={loadingTicketPdf} |             disabled={loadingTicketPdf} | ||||||
|           > |           > | ||||||
| @@ -483,13 +549,10 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|             Reimprimir Ticket Liq. |             Reimprimir Ticket Liq. | ||||||
|           </MenuItem> |           </MenuItem> | ||||||
|         )} |         )} | ||||||
|  |         {selectedRow && ( | ||||||
|         {selectedRow && ( // Opción de Eliminar |  | ||||||
|           ((!selectedRow.liquidado && puedeEliminar) || (selectedRow.liquidado && puedeEliminarLiquidados)) |           ((!selectedRow.liquidado && puedeEliminar) || (selectedRow.liquidado && puedeEliminarLiquidados)) | ||||||
|         ) && ( |         ) && ( | ||||||
|             <MenuItem onClick={() => { |             <MenuItem onClick={() => { if (selectedRow) handleDelete(selectedRow.idParte); }}> | ||||||
|               if (selectedRow) handleDelete(selectedRow.idParte); |  | ||||||
|             }}> |  | ||||||
|               <DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Eliminar |               <DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Eliminar | ||||||
|             </MenuItem> |             </MenuItem> | ||||||
|           )} |           )} | ||||||
| @@ -498,12 +561,14 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|       <EntradaSalidaCanillaFormModal |       <EntradaSalidaCanillaFormModal | ||||||
|         open={modalOpen} |         open={modalOpen} | ||||||
|         onClose={handleCloseModal} |         onClose={handleCloseModal} | ||||||
|         onSubmit={handleModalEditSubmit} |         onSubmit={handleModalEditSubmit} // Este onSubmit es solo para edición | ||||||
|         initialData={editingMovimiento} |         initialData={editingMovimiento} | ||||||
|  |         prefillData={prefillModalData} | ||||||
|         errorMessage={apiErrorMessage} |         errorMessage={apiErrorMessage} | ||||||
|         clearErrorMessage={() => setApiErrorMessage(null)} |         clearErrorMessage={() => setApiErrorMessage(null)} | ||||||
|       /> |       /> | ||||||
|  |  | ||||||
|  |       {/* ... (Dialog de Liquidación sin cambios) ... */} | ||||||
|        <Dialog open={openLiquidarDialog} onClose={handleCloseLiquidarDialog}> |        <Dialog open={openLiquidarDialog} onClose={handleCloseLiquidarDialog}> | ||||||
|         <DialogTitle>Confirmar Liquidación</DialogTitle> |         <DialogTitle>Confirmar Liquidación</DialogTitle> | ||||||
|         <DialogContent> |         <DialogContent> | ||||||
| @@ -523,7 +588,6 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => { | |||||||
|           </Button> |           </Button> | ||||||
|         </DialogActions> |         </DialogActions> | ||||||
|       </Dialog> |       </Dialog> | ||||||
|  |  | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -0,0 +1,301 @@ | |||||||
|  | // src/pages/Distribucion/GestionarNovedadesCanillaPage.tsx | ||||||
|  | import React, { useState, useEffect, useCallback } from 'react'; | ||||||
|  | import { useParams, useNavigate } from 'react-router-dom'; | ||||||
|  | import { | ||||||
|  |     Box, Typography, Button, Paper, IconButton, Menu, MenuItem, | ||||||
|  |     Table, TableBody, TableCell, TableContainer, TableHead, TableRow, | ||||||
|  |     CircularProgress, Alert, TextField, Tooltip | ||||||
|  | } from '@mui/material'; | ||||||
|  | import AddIcon from '@mui/icons-material/Add'; | ||||||
|  | import MoreVertIcon from '@mui/icons-material/MoreVert'; | ||||||
|  | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | ||||||
|  | import EditIcon from '@mui/icons-material/Edit'; | ||||||
|  | import DeleteIcon from '@mui/icons-material/Delete'; | ||||||
|  | import FilterListIcon from '@mui/icons-material/FilterList'; | ||||||
|  |  | ||||||
|  | import novedadCanillaService from '../../services/Distribucion/novedadCanillaService'; | ||||||
|  | import canillaService from '../../services/Distribucion/canillaService'; | ||||||
|  | import type { NovedadCanillaDto } from '../../models/dtos/Distribucion/NovedadCanillaDto'; | ||||||
|  | import type { CreateNovedadCanillaDto } from '../../models/dtos/Distribucion/CreateNovedadCanillaDto'; | ||||||
|  | import type { UpdateNovedadCanillaDto } from '../../models/dtos/Distribucion/UpdateNovedadCanillaDto'; | ||||||
|  | import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto'; | ||||||
|  | import NovedadCanillaFormModal from '../../components/Modals/Distribucion/NovedadCanillaFormModal'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
|  | import axios from 'axios'; | ||||||
|  |  | ||||||
|  | const GestionarNovedadesCanillaPage: React.FC = () => { | ||||||
|  |   const { idCanilla: idCanillaStr } = useParams<{ idCanilla: string }>(); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const idCanilla = Number(idCanillaStr); | ||||||
|  |  | ||||||
|  |   const [canillita, setCanillita] = useState<CanillaDto | null>(null); | ||||||
|  |   const [novedades, setNovedades] = useState<NovedadCanillaDto[]>([]); | ||||||
|  |   const [loading, setLoading] = useState(true); | ||||||
|  |   const [errorPage, setErrorPage] = useState<string | null>(null); // Error general de la página | ||||||
|  |  | ||||||
|  |   const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(''); | ||||||
|  |   const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(''); | ||||||
|  |  | ||||||
|  |   const [modalOpen, setModalOpen] = useState(false); | ||||||
|  |   const [editingNovedad, setEditingNovedad] = useState<NovedadCanillaDto | null>(null); | ||||||
|  |   const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); // Para modal/delete | ||||||
|  |  | ||||||
|  |   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||||
|  |   const [selectedNovedadRow, setSelectedNovedadRow] = useState<NovedadCanillaDto | null>(null); | ||||||
|  |  | ||||||
|  |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |   const puedeGestionarNovedades = isSuperAdmin || tienePermiso("CG006"); | ||||||
|  |   const puedeVerCanillitas = isSuperAdmin || tienePermiso("CG001"); | ||||||
|  |  | ||||||
|  |   // Cargar datos del canillita (solo una vez o si idCanilla cambia) | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (isNaN(idCanilla)) { | ||||||
|  |       setErrorPage("ID de Canillita inválido."); | ||||||
|  |       setLoading(false); // Detener carga principal | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     if (!puedeVerCanillitas && !puedeGestionarNovedades) { | ||||||
|  |         setErrorPage("No tiene permiso para acceder a esta sección."); | ||||||
|  |         setLoading(false); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setLoading(true); // Iniciar carga para datos del canillita | ||||||
|  |     const fetchCanillita = async () => { | ||||||
|  |       try { | ||||||
|  |         if (puedeVerCanillitas) { | ||||||
|  |             const canData = await canillaService.getCanillaById(idCanilla); | ||||||
|  |             setCanillita(canData); | ||||||
|  |         } else { | ||||||
|  |             // Si no puede ver detalles del canillita pero sí novedades, al menos mostrar ID | ||||||
|  |             setCanillita({ idCanilla, nomApe: `ID ${idCanilla}` } as CanillaDto); | ||||||
|  |         } | ||||||
|  |       } catch (err) { | ||||||
|  |         console.error("Error cargando datos del canillita:", err); | ||||||
|  |         setErrorPage(`Error al cargar datos del canillita (ID: ${idCanilla}).`); | ||||||
|  |       } | ||||||
|  |       // No ponemos setLoading(false) aquí, porque la carga de novedades sigue. | ||||||
|  |     }; | ||||||
|  |     fetchCanillita(); | ||||||
|  |   }, [idCanilla, puedeVerCanillitas, puedeGestionarNovedades]); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   // Cargar/filtrar novedades | ||||||
|  |   const cargarNovedades = useCallback(async () => { | ||||||
|  |     if (isNaN(idCanilla) || (!puedeGestionarNovedades && !puedeVerCanillitas)) { | ||||||
|  |         // Los permisos ya se validaron en el useEffect anterior, pero es bueno tenerlo | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |     // Si ya está cargando los datos del canillita, no iniciar otra carga paralela | ||||||
|  |     // Se usará el mismo 'loading' para ambas operaciones iniciales. | ||||||
|  |     // if (!loading) setLoading(true); // No es necesario si el useEffect anterior ya lo hizo | ||||||
|  |  | ||||||
|  |     setApiErrorMessage(null); // Limpiar errores de API de acciones previas | ||||||
|  |     // setErrorPage(null); // No limpiar error de página aquí, podría ser por el canillita | ||||||
|  |      | ||||||
|  |     try { | ||||||
|  |       const params = { | ||||||
|  |         fechaDesde: filtroFechaDesde || null, | ||||||
|  |         fechaHasta: filtroFechaHasta || null, | ||||||
|  |       }; | ||||||
|  |       const dataNovedades = await novedadCanillaService.getNovedadesPorCanilla(idCanilla, params); | ||||||
|  |       setNovedades(dataNovedades); | ||||||
|  |       // Si no hay datos con filtros, no es un error de API, simplemente no hay datos. | ||||||
|  |       // El mensaje de "no hay novedades" se maneja en la tabla. | ||||||
|  |     } catch (err: any) { | ||||||
|  |       console.error("Error al cargar/filtrar novedades:", err); | ||||||
|  |       const message = axios.isAxiosError(err) && err.response?.data?.message  | ||||||
|  |                         ? err.response.data.message  | ||||||
|  |                         : 'Error al cargar las novedades.'; | ||||||
|  |       setErrorPage(message); // Usar el error de página para problemas de carga de novedades | ||||||
|  |       setNovedades([]); // Limpiar en caso de error | ||||||
|  |     } finally { | ||||||
|  |       // Solo poner setLoading(false) después de que AMBAS cargas (canillita y novedades) se intenten. | ||||||
|  |       // Como se llaman en secuencia implícita por los useEffect, el último setLoading(false) es el de novedades. | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }, [idCanilla, puedeGestionarNovedades, puedeVerCanillitas, filtroFechaDesde, filtroFechaHasta]); | ||||||
|  |  | ||||||
|  |   // useEffect para cargar novedades cuando los filtros o el canillita (o permisos) cambian | ||||||
|  |   useEffect(() => { | ||||||
|  |     // Solo cargar si tenemos un idCanilla válido y permisos | ||||||
|  |     if (!isNaN(idCanilla) && (puedeGestionarNovedades || puedeVerCanillitas)) { | ||||||
|  |         cargarNovedades(); | ||||||
|  |     } else if (isNaN(idCanilla)){ | ||||||
|  |         setErrorPage("ID de Canillita inválido."); | ||||||
|  |         setLoading(false); | ||||||
|  |     } else if (!puedeGestionarNovedades && !puedeVerCanillitas) { | ||||||
|  |          setErrorPage("No tiene permiso para acceder a esta sección."); | ||||||
|  |          setLoading(false); | ||||||
|  |     } | ||||||
|  |   }, [idCanilla, cargarNovedades, puedeGestionarNovedades, puedeVerCanillitas]); // `cargarNovedades` ya tiene sus dependencias | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   const handleOpenModal = (item?: NovedadCanillaDto) => { | ||||||
|  |     if (!puedeGestionarNovedades) { | ||||||
|  |       setApiErrorMessage("No tiene permiso para agregar o editar novedades."); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     setEditingNovedad(item || null); setApiErrorMessage(null); setModalOpen(true); | ||||||
|  |   }; | ||||||
|  |   const handleCloseModal = () => { | ||||||
|  |     setModalOpen(false); setEditingNovedad(null); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSubmitModal = async (data: CreateNovedadCanillaDto | UpdateNovedadCanillaDto, idNovedad?: number) => { | ||||||
|  |     if (!puedeGestionarNovedades) return; | ||||||
|  |     setApiErrorMessage(null); | ||||||
|  |     try { | ||||||
|  |       if (editingNovedad && idNovedad) { | ||||||
|  |         await novedadCanillaService.updateNovedad(idNovedad, data as UpdateNovedadCanillaDto); | ||||||
|  |       } else { | ||||||
|  |         await novedadCanillaService.createNovedad(data as CreateNovedadCanillaDto); | ||||||
|  |       } | ||||||
|  |       cargarNovedades(); // Recargar lista de novedades | ||||||
|  |     } catch (err: any) { | ||||||
|  |       const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar la novedad.'; | ||||||
|  |       setApiErrorMessage(message); throw err; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleDelete = async (idNovedadDelRow: number) => { | ||||||
|  |     if (!puedeGestionarNovedades) return; | ||||||
|  |     if (window.confirm(`¿Seguro de eliminar esta novedad (ID: ${idNovedadDelRow})?`)) { | ||||||
|  |        setApiErrorMessage(null); | ||||||
|  |        try { | ||||||
|  |         await novedadCanillaService.deleteNovedad(idNovedadDelRow); | ||||||
|  |         cargarNovedades(); // Recargar lista de novedades | ||||||
|  |       } catch (err: any) { | ||||||
|  |          const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar la novedad.'; | ||||||
|  |          setApiErrorMessage(message); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     handleMenuClose(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, item: NovedadCanillaDto) => { | ||||||
|  |     setAnchorEl(event.currentTarget); setSelectedNovedadRow(item); | ||||||
|  |   }; | ||||||
|  |   const handleMenuClose = () => { | ||||||
|  |     setAnchorEl(null); setSelectedNovedadRow(null); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const formatDate = (dateString?: string | null) => dateString ? new Date(dateString).toLocaleDateString('es-AR', {timeZone: 'UTC'}) : '-'; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   if (loading && !canillita) { // Muestra cargando solo si aún no tenemos los datos del canillita | ||||||
|  |     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (errorPage && !canillita) { // Si hay un error al cargar el canillita, no mostrar nada más | ||||||
|  |     return <Alert severity="error" sx={{ m: 2 }}>{errorPage}</Alert>; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   // Si no tiene permiso para la sección en general | ||||||
|  |   if (!puedeGestionarNovedades && !puedeVerCanillitas) { | ||||||
|  |       return <Alert severity="error" sx={{ m: 2 }}>Acceso denegado.</Alert>; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box sx={{ p: 2 }}> | ||||||
|  |         <Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`/distribucion/canillas`)} sx={{ mb: 2 }}> | ||||||
|  |             Volver a Canillitas | ||||||
|  |         </Button> | ||||||
|  |       <Typography variant="h5" gutterBottom> | ||||||
|  |         Novedades de: {canillita?.nomApe || `Canillita ID ${idCanilla}`} | ||||||
|  |       </Typography> | ||||||
|  |  | ||||||
|  |       <Paper sx={{ p: 2, mb: 2 }}> | ||||||
|  |         <Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2}}> | ||||||
|  |             {puedeGestionarNovedades && ( | ||||||
|  |                 <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: {xs: 2, sm:0} }}> | ||||||
|  |                     Agregar Novedad | ||||||
|  |                 </Button> | ||||||
|  |             )} | ||||||
|  |              <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}> | ||||||
|  |                  <FilterListIcon sx={{color: 'action.active', alignSelf:'center'}} /> | ||||||
|  |                 <TextField label="Fecha Desde" type="date" size="small" value={filtroFechaDesde} | ||||||
|  |                     onChange={(e) => setFiltroFechaDesde(e.target.value)} InputLabelProps={{ shrink: true }} | ||||||
|  |                     disabled={loading} // Deshabilitar durante cualquier carga | ||||||
|  |                 /> | ||||||
|  |                 <TextField label="Fecha Hasta" type="date" size="small" value={filtroFechaHasta} | ||||||
|  |                     onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} | ||||||
|  |                     disabled={loading} // Deshabilitar durante cualquier carga | ||||||
|  |                 /> | ||||||
|  |             </Box> | ||||||
|  |         </Box> | ||||||
|  |       </Paper> | ||||||
|  |  | ||||||
|  |       {/* Mostrar error de API (de submit/delete) o error de carga de novedades */} | ||||||
|  |       {(apiErrorMessage || (errorPage && novedades.length === 0 && !loading)) && ( | ||||||
|  |         <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage || errorPage}</Alert> | ||||||
|  |       )} | ||||||
|  |        | ||||||
|  |       {loading && <Box sx={{display:'flex', justifyContent:'center', my:2}}><CircularProgress size={30} /></Box>} | ||||||
|  |  | ||||||
|  |       <TableContainer component={Paper}> | ||||||
|  |         <Table size="small"> | ||||||
|  |           <TableHead><TableRow> | ||||||
|  |             <TableCell sx={{ fontWeight: 'bold' }}>Fecha</TableCell> | ||||||
|  |             <TableCell sx={{ fontWeight: 'bold', width: '70%' }}>Detalle de Novedad</TableCell> | ||||||
|  |             {puedeGestionarNovedades && <TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell>} | ||||||
|  |           </TableRow></TableHead> | ||||||
|  |           <TableBody> | ||||||
|  |             {novedades.length === 0 && !loading ? ( | ||||||
|  |               <TableRow><TableCell colSpan={puedeGestionarNovedades ? 3 : 2} align="center"> | ||||||
|  |                 No hay novedades registradas { (filtroFechaDesde || filtroFechaHasta) && "con los filtros aplicados"}. | ||||||
|  |                 </TableCell></TableRow> | ||||||
|  |             ) : ( | ||||||
|  |               novedades.map((nov) => ( | ||||||
|  |                 <TableRow key={nov.idNovedad} hover> | ||||||
|  |                   <TableCell>{formatDate(nov.fecha)}</TableCell> | ||||||
|  |                   <TableCell> | ||||||
|  |                     <Tooltip title={nov.detalle || ''} arrow> | ||||||
|  |                         <Typography variant="body2" sx={{  | ||||||
|  |                             whiteSpace: 'nowrap',  | ||||||
|  |                             overflow: 'hidden',  | ||||||
|  |                             textOverflow: 'ellipsis', | ||||||
|  |                             maxWidth: '500px'  | ||||||
|  |                         }}> | ||||||
|  |                             {nov.detalle || '-'} | ||||||
|  |                         </Typography> | ||||||
|  |                     </Tooltip> | ||||||
|  |                   </TableCell> | ||||||
|  |                   {puedeGestionarNovedades && ( | ||||||
|  |                     <TableCell align="right"> | ||||||
|  |                         <IconButton onClick={(e) => handleMenuOpen(e, nov)} disabled={!puedeGestionarNovedades}> | ||||||
|  |                           <MoreVertIcon /> | ||||||
|  |                         </IconButton> | ||||||
|  |                     </TableCell> | ||||||
|  |                   )} | ||||||
|  |                 </TableRow> | ||||||
|  |               )))} | ||||||
|  |           </TableBody> | ||||||
|  |         </Table> | ||||||
|  |       </TableContainer> | ||||||
|  |  | ||||||
|  |       <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> | ||||||
|  |         {puedeGestionarNovedades && selectedNovedadRow && ( | ||||||
|  |             <MenuItem onClick={() => { handleOpenModal(selectedNovedadRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{mr:1}}/> Editar</MenuItem>)} | ||||||
|  |         {puedeGestionarNovedades && selectedNovedadRow && ( | ||||||
|  |             <MenuItem onClick={() => handleDelete(selectedNovedadRow.idNovedad)}><DeleteIcon fontSize="small" sx={{mr:1}}/> Eliminar</MenuItem>)} | ||||||
|  |       </Menu> | ||||||
|  |  | ||||||
|  |       {idCanilla && | ||||||
|  |         <NovedadCanillaFormModal | ||||||
|  |             open={modalOpen} | ||||||
|  |             onClose={handleCloseModal} | ||||||
|  |             onSubmit={handleSubmitModal} | ||||||
|  |             idCanilla={idCanilla} | ||||||
|  |             nombreCanilla={canillita?.nomApe} | ||||||
|  |             initialData={editingNovedad} | ||||||
|  |             errorMessage={apiErrorMessage} | ||||||
|  |             clearErrorMessage={() => setApiErrorMessage(null)} | ||||||
|  |         /> | ||||||
|  |       } | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default GestionarNovedadesCanillaPage; | ||||||
| @@ -37,15 +37,16 @@ const GestionarOtrosDestinosPage: React.FC = () => { | |||||||
|   const { tienePermiso, isSuperAdmin } = usePermissions(); |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |  | ||||||
|   // Permisos para Otros Destinos (OD001 a OD004) - Revisa tus códigos de permiso |   // Permisos para Otros Destinos (OD001 a OD004) - Revisa tus códigos de permiso | ||||||
|   const puedeVer = isSuperAdmin || tienePermiso("OD001"); // Asumiendo OD001 es ver entidad |   const puedeVer = isSuperAdmin || tienePermiso("OD001"); | ||||||
|   const puedeCrear = isSuperAdmin || tienePermiso("OD002"); |   const puedeCrear = isSuperAdmin || tienePermiso("OD002"); | ||||||
|   const puedeModificar = isSuperAdmin || tienePermiso("OD003"); |   const puedeModificar = isSuperAdmin || tienePermiso("OD003"); | ||||||
|   const puedeEliminar = isSuperAdmin || tienePermiso("OD004"); |   const puedeEliminar = isSuperAdmin || tienePermiso("OD004"); | ||||||
|  |  | ||||||
|   const cargarOtrosDestinos = useCallback(async () => { |   const cargarOtrosDestinos = useCallback(async () => { | ||||||
|     if (!puedeVer) { |     if (!puedeVer) { | ||||||
|       setError("No tiene permiso para ver esta sección."); |       setError("No tiene permiso para ver los 'Otros Destinos'."); | ||||||
|       setLoading(false); |       setLoading(false); | ||||||
|  |       setOtrosDestinos([]); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     setLoading(true); |     setLoading(true); | ||||||
| @@ -131,8 +132,8 @@ const GestionarOtrosDestinosPage: React.FC = () => { | |||||||
|  |  | ||||||
|   if (!loading && !puedeVer) { |   if (!loading && !puedeVer) { | ||||||
|     return ( |     return ( | ||||||
|       <Box sx={{ p: 2 }}> |       <Box sx={{ p: 1 }}> | ||||||
|         <Typography variant="h4" gutterBottom>Gestionar Otros Destinos</Typography> |         <Typography variant="h5" gutterBottom>Gestionar Otros Destinos</Typography> {/* Cambiado h4 a h5 */} | ||||||
|         <Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert> |         <Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert> | ||||||
|       </Box> |       </Box> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -241,7 +241,7 @@ const GestionarPublicacionesPage: React.FC = () => { | |||||||
|           </FormControl> |           </FormControl> | ||||||
|           <FormControlLabel |           <FormControlLabel | ||||||
|             control={<Switch checked={filtroSoloHabilitadas === undefined ? true : filtroSoloHabilitadas} onChange={(e) => setFiltroSoloHabilitadas(e.target.checked)} size="small" />} |             control={<Switch checked={filtroSoloHabilitadas === undefined ? true : filtroSoloHabilitadas} onChange={(e) => setFiltroSoloHabilitadas(e.target.checked)} size="small" />} | ||||||
|             label="Solo Habilitadas" |             label="Ver Habilitadas" | ||||||
|           /> |           /> | ||||||
|         </Box> |         </Box> | ||||||
|         {puedeCrear && (<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: 2 }}>Agregar Publicación</Button>)} |         {puedeCrear && (<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: 2 }}>Agregar Publicación</Button>)} | ||||||
|   | |||||||
| @@ -37,13 +37,19 @@ const GestionarZonasPage: React.FC = () => { | |||||||
|  |  | ||||||
|   const { tienePermiso, isSuperAdmin } = usePermissions(); |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |  | ||||||
|   // Ajustar códigos de permiso para Zonas |   const puedeVer = isSuperAdmin || tienePermiso("ZD001"); // Permiso para ver Zonas | ||||||
|   const puedeCrear = isSuperAdmin || tienePermiso("ZD002"); |   const puedeCrear = isSuperAdmin || tienePermiso("ZD002"); | ||||||
|   const puedeModificar = isSuperAdmin || tienePermiso("ZD003"); |   const puedeModificar = isSuperAdmin || tienePermiso("ZD003"); | ||||||
|   const puedeEliminar = isSuperAdmin || tienePermiso("ZD004"); |   const puedeEliminar = isSuperAdmin || tienePermiso("ZD004"); | ||||||
|  |  | ||||||
|  |  | ||||||
|   const cargarZonas = useCallback(async () => { |   const cargarZonas = useCallback(async () => { | ||||||
|  |     if (!puedeVer) { | ||||||
|  |       setError("No tiene permiso para ver las zonas."); | ||||||
|  |       setLoading(false); | ||||||
|  |       setZonas([]); // Asegurar que no se muestren datos previos | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     setError(null); |     setError(null); | ||||||
|     try { |     try { | ||||||
| @@ -134,6 +140,17 @@ const GestionarZonasPage: React.FC = () => { | |||||||
|   // Adaptar para paginación |   // Adaptar para paginación | ||||||
|   const displayData = zonas.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); |   const displayData = zonas.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); | ||||||
|  |  | ||||||
|  |   if (!loading && !puedeVer) { | ||||||
|  |     return ( | ||||||
|  |       <Box sx={{ p: 1 }}> | ||||||
|  |         <Typography variant="h5" gutterBottom> | ||||||
|  |           Gestionar Zonas | ||||||
|  |         </Typography> | ||||||
|  |         {/* El error de "sin permiso" ya fue seteado en cargarZonas */} | ||||||
|  |         <Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert> | ||||||
|  |       </Box> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Box sx={{ p: 1 }}> |     <Box sx={{ p: 1 }}> | ||||||
| @@ -150,7 +167,6 @@ const GestionarZonasPage: React.FC = () => { | |||||||
|             value={filtroNombre} |             value={filtroNombre} | ||||||
|             onChange={(e) => setFiltroNombre(e.target.value)} |             onChange={(e) => setFiltroNombre(e.target.value)} | ||||||
|           /> |           /> | ||||||
|           {/* <TextField label="Filtrar por Descripción" ... /> */} |  | ||||||
|         </Box> |         </Box> | ||||||
|         {puedeCrear && ( |         {puedeCrear && ( | ||||||
|             <Button |             <Button | ||||||
| @@ -165,11 +181,11 @@ const GestionarZonasPage: React.FC = () => { | |||||||
|       </Paper> |       </Paper> | ||||||
|  |  | ||||||
|       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} |       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} | ||||||
|       {error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} |       {error && !loading && puedeVer && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} | ||||||
|       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} |       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} | ||||||
|  |  | ||||||
|  |  | ||||||
|       {!loading && !error && ( |       {!loading && !error && puedeVer && ( | ||||||
|         <TableContainer component={Paper}> |         <TableContainer component={Paper}> | ||||||
|           <Table> |           <Table> | ||||||
|             <TableHead> |             <TableHead> | ||||||
|   | |||||||
| @@ -4,12 +4,12 @@ import { Box, Typography, Paper, CircularProgress, Alert, Button, Divider, type | |||||||
| import reportesService from '../../services/Reportes/reportesService'; | import reportesService from '../../services/Reportes/reportesService'; | ||||||
| import type { ControlDevolucionesDataResponseDto } from '../../models/dtos/Reportes/ControlDevolucionesDataResponseDto'; | import type { ControlDevolucionesDataResponseDto } from '../../models/dtos/Reportes/ControlDevolucionesDataResponseDto'; | ||||||
| import SeleccionaReporteControlDevoluciones from './SeleccionaReporteControlDevoluciones'; | import SeleccionaReporteControlDevoluciones from './SeleccionaReporteControlDevoluciones'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
| import * as XLSX from 'xlsx'; | import * as XLSX from 'xlsx'; | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
|  |  | ||||||
|  |  | ||||||
| const ReporteControlDevolucionesPage: React.FC = () => { | const ReporteControlDevolucionesPage: React.FC = () => { | ||||||
|   // ... (estados y funciones de manejo de datos sin cambios significativos, excepto cómo se renderiza) ... |  | ||||||
|   const [reportData, setReportData] = useState<ControlDevolucionesDataResponseDto | null>(null); |   const [reportData, setReportData] = useState<ControlDevolucionesDataResponseDto | null>(null); | ||||||
|   const [loading, setLoading] = useState(false); |   const [loading, setLoading] = useState(false); | ||||||
|   const [loadingPdf, setLoadingPdf] = useState(false); |   const [loadingPdf, setLoadingPdf] = useState(false); | ||||||
| @@ -17,6 +17,8 @@ const ReporteControlDevolucionesPage: React.FC = () => { | |||||||
|   const [apiErrorParams, setApiErrorParams] = useState<string | null>(null); |   const [apiErrorParams, setApiErrorParams] = useState<string | null>(null); | ||||||
|   const [showParamSelector, setShowParamSelector] = useState(true); |   const [showParamSelector, setShowParamSelector] = useState(true); | ||||||
|   const [currentParams, setCurrentParams] = useState<{ fecha: string; idEmpresa: number; nombreEmpresa?: string } | null>(null); |   const [currentParams, setCurrentParams] = useState<{ fecha: string; idEmpresa: number; nombreEmpresa?: string } | null>(null); | ||||||
|  |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |   const puedeVerReporte = isSuperAdmin || tienePermiso("RR003"); | ||||||
|  |  | ||||||
|   const numberLocaleFormatter = (value: number | null | undefined, showSign = false): string => { |   const numberLocaleFormatter = (value: number | null | undefined, showSign = false): string => { | ||||||
|     if (value == null) return ''; |     if (value == null) return ''; | ||||||
| @@ -26,6 +28,11 @@ const ReporteControlDevolucionesPage: React.FC = () => { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleGenerarReporte = useCallback(async (params: { fecha: string; idEmpresa: number }) => { |   const handleGenerarReporte = useCallback(async (params: { fecha: string; idEmpresa: number }) => { | ||||||
|  |     if (!puedeVerReporte) { | ||||||
|  |       setError("No tiene permiso para generar este reporte."); | ||||||
|  |       setLoading(false); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     setError(null); |     setError(null); | ||||||
|     setApiErrorParams(null); |     setApiErrorParams(null); | ||||||
| @@ -306,6 +313,9 @@ const ReporteControlDevolucionesPage: React.FC = () => { | |||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   if (showParamSelector) { |   if (showParamSelector) { | ||||||
|  |     if (!loading && !puedeVerReporte) { | ||||||
|  |       return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>; | ||||||
|  |     } | ||||||
|     return ( |     return ( | ||||||
|       <Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}> |       <Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}> | ||||||
|         <Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}> |         <Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}> | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import React, { useState, useCallback } from 'react'; | import React, { useState, useCallback } from 'react'; | ||||||
| import { | import { | ||||||
|   Box, Typography, Paper, CircularProgress, Button |   Box, Typography, Paper, CircularProgress, Button, | ||||||
|  |   Alert | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import { DataGrid, type GridColDef, GridFooterContainer, GridFooter } from '@mui/x-data-grid'; | import { DataGrid, type GridColDef, GridFooterContainer, GridFooter } from '@mui/x-data-grid'; | ||||||
| import { esES } from '@mui/x-data-grid/locales'; | import { esES } from '@mui/x-data-grid/locales'; | ||||||
| @@ -9,6 +10,7 @@ import type { ReporteCuentasDistribuidorResponseDto } from '../../models/dtos/Re | |||||||
| import type { BalanceCuentaDistDto } from '../../models/dtos/Reportes/BalanceCuentaDistDto'; | import type { BalanceCuentaDistDto } from '../../models/dtos/Reportes/BalanceCuentaDistDto'; | ||||||
| import type { BalanceCuentaDebCredDto } from '../../models/dtos/Reportes/BalanceCuentaDebCredDto'; | import type { BalanceCuentaDebCredDto } from '../../models/dtos/Reportes/BalanceCuentaDebCredDto'; | ||||||
| import type { BalanceCuentaPagosDto } from '../../models/dtos/Reportes/BalanceCuentaPagosDto'; | import type { BalanceCuentaPagosDto } from '../../models/dtos/Reportes/BalanceCuentaPagosDto'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
| import SeleccionaReporteCuentasDistribuidores from './SeleccionaReporteCuentasDistribuidores'; | import SeleccionaReporteCuentasDistribuidores from './SeleccionaReporteCuentasDistribuidores'; | ||||||
| import * as XLSX from 'xlsx'; | import * as XLSX from 'xlsx'; | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| @@ -20,6 +22,7 @@ type PagoConSaldo = BalanceCuentaPagosDto & { id: string; saldoAcumulado: number | |||||||
| const ReporteCuentasDistribuidoresPage: React.FC = () => { | const ReporteCuentasDistribuidoresPage: React.FC = () => { | ||||||
|   const [originalReportData, setOriginalReportData] = useState<ReporteCuentasDistribuidorResponseDto | null>(null); |   const [originalReportData, setOriginalReportData] = useState<ReporteCuentasDistribuidorResponseDto | null>(null); | ||||||
|   const [movimientosConSaldo, setMovimientosConSaldo] = useState<MovimientoConSaldo[]>([]); |   const [movimientosConSaldo, setMovimientosConSaldo] = useState<MovimientoConSaldo[]>([]); | ||||||
|  |   const [error, setError] = useState<string | null>(null); | ||||||
|   const [notasConSaldo, setNotasConSaldo] = useState<NotaConSaldo[]>([]); |   const [notasConSaldo, setNotasConSaldo] = useState<NotaConSaldo[]>([]); | ||||||
|   const [pagosConSaldo, setPagosConSaldo] = useState<PagoConSaldo[]>([]); |   const [pagosConSaldo, setPagosConSaldo] = useState<PagoConSaldo[]>([]); | ||||||
|   const [loading, setLoading] = useState<boolean>(false); |   const [loading, setLoading] = useState<boolean>(false); | ||||||
| @@ -34,6 +37,8 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { | |||||||
|     nombreDistribuidor?: string; |     nombreDistribuidor?: string; | ||||||
|     nombreEmpresa?: string; |     nombreEmpresa?: string; | ||||||
|   } | null>(null); |   } | null>(null); | ||||||
|  |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |   const puedeVerReporte = isSuperAdmin || tienePermiso("RR001"); | ||||||
|  |  | ||||||
|   // Calcula saldos acumulados seccion por seccion |   // Calcula saldos acumulados seccion por seccion | ||||||
|   const calcularSaldosPorSeccion = (data: ReporteCuentasDistribuidorResponseDto) => { |   const calcularSaldosPorSeccion = (data: ReporteCuentasDistribuidorResponseDto) => { | ||||||
| @@ -227,6 +232,12 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { | |||||||
|     fechaDesde: string; |     fechaDesde: string; | ||||||
|     fechaHasta: string; |     fechaHasta: string; | ||||||
|   }) => { |   }) => { | ||||||
|  |     if (!puedeVerReporte) { | ||||||
|  |       setError("No tiene permiso para generar este reporte."); | ||||||
|  |       setLoading(false); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     setError(null); | ||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     setApiErrorParams(null); |     setApiErrorParams(null); | ||||||
|     setOriginalReportData(null); |     setOriginalReportData(null); | ||||||
| @@ -237,8 +248,8 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { | |||||||
|     const distSvc = (await import('../../services/Distribucion/distribuidorService')).default; |     const distSvc = (await import('../../services/Distribucion/distribuidorService')).default; | ||||||
|     const empSvc = (await import('../../services/Distribucion/empresaService')).default; |     const empSvc = (await import('../../services/Distribucion/empresaService')).default; | ||||||
|     const [distData, empData] = await Promise.all([ |     const [distData, empData] = await Promise.all([ | ||||||
|       distSvc.getDistribuidorById(params.idDistribuidor), |       distSvc.getDistribuidorLookupById(params.idDistribuidor), | ||||||
|       empSvc.getEmpresaById(params.idEmpresa) |       empSvc.getEmpresaLookupById(params.idEmpresa) | ||||||
|     ]); |     ]); | ||||||
|     setCurrentParams({ |     setCurrentParams({ | ||||||
|       ...params, |       ...params, | ||||||
| @@ -273,20 +284,39 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { | |||||||
|   }, []); |   }, []); | ||||||
|  |  | ||||||
|   const handleExportToExcel = useCallback(() => { |   const handleExportToExcel = useCallback(() => { | ||||||
|     if (!originalReportData) return; |     if ( | ||||||
|     const wb = XLSX.utils.book_new(); |         !originalReportData || | ||||||
|     if (movimientosConSaldo.length) { |         (movimientosConSaldo.length === 0 && | ||||||
|  |          notasConSaldo.length === 0 && | ||||||
|  |          pagosConSaldo.length === 0) | ||||||
|  |     ) { | ||||||
|  |         alert("No hay datos para exportar."); // O un mensaje más amigable | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const wb = XLSX.utils.book_new();// Se crea un nuevo libro | ||||||
|  |  | ||||||
|  |     // Movimientos | ||||||
|  |     if (movimientosConSaldo.length) { // <--- CHEQUEO 1 | ||||||
|  |       // Si movimientosConSaldo está vacío, esta hoja no se añade | ||||||
|       const ws = XLSX.utils.json_to_sheet(movimientosConSaldo.map(({ id, ...rest }) => rest)); |       const ws = XLSX.utils.json_to_sheet(movimientosConSaldo.map(({ id, ...rest }) => rest)); | ||||||
|       XLSX.utils.book_append_sheet(wb, ws, 'Movimientos'); |       XLSX.utils.book_append_sheet(wb, ws, 'Movimientos'); | ||||||
|     } |     } | ||||||
|     if (notasConSaldo.length) { |     // Notas | ||||||
|  |     if (notasConSaldo.length) { // <--- CHEQUEO 2 | ||||||
|  |       // Si notasConSaldo está vacío, esta hoja no se añade | ||||||
|       const ws = XLSX.utils.json_to_sheet(notasConSaldo.map(({ id, ...rest }) => rest)); |       const ws = XLSX.utils.json_to_sheet(notasConSaldo.map(({ id, ...rest }) => rest)); | ||||||
|       XLSX.utils.book_append_sheet(wb, ws, 'Notas'); |       XLSX.utils.book_append_sheet(wb, ws, 'Notas'); | ||||||
|     } |     } | ||||||
|     if (pagosConSaldo.length) { |     // Pagos | ||||||
|  |     if (pagosConSaldo.length) { // <--- CHEQUEO 3 | ||||||
|  |       // Si pagosConSaldo está vacío, esta hoja no se añade | ||||||
|       const ws = XLSX.utils.json_to_sheet(pagosConSaldo.map(({ id, ...rest }) => rest)); |       const ws = XLSX.utils.json_to_sheet(pagosConSaldo.map(({ id, ...rest }) => rest)); | ||||||
|       XLSX.utils.book_append_sheet(wb, ws, 'Pagos'); |       XLSX.utils.book_append_sheet(wb, ws, 'Pagos'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Si ninguno de los arrays tiene datos, el libro 'wb' quedará vacío. | ||||||
|  |     // Y la siguiente línea dará el error: | ||||||
|     XLSX.writeFile(wb, `Reporte_${Date.now()}.xlsx`); |     XLSX.writeFile(wb, `Reporte_${Date.now()}.xlsx`); | ||||||
|   }, [originalReportData, movimientosConSaldo, notasConSaldo, pagosConSaldo]); |   }, [originalReportData, movimientosConSaldo, notasConSaldo, pagosConSaldo]); | ||||||
|  |  | ||||||
| @@ -297,13 +327,16 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { | |||||||
|       const blob = await reportesService.getReporteCuentasDistribuidorPdf(currentParams); |       const blob = await reportesService.getReporteCuentasDistribuidorPdf(currentParams); | ||||||
|       window.open(URL.createObjectURL(blob), '_blank'); |       window.open(URL.createObjectURL(blob), '_blank'); | ||||||
|     } catch { |     } catch { | ||||||
|       /* manejar error */ |       setError('Ocurrió un error al generar el PDF.'); | ||||||
|     } finally { |     } finally { | ||||||
|       setLoadingPdf(false); |       setLoadingPdf(false); | ||||||
|     } |     } | ||||||
|   }, [currentParams]); |   }, [currentParams]); | ||||||
|  |  | ||||||
|   if (showParamSelector) { |   if (showParamSelector) { | ||||||
|  |     if (!loading && !puedeVerReporte) { | ||||||
|  |       return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>; | ||||||
|  |     } | ||||||
|     return ( |     return ( | ||||||
|       <Box sx={{ p: 2, display: 'flex', justifyContent: 'center' }}> |       <Box sx={{ p: 2, display: 'flex', justifyContent: 'center' }}> | ||||||
|         <Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}> |         <Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}> | ||||||
| @@ -328,6 +361,11 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { | |||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Box sx={{ p: 2 }}> |     <Box sx={{ p: 2 }}> | ||||||
|  |       {error && ( | ||||||
|  |         <Paper sx={{ mb: 2, p: 2, backgroundColor: '#ffeaea' }}> | ||||||
|  |           <Typography color="error">{error}</Typography> | ||||||
|  |         </Paper> | ||||||
|  |       )} | ||||||
|       <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> |       <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | ||||||
|         <Typography variant="h5">Cuenta Corriente Distribuidor</Typography> |         <Typography variant="h5">Cuenta Corriente Distribuidor</Typography> | ||||||
|         <Box sx={{ display: 'flex', gap: 1 }}> |         <Box sx={{ display: 'flex', gap: 1 }}> | ||||||
|   | |||||||
							
								
								
									
										404
									
								
								Frontend/src/pages/Reportes/ReporteListadoDistMensualPage.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										404
									
								
								Frontend/src/pages/Reportes/ReporteListadoDistMensualPage.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,404 @@ | |||||||
|  | // src/pages/Reportes/ReporteListadoDistMensualPage.tsx | ||||||
|  | import React, { useState, useCallback, useMemo } from 'react'; | ||||||
|  | import { | ||||||
|  |   Box, Typography, Paper, CircularProgress, Alert, Button, | ||||||
|  | } from '@mui/material'; | ||||||
|  | import { DataGrid, type GridColDef, GridFooterContainer, GridFooter } from '@mui/x-data-grid'; | ||||||
|  | import { esES } from '@mui/x-data-grid/locales'; | ||||||
|  | import reportesService from '../../services/Reportes/reportesService'; | ||||||
|  | import type { ListadoDistCanMensualDiariosDto } from '../../models/dtos/Reportes/ListadoDistCanMensualDiariosDto'; | ||||||
|  | import type { ListadoDistCanMensualPubDto } from '../../models/dtos/Reportes/ListadoDistCanMensualPubDto'; | ||||||
|  | import SeleccionaReporteListadoDistMensual, { type TipoListadoDistMensual } from './SeleccionaReporteListadoDistMensual'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
|  | import * as XLSX from 'xlsx'; | ||||||
|  | import axios from 'axios'; | ||||||
|  |  | ||||||
|  | // Interfaces para DataGrid (añadiendo 'id') | ||||||
|  | interface GridDiariosItem extends ListadoDistCanMensualDiariosDto { id: string; } | ||||||
|  | interface GridPubItem extends ListadoDistCanMensualPubDto { id: string; } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | const ReporteListadoDistMensualPage: React.FC = () => { | ||||||
|  |   const [reporteDiariosData, setReporteDiariosData] = useState<GridDiariosItem[]>([]); | ||||||
|  |   const [reportePubData, setReportePubData] = useState<GridPubItem[]>([]); | ||||||
|  |   const [currentReportVariant, setCurrentReportVariant] = useState<TipoListadoDistMensual | null>(null); | ||||||
|  |  | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [loadingPdf, setLoadingPdf] = useState(false); | ||||||
|  |   const [error, setError] = useState<string | null>(null); | ||||||
|  |   const [apiErrorParams, setApiErrorParams] = useState<string | null>(null); | ||||||
|  |   const [showParamSelector, setShowParamSelector] = useState(true); | ||||||
|  |   const [currentParams, setCurrentParams] = useState<{ | ||||||
|  |     fechaDesde: string; | ||||||
|  |     fechaHasta: string; | ||||||
|  |     esAccionista: boolean; | ||||||
|  |     tipoReporte: TipoListadoDistMensual; | ||||||
|  |     nombreTipoVendedor?: string; | ||||||
|  |     mesAnio?: string; | ||||||
|  |   } | null>(null); | ||||||
|  |  | ||||||
|  |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |   const puedeVerReporte = isSuperAdmin || tienePermiso("RR009"); // Asumiendo RR009 para este reporte | ||||||
|  |  | ||||||
|  |   const currencyFormatter = (value?: number | null) => value != null ? value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' }) : '-'; | ||||||
|  |   const numberFormatter = (value?: number | null) => value != null ? Number(value).toLocaleString('es-AR') : '-'; | ||||||
|  |  | ||||||
|  |   const handleGenerarReporte = useCallback(async (params: { | ||||||
|  |     fechaDesde: string; | ||||||
|  |     fechaHasta: string; | ||||||
|  |     esAccionista: boolean; | ||||||
|  |     tipoReporte: TipoListadoDistMensual; | ||||||
|  |   }) => { | ||||||
|  |     if (!puedeVerReporte) { | ||||||
|  |       setError("No tiene permiso para generar este reporte."); | ||||||
|  |       setLoading(false); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     setLoading(true); | ||||||
|  |     setError(null); | ||||||
|  |     setApiErrorParams(null); | ||||||
|  |     setReporteDiariosData([]); | ||||||
|  |     setReportePubData([]); | ||||||
|  |     setCurrentReportVariant(params.tipoReporte); | ||||||
|  |  | ||||||
|  |     const mesAnioStr = new Date(params.fechaDesde + 'T00:00:00').toLocaleDateString('es-AR', { month: 'long', year: 'numeric', timeZone: 'UTC' }); | ||||||
|  |     setCurrentParams({ ...params, nombreTipoVendedor: params.esAccionista ? "Accionistas" : "Canillitas", mesAnio: mesAnioStr }); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       if (params.tipoReporte === 'diarios') { | ||||||
|  |         const data = await reportesService.getListadoDistMensualDiarios(params); | ||||||
|  |         setReporteDiariosData(data.map((item, i) => ({ ...item, id: `diario-${item.canilla}-${i}` }))); | ||||||
|  |         if (data.length === 0) setError("No se encontraron datos para la variante 'Desglose por Diarios'."); | ||||||
|  |       } else { // 'publicaciones' | ||||||
|  |         const data = await reportesService.getListadoDistMensualPorPublicacion(params); | ||||||
|  |         setReportePubData(data.map((item, i) => ({ ...item, id: `pub-${item.canilla}-${item.publicacion}-${i}` }))); | ||||||
|  |         if (data.length === 0) setError("No se encontraron datos para la variante 'Desglose por Publicación'."); | ||||||
|  |       } | ||||||
|  |       setShowParamSelector(false); | ||||||
|  |     } catch (err: any) { | ||||||
|  |       const message = axios.isAxiosError(err) && err.response?.data?.message | ||||||
|  |         ? err.response.data.message : 'Ocurrió un error al generar el reporte.'; | ||||||
|  |       setApiErrorParams(message); | ||||||
|  |     } finally { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }, [puedeVerReporte]); | ||||||
|  |  | ||||||
|  |   const handleVolverAParametros = useCallback(() => { | ||||||
|  |     setShowParamSelector(true); | ||||||
|  |     setReporteDiariosData([]); | ||||||
|  |     setReportePubData([]); | ||||||
|  |     setError(null); | ||||||
|  |     setApiErrorParams(null); | ||||||
|  |     setCurrentParams(null); | ||||||
|  |     setCurrentReportVariant(null); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const handleExportToExcel = useCallback(() => { | ||||||
|  |     if (reporteDiariosData.length === 0 && reportePubData.length === 0) { | ||||||
|  |       alert("No hay datos para exportar."); return; | ||||||
|  |     } | ||||||
|  |     const wb = XLSX.utils.book_new(); | ||||||
|  |     let fileName = "ListadoDistMensual"; | ||||||
|  |     if (currentParams) { | ||||||
|  |       fileName += `_${currentParams.nombreTipoVendedor?.replace(/\s+/g, '')}`; | ||||||
|  |       fileName += `_${currentParams.mesAnio?.replace(/\s+/g, '-').replace('/', '-')}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (currentReportVariant === 'diarios' && reporteDiariosData.length > 0) { | ||||||
|  |       const data = reporteDiariosData.map(({ id, ...r }) => ({ | ||||||
|  |         "Canillita": r.canilla, "El Día (Cant.)": r.elDia, "El Plata (Cant.)": r.elPlata, | ||||||
|  |         "Total Vendidos": r.vendidos, "Imp. El Día": r.importeElDia, "Imp. El Plata": r.importeElPlata, | ||||||
|  |         "Importe Total": r.importeTotal | ||||||
|  |       })); | ||||||
|  |       const totalesDiarios = { | ||||||
|  |         "Canillita": "TOTALES", "El Día (Cant.)": reporteDiariosData.reduce((s, i) => s + (i.elDia ?? 0), 0), | ||||||
|  |         "El Plata (Cant.)": reporteDiariosData.reduce((s, i) => s + (i.elPlata ?? 0), 0), | ||||||
|  |         "Total Vendidos": reporteDiariosData.reduce((s, i) => s + (i.vendidos ?? 0), 0), | ||||||
|  |         "Imp. El Día": reporteDiariosData.reduce((s, i) => s + (i.importeElDia ?? 0), 0), | ||||||
|  |         "Imp. El Plata": reporteDiariosData.reduce((s, i) => s + (i.importeElPlata ?? 0), 0), | ||||||
|  |         "Importe Total": reporteDiariosData.reduce((s, i) => s + (i.importeTotal ?? 0), 0) | ||||||
|  |       }; | ||||||
|  |       data.push(totalesDiarios); | ||||||
|  |       const ws = XLSX.utils.json_to_sheet(data); | ||||||
|  |       const headers = Object.keys(data[0] || {}); | ||||||
|  |       ws['!cols'] = headers.map(h => ({ wch: Math.max(...data.map(row => (row as any)[h]?.toString().length ?? 0), h.length) + 2 })); | ||||||
|  |       XLSX.utils.book_append_sheet(wb, ws, "Desglose Diarios"); | ||||||
|  |       fileName += "_Diarios.xlsx"; | ||||||
|  |  | ||||||
|  |     } else if (currentReportVariant === 'publicaciones' && reportePubData.length > 0) { | ||||||
|  |       // --- INICIO DE CAMBIOS PARA TOTALES EN EXCEL DE PUBLICACIONES --- | ||||||
|  |       const dataAgrupadaParaExcel: any[] = []; | ||||||
|  |       const canillitasUnicos = [...new Set(reportePubData.map(item => item.canilla))]; | ||||||
|  |  | ||||||
|  |       canillitasUnicos.sort().forEach(nombreCanillita => { | ||||||
|  |         const itemsDelCanillita = reportePubData.filter(item => item.canilla === nombreCanillita); | ||||||
|  |         itemsDelCanillita.forEach(item => { | ||||||
|  |           dataAgrupadaParaExcel.push({ | ||||||
|  |             "Canillita": item.canilla, | ||||||
|  |             "Publicación": item.publicacion, | ||||||
|  |             "Llevados": item.totalCantSalida, | ||||||
|  |             "Devueltos": item.totalCantEntrada, | ||||||
|  |             "A Rendir": item.totalRendir | ||||||
|  |           }); | ||||||
|  |         }); | ||||||
|  |         // Fila de Total por Canillita | ||||||
|  |         const totalLlevadosCanillita = itemsDelCanillita.reduce((sum, item) => sum + (item.totalCantSalida ?? 0), 0); | ||||||
|  |         const totalDevueltosCanillita = itemsDelCanillita.reduce((sum, item) => sum + (item.totalCantEntrada ?? 0), 0); | ||||||
|  |         const totalRendirCanillita = itemsDelCanillita.reduce((sum, item) => sum + (item.totalRendir ?? 0), 0); | ||||||
|  |         dataAgrupadaParaExcel.push({ | ||||||
|  |           "Canillita": `Total ${nombreCanillita}`, | ||||||
|  |           "Publicación": "", | ||||||
|  |           "Llevados": totalLlevadosCanillita, | ||||||
|  |           "Devueltos": totalDevueltosCanillita, | ||||||
|  |           "A Rendir": totalRendirCanillita, | ||||||
|  |         }); | ||||||
|  |         dataAgrupadaParaExcel.push({}); // Fila vacía para separar | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       // Fila de Total General | ||||||
|  |       const totalGeneralLlevados = reportePubData.reduce((sum, item) => sum + (item.totalCantSalida ?? 0), 0); | ||||||
|  |       const totalGeneralDevueltos = reportePubData.reduce((sum, item) => sum + (item.totalCantEntrada ?? 0), 0); | ||||||
|  |       const totalGeneralRendir = reportePubData.reduce((sum, item) => sum + (item.totalRendir ?? 0), 0); | ||||||
|  |       dataAgrupadaParaExcel.push({ | ||||||
|  |         "Canillita": "TOTAL GENERAL", | ||||||
|  |         "Publicación": "", | ||||||
|  |         "Llevados": totalGeneralLlevados, | ||||||
|  |         "Devueltos": totalGeneralDevueltos, | ||||||
|  |         "A Rendir": totalGeneralRendir, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       const ws = XLSX.utils.json_to_sheet(dataAgrupadaParaExcel); | ||||||
|  |       const headers = ["Canillita", "Publicación", "Llevados", "Devueltos", "A Rendir"]; // Definir orden | ||||||
|  |       ws['!cols'] = headers.map(h => ({ wch: Math.max(...dataAgrupadaParaExcel.map(row => (row as any)[h]?.toString().length ?? 0), h.length) + 2 })); | ||||||
|  |       XLSX.utils.book_append_sheet(wb, ws, "Desglose Publicaciones"); | ||||||
|  |       fileName += "_Publicaciones.xlsx"; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (wb.SheetNames.length > 0) XLSX.writeFile(wb, fileName); | ||||||
|  |     else alert("No hay datos para la variante seleccionada para exportar."); | ||||||
|  |  | ||||||
|  |   }, [reporteDiariosData, reportePubData, currentReportVariant, currentParams]); | ||||||
|  |  | ||||||
|  |   const handleGenerarYAbrirPdf = useCallback(async () => { | ||||||
|  |     if (!currentParams) { setError("Seleccione parámetros."); return; } | ||||||
|  |     if (!puedeVerReporte) { setError("Sin permiso para PDF."); return; } | ||||||
|  |     setLoadingPdf(true); setError(null); | ||||||
|  |     try { | ||||||
|  |       let blob; | ||||||
|  |       if (currentParams.tipoReporte === 'diarios') { | ||||||
|  |         blob = await reportesService.getListadoDistMensualDiariosPdf(currentParams); | ||||||
|  |       } else { | ||||||
|  |         blob = await reportesService.getListadoDistMensualPorPublicacionPdf(currentParams); | ||||||
|  |       } | ||||||
|  |       if (blob.type === "application/json") { | ||||||
|  |         const text = await blob.text(); | ||||||
|  |         const msg = JSON.parse(text).message ?? "Error inesperado al generar PDF."; | ||||||
|  |         setError(msg); | ||||||
|  |         setLoadingPdf(false); // Asegurar que se detenga el loader | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       const url = URL.createObjectURL(blob); | ||||||
|  |       const w = window.open(url, '_blank'); | ||||||
|  |       if (!w) alert("Permita popups para ver el PDF."); | ||||||
|  |     } catch (err: any) { | ||||||
|  |       const message = axios.isAxiosError(err) && err.response?.data?.message | ||||||
|  |         ? err.response.data.message | ||||||
|  |         : 'Ocurrió un error al generar el PDF.'; | ||||||
|  |       setError(message); | ||||||
|  |     } finally { | ||||||
|  |       setLoadingPdf(false); | ||||||
|  |     } | ||||||
|  |   }, [currentParams, puedeVerReporte]); | ||||||
|  |  | ||||||
|  |   const FooterSimple = () => (<GridFooterContainer><GridFooter /></GridFooterContainer>); | ||||||
|  |  | ||||||
|  |   const totalGeneralDiarios = useMemo(() => { | ||||||
|  |     if (reporteDiariosData.length === 0) return null; | ||||||
|  |     return { | ||||||
|  |       elDia: reporteDiariosData.reduce((s, i) => s + (i.elDia ?? 0), 0), | ||||||
|  |       elPlata: reporteDiariosData.reduce((s, i) => s + (i.elPlata ?? 0), 0), | ||||||
|  |       vendidos: reporteDiariosData.reduce((s, i) => s + (i.vendidos ?? 0), 0), | ||||||
|  |       importeElDia: reporteDiariosData.reduce((s, i) => s + (i.importeElDia ?? 0), 0), | ||||||
|  |       importeElPlata: reporteDiariosData.reduce((s, i) => s + (i.importeElPlata ?? 0), 0), | ||||||
|  |       importeTotal: reporteDiariosData.reduce((s, i) => s + (i.importeTotal ?? 0), 0) | ||||||
|  |     }; | ||||||
|  |   }, [reporteDiariosData]); | ||||||
|  |  | ||||||
|  |   // eslint-disable-next-line react/display-name | ||||||
|  |   const FooterDiarios = () => { | ||||||
|  |     if (!totalGeneralDiarios) return <GridFooterContainer><GridFooter sx={{ borderTop: 'none' }} /></GridFooterContainer>; | ||||||
|  |     return ( | ||||||
|  |       <GridFooterContainer sx={{ | ||||||
|  |         display: 'flex', | ||||||
|  |         justifyContent: 'space-between', // Separa la paginación (izquierda) de los totales (derecha) | ||||||
|  |         alignItems: 'center', | ||||||
|  |         width: '100%', | ||||||
|  |         borderTop: (theme) => `1px solid ${theme.palette.divider}`, | ||||||
|  |         minHeight: '52px', // Altura estándar para el footer | ||||||
|  |         // No es necesario p: aquí si los hijos lo manejan o el GridFooterContainer lo aplica por defecto | ||||||
|  |       }}> | ||||||
|  |         {/* Box para la paginación estándar */} | ||||||
|  |         <Box sx={{ | ||||||
|  |           display: 'flex', | ||||||
|  |           alignItems: 'center', | ||||||
|  |           flexShrink: 0, // Evita que este box se encoja si los totales son anchos | ||||||
|  |           overflow: 'hidden', // Para asegurar que no desborde si el contenido interno es muy ancho | ||||||
|  |           px: 1, // Padding horizontal para el contenedor de la paginación | ||||||
|  |           // Considera un flexGrow o un width/maxWidth si necesitas más control sobre el espacio de la paginación | ||||||
|  |           // Ejemplo: flexGrow: 1, maxWidth: 'calc(100% - 250px)' (para dejar espacio a los totales) | ||||||
|  |         }}> | ||||||
|  |           <GridFooter | ||||||
|  |             sx={{ | ||||||
|  |               borderTop: 'none', // Quitar el borde superior del GridFooter interno | ||||||
|  |               width: 'auto', // Permite que el GridFooter se ajuste a su contenido (paginador) | ||||||
|  |               '& .MuiToolbar-root': { // Ajustar padding del toolbar de paginación | ||||||
|  |                 paddingLeft: 0, // O un valor pequeño si es necesario | ||||||
|  |                 paddingRight: 0, | ||||||
|  |               }, | ||||||
|  |               // Mantenemos oculto el contador de filas seleccionadas si no lo queremos | ||||||
|  |               '& .MuiDataGrid-selectedRowCount': { display: 'none' }, | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |         </Box> | ||||||
|  |         {/* Box para los totales personalizados */} | ||||||
|  |         <Box sx={{ | ||||||
|  |           display: 'flex', | ||||||
|  |           alignItems: 'center', | ||||||
|  |           fontWeight: 'bold', | ||||||
|  |           whiteSpace: 'nowrap', // Evita que los totales hagan salto de línea | ||||||
|  |           overflowX: 'auto',   // Scroll DENTRO de este Box si los totales son muy anchos | ||||||
|  |           px: 2, // Padding horizontal para el contenedor de los totales (ajusta pr:2 de tu ejemplo) | ||||||
|  |           flexShrink: 1, // Permitir que este contenedor se encoja si la paginación necesita más espacio | ||||||
|  |         }}> | ||||||
|  |           <Typography variant="subtitle2" sx={{ width: columnsDiarios[0].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>TOTALES:</Typography> | ||||||
|  |           <Typography variant="subtitle2" sx={{ width: columnsDiarios[1].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{numberFormatter(totalGeneralDiarios.elDia)}</Typography> | ||||||
|  |           <Typography variant="subtitle2" sx={{ width: columnsDiarios[2].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{numberFormatter(totalGeneralDiarios.elPlata)}</Typography> | ||||||
|  |           <Typography variant="subtitle2" sx={{ width: columnsDiarios[3].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{numberFormatter(totalGeneralDiarios.vendidos)}</Typography> | ||||||
|  |           <Typography variant="subtitle2" sx={{ width: columnsDiarios[4].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{currencyFormatter(totalGeneralDiarios.importeElDia)}</Typography> | ||||||
|  |           <Typography variant="subtitle2" sx={{ width: columnsDiarios[5].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{currencyFormatter(totalGeneralDiarios.importeElPlata)}</Typography> | ||||||
|  |           <Typography variant="subtitle2" sx={{ width: columnsDiarios[6].width, textAlign: 'right', fontWeight: 'bold' }}>{currencyFormatter(totalGeneralDiarios.importeTotal)}</Typography> | ||||||
|  |         </Box> | ||||||
|  |       </GridFooterContainer> | ||||||
|  |     ); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const columnsDiarios: GridColDef<GridDiariosItem>[] = [ | ||||||
|  |     { field: 'canilla', headerName: 'Nombre', width: 250, flex: 1.5 }, | ||||||
|  |     { field: 'elDia', headerName: 'El Día (Cant)', type: 'number', width: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) }, | ||||||
|  |     { field: 'elPlata', headerName: 'El Plata (Cant)', type: 'number', width: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) }, | ||||||
|  |     { field: 'vendidos', headerName: 'Total Vendidos', type: 'number', width: 130, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) }, | ||||||
|  |     { field: 'importeElDia', headerName: 'Imp. El Día', type: 'number', width: 150, align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(value as number) }, | ||||||
|  |     { field: 'importeElPlata', headerName: 'Imp. El Plata', type: 'number', width: 150, align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(value as number) }, | ||||||
|  |     { field: 'importeTotal', headerName: 'Importe Total', type: 'number', width: 160, align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(value as number) }, | ||||||
|  |   ]; | ||||||
|  |   const columnsPublicaciones: GridColDef<GridPubItem>[] = [ | ||||||
|  |     { field: 'canilla', headerName: 'Canillita', width: 250, flex: 1.2 }, | ||||||
|  |     { field: 'publicacion', headerName: 'Publicación', width: 200, flex: 1 }, | ||||||
|  |     { field: 'totalCantSalida', headerName: 'Llevados', type: 'number', width: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) }, | ||||||
|  |     { field: 'totalCantEntrada', headerName: 'Devueltos', type: 'number', width: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) }, | ||||||
|  |     { field: 'totalRendir', headerName: 'A Rendir', type: 'number', width: 150, align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(value as number) }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   const rowsDiarios = useMemo(() => reporteDiariosData, [reporteDiariosData]); | ||||||
|  |   const rowsPubs = useMemo(() => reportePubData, [reportePubData]); | ||||||
|  |  | ||||||
|  |   if (showParamSelector) { | ||||||
|  |     if (!loading && !puedeVerReporte) { | ||||||
|  |       return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>; | ||||||
|  |     } | ||||||
|  |     return ( | ||||||
|  |       <Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}> | ||||||
|  |         <Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}> | ||||||
|  |           <SeleccionaReporteListadoDistMensual | ||||||
|  |             onGenerarReporte={handleGenerarReporte} | ||||||
|  |             isLoading={loading} | ||||||
|  |             apiErrorMessage={apiErrorParams} | ||||||
|  |           /> | ||||||
|  |         </Paper> | ||||||
|  |       </Box> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!loading && !puedeVerReporte && !showParamSelector) { | ||||||
|  |     return ( | ||||||
|  |       <Box sx={{ p: 2 }}> | ||||||
|  |         <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para ver este reporte.</Alert> | ||||||
|  |         <Button onClick={handleVolverAParametros} variant="outlined" color="secondary" size="small"> | ||||||
|  |           Volver | ||||||
|  |         </Button> | ||||||
|  |       </Box> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const renderReportContent = () => { | ||||||
|  |     if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>; | ||||||
|  |     if (error && !loading) return <Alert severity="info" sx={{ my: 2 }}>{error}</Alert>; | ||||||
|  |  | ||||||
|  |     if (currentReportVariant === 'diarios') { | ||||||
|  |       if (reporteDiariosData.length === 0) return <Typography sx={{ mt: 2, fontStyle: 'italic' }}>No hay datos para la variante "Desglose por Diarios".</Typography>; | ||||||
|  |       return ( | ||||||
|  |         <Paper sx={{ height: 'calc(100vh - 320px)', width: '100%', mt: 2 }}> | ||||||
|  |           <DataGrid | ||||||
|  |             rows={rowsDiarios} | ||||||
|  |             columns={columnsDiarios} | ||||||
|  |             localeText={esES.components.MuiDataGrid.defaultProps.localeText} | ||||||
|  |             slots={{ footer: FooterDiarios }} | ||||||
|  |             density="compact" | ||||||
|  |             hideFooterSelectedRowCount | ||||||
|  |             disableRowSelectionOnClick | ||||||
|  |             initialState={{ | ||||||
|  |               pagination: { paginationModel: { pageSize: 100 } }, | ||||||
|  |             }} | ||||||
|  |             pageSizeOptions={[25, 50, 100]} | ||||||
|  |           /> | ||||||
|  |         </Paper> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     if (currentReportVariant === 'publicaciones') { | ||||||
|  |       if (reportePubData.length === 0) return <Typography sx={{ mt: 2, fontStyle: 'italic' }}>No hay datos para la variante "Desglose por Publicación".</Typography>; | ||||||
|  |       return ( | ||||||
|  |         <Paper sx={{ height: 'calc(100vh - 320px)', width: '100%', mt: 2 }}> | ||||||
|  |           <DataGrid | ||||||
|  |             rows={rowsPubs} | ||||||
|  |             columns={columnsPublicaciones} | ||||||
|  |             localeText={esES.components.MuiDataGrid.defaultProps.localeText} | ||||||
|  |             slots={{ footer: FooterSimple }} // Para esta tabla, un footer simple sin totales complejos por ahora | ||||||
|  |             density="compact" | ||||||
|  |             initialState={{ | ||||||
|  |               pagination: { paginationModel: { pageSize: 100 } }, | ||||||
|  |             }} | ||||||
|  |             pageSizeOptions={[25, 50, 100]} | ||||||
|  |             disableRowSelectionOnClick | ||||||
|  |           /> | ||||||
|  |         </Paper> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box sx={{ p: 2 }}> | ||||||
|  |       <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}> | ||||||
|  |         <Typography variant="h5">Listado Distribución Mensual ({currentParams?.nombreTipoVendedor})</Typography> | ||||||
|  |         <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> | ||||||
|  |           <Button onClick={handleGenerarYAbrirPdf} variant="contained" disabled={loadingPdf || (reporteDiariosData.length === 0 && reportePubData.length === 0) || !!error} size="small"> | ||||||
|  |             {loadingPdf ? <CircularProgress size={20} color="inherit" /> : "Abrir PDF"} | ||||||
|  |           </Button> | ||||||
|  |           <Button onClick={handleExportToExcel} variant="outlined" disabled={(reporteDiariosData.length === 0 && reportePubData.length === 0) || !!error} size="small"> | ||||||
|  |             Exportar a Excel | ||||||
|  |           </Button> | ||||||
|  |           <Button onClick={handleVolverAParametros} variant="outlined" color="secondary" size="small"> | ||||||
|  |             Nuevos Parámetros | ||||||
|  |           </Button> | ||||||
|  |         </Box> | ||||||
|  |       </Box> | ||||||
|  |       <Typography variant="subtitle1" gutterBottom> | ||||||
|  |         Mes: {currentParams?.mesAnio || '-'} | ||||||
|  |       </Typography> | ||||||
|  |       {renderReportContent()} | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default ReporteListadoDistMensualPage; | ||||||
| @@ -8,6 +8,7 @@ import { esES } from '@mui/x-data-grid/locales'; | |||||||
| import reportesService from '../../services/Reportes/reportesService'; | import reportesService from '../../services/Reportes/reportesService'; | ||||||
| import type { ListadoDistribucionDistribuidoresResponseDto } from '../../models/dtos/Reportes/ListadoDistribucionDistribuidoresResponseDto'; | import type { ListadoDistribucionDistribuidoresResponseDto } from '../../models/dtos/Reportes/ListadoDistribucionDistribuidoresResponseDto'; | ||||||
| import SeleccionaReporteListadoDistribucion from './SeleccionaReporteListadoDistribucion'; | import SeleccionaReporteListadoDistribucion from './SeleccionaReporteListadoDistribucion'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
| import * as XLSX from 'xlsx'; | import * as XLSX from 'xlsx'; | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
|  |  | ||||||
| @@ -26,6 +27,8 @@ const ReporteListadoDistribucionPage: React.FC = () => { | |||||||
|     nombrePublicacion?: string; |     nombrePublicacion?: string; | ||||||
|     nombreDistribuidor?: string; |     nombreDistribuidor?: string; | ||||||
|   } | null>(null); |   } | null>(null); | ||||||
|  |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |   const puedeVerReporte = isSuperAdmin || tienePermiso("RR002"); | ||||||
|  |  | ||||||
|   // --- ESTADO PARA TOTALES CALCULADOS (PARA EL FOOTER DEL DETALLE) --- |   // --- ESTADO PARA TOTALES CALCULADOS (PARA EL FOOTER DEL DETALLE) --- | ||||||
|   const [totalesDetalle, setTotalesDetalle] = useState({ |   const [totalesDetalle, setTotalesDetalle] = useState({ | ||||||
| @@ -51,6 +54,11 @@ const ReporteListadoDistribucionPage: React.FC = () => { | |||||||
|     fechaDesde: string; |     fechaDesde: string; | ||||||
|     fechaHasta: string; |     fechaHasta: string; | ||||||
|   }) => { |   }) => { | ||||||
|  |     if (!puedeVerReporte) { | ||||||
|  |       setError("No tiene permiso para generar este reporte."); | ||||||
|  |       setLoading(false); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     setError(null); |     setError(null); | ||||||
|     setApiErrorParams(null); |     setApiErrorParams(null); | ||||||
| @@ -344,6 +352,9 @@ const ReporteListadoDistribucionPage: React.FC = () => { | |||||||
|  |  | ||||||
|  |  | ||||||
|   if (showParamSelector) { |   if (showParamSelector) { | ||||||
|  |     if (!loading && !puedeVerReporte) { | ||||||
|  |       return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>; | ||||||
|  |     } | ||||||
|     return ( |     return ( | ||||||
|       <Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}> |       <Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}> | ||||||
|         <Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}> |         <Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}> | ||||||
|   | |||||||
							
								
								
									
										362
									
								
								Frontend/src/pages/Reportes/ReporteNovedadesCanillasPage.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										362
									
								
								Frontend/src/pages/Reportes/ReporteNovedadesCanillasPage.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,362 @@ | |||||||
|  | import React, { useState, useCallback, useMemo } from 'react'; | ||||||
|  | import { | ||||||
|  |   Box, Typography, Paper, CircularProgress, Alert, Button, Tooltip | ||||||
|  | } from '@mui/material'; | ||||||
|  | import { DataGrid, type GridColDef, GridFooterContainer, GridFooter } from '@mui/x-data-grid'; | ||||||
|  | import { esES } from '@mui/x-data-grid/locales'; | ||||||
|  | import reportesService from '../../services/Reportes/reportesService'; | ||||||
|  | import type { NovedadesCanillasReporteDto } from '../../models/dtos/Reportes/NovedadesCanillasReporteDto'; | ||||||
|  | import type { CanillaGananciaReporteDto } from '../../models/dtos/Reportes/CanillaGananciaReporteDto'; | ||||||
|  | import SeleccionaReporteNovedadesCanillas from './SeleccionaReporteNovedadesCanillas'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
|  | import * as XLSX from 'xlsx'; | ||||||
|  | import axios from 'axios'; | ||||||
|  |  | ||||||
|  | interface NovedadesCanillasReporteGridItem extends NovedadesCanillasReporteDto { | ||||||
|  |   id: string; | ||||||
|  | } | ||||||
|  | interface CanillaGananciaGridItem extends CanillaGananciaReporteDto { | ||||||
|  |   id: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const ReporteNovedadesCanillasPage: React.FC = () => { | ||||||
|  |   const [novedadesData, setNovedadesData] = useState<NovedadesCanillasReporteGridItem[]>([]); | ||||||
|  |   const [gananciasData, setGananciasData] = useState<CanillaGananciaGridItem[]>([]); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [loadingPdf, setLoadingPdf] = useState(false); | ||||||
|  |   const [error, setError] = useState<string | null>(null); | ||||||
|  |   const [apiErrorParams, setApiErrorParams] = useState<string | null>(null); | ||||||
|  |   const [showParamSelector, setShowParamSelector] = useState(true); | ||||||
|  |   const [currentParams, setCurrentParams] = useState<{ | ||||||
|  |     idEmpresa: number; | ||||||
|  |     fechaDesde: string; | ||||||
|  |     fechaHasta: string; | ||||||
|  |     nombreEmpresa?: string; | ||||||
|  |   } | null>(null); | ||||||
|  |  | ||||||
|  |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |   const puedeVerReporte = isSuperAdmin || tienePermiso("RR004"); | ||||||
|  |  | ||||||
|  |   const currencyFormatter = (value: number | null | undefined) => // Helper para formato moneda | ||||||
|  |     value != null ? value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' }) : ''; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   const handleGenerarReporte = useCallback(async (params: { | ||||||
|  |     idEmpresa: number; | ||||||
|  |     fechaDesde: string; | ||||||
|  |     fechaHasta: string; | ||||||
|  |   }) => { | ||||||
|  |     if (!puedeVerReporte) { | ||||||
|  |       setError("No tiene permiso para generar este reporte."); | ||||||
|  |       setLoading(false); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     setLoading(true); | ||||||
|  |     setError(null); | ||||||
|  |     setApiErrorParams(null); | ||||||
|  |     setNovedadesData([]); | ||||||
|  |     setGananciasData([]); // Limpiar datos de ganancias | ||||||
|  |  | ||||||
|  |     let empresaNombre = `Empresa ${params.idEmpresa}`; | ||||||
|  |     try { | ||||||
|  |         const empresaService = (await import('../../services/Distribucion/empresaService')).default; | ||||||
|  |         const empData = await empresaService.getEmpresaLookupById(params.idEmpresa); | ||||||
|  |         if (empData) empresaNombre = empData.nombre; | ||||||
|  |     } catch (e) { console.warn("No se pudo obtener nombre de empresa para el reporte", e); } | ||||||
|  |      | ||||||
|  |     setCurrentParams({ ...params, nombreEmpresa: empresaNombre }); | ||||||
|  |      | ||||||
|  |     try { | ||||||
|  |       // Llamadas concurrentes para ambos conjuntos de datos | ||||||
|  |       const [novedadesResult, gananciasResult] = await Promise.all([ | ||||||
|  |         reportesService.getNovedadesCanillasReporte(params), | ||||||
|  |         reportesService.getCanillasGananciasReporte(params) // << LLAMAR AL NUEVO SERVICIO | ||||||
|  |       ]); | ||||||
|  |  | ||||||
|  |       const novedadesConIds = novedadesResult.map((item, index) => ({ | ||||||
|  |         ...item, | ||||||
|  |         id: `nov-${item.nomApe || 'sinnom'}-${item.fecha || 'sinfec'}-${index}` | ||||||
|  |       })); | ||||||
|  |       setNovedadesData(novedadesConIds); | ||||||
|  |  | ||||||
|  |       const gananciasConIds = gananciasResult.map((item, index) => ({ | ||||||
|  |         ...item, | ||||||
|  |         id: `gan-${item.canilla || 'sincan'}-${index}` | ||||||
|  |       })); | ||||||
|  |       setGananciasData(gananciasConIds); | ||||||
|  |  | ||||||
|  |       if (novedadesConIds.length === 0 && gananciasConIds.length === 0) { | ||||||
|  |         setError("No se encontraron datos para los parámetros seleccionados."); | ||||||
|  |       } | ||||||
|  |       setShowParamSelector(false); | ||||||
|  |     } catch (err: any) { | ||||||
|  |       const message = axios.isAxiosError(err) && err.response?.data?.message | ||||||
|  |         ? err.response.data.message | ||||||
|  |         : 'Ocurrió un error al generar el reporte.'; | ||||||
|  |       setApiErrorParams(message); | ||||||
|  |     } finally { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }, [puedeVerReporte]); | ||||||
|  |  | ||||||
|  |   const handleVolverAParametros = useCallback(() => { | ||||||
|  |     setShowParamSelector(true); | ||||||
|  |     setNovedadesData([]); | ||||||
|  |     setGananciasData([]); // Limpiar también | ||||||
|  |     setError(null); | ||||||
|  |     setApiErrorParams(null); | ||||||
|  |     setCurrentParams(null); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const handleExportToExcel = useCallback(() => { | ||||||
|  |     if (novedadesData.length === 0 && gananciasData.length === 0) { // Chequear ambos | ||||||
|  |       alert("No hay datos para exportar."); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const wb = XLSX.utils.book_new(); | ||||||
|  |  | ||||||
|  |     // Hoja de Ganancias | ||||||
|  |     if (gananciasData.length > 0) { | ||||||
|  |         const gananciasToExport = gananciasData.map(({ id, ...rest }) => ({ | ||||||
|  |             "Canilla": rest.canilla, | ||||||
|  |             "Legajo": rest.legajo ?? '-', | ||||||
|  |             "Faltas": rest.faltas ?? 0, | ||||||
|  |             "Francos": rest.francos ?? 0, | ||||||
|  |             "Comisiones": rest.totalRendir ?? 0, | ||||||
|  |         })); | ||||||
|  |          const totalComisiones = gananciasData.reduce((sum, item) => sum + (item.totalRendir ?? 0), 0); | ||||||
|  |          gananciasToExport.push({ | ||||||
|  |             "Canilla": "Total", "Legajo": "", "Faltas": 0, "Francos": 0, | ||||||
|  |             "Comisiones": totalComisiones | ||||||
|  |          }); | ||||||
|  |         const wsGanancias = XLSX.utils.json_to_sheet(gananciasToExport); | ||||||
|  |         const headersGan = Object.keys(gananciasToExport[0] || {}); | ||||||
|  |         wsGanancias['!cols'] = headersGan.map(h => { | ||||||
|  |             const maxLen = Math.max(...gananciasToExport.map(row => (row as any)[h]?.toString().length ?? 0), h.length); | ||||||
|  |             return { wch: maxLen + 2 }; | ||||||
|  |         }); | ||||||
|  |         XLSX.utils.book_append_sheet(wb, wsGanancias, "ResumenCanillas"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Hoja de Novedades | ||||||
|  |     if (novedadesData.length > 0) { | ||||||
|  |         const novedadesToExport = novedadesData.map(({ id, ...rest }) => ({ | ||||||
|  |             "Canillita": rest.nomApe, | ||||||
|  |             "Fecha": rest.fecha ? new Date(rest.fecha).toLocaleDateString('es-AR', {timeZone: 'UTC'}) : '-', | ||||||
|  |             "Detalle": rest.detalle, | ||||||
|  |         })); | ||||||
|  |         const wsNovedades = XLSX.utils.json_to_sheet(novedadesToExport); | ||||||
|  |         const headersNov = Object.keys(novedadesToExport[0] || {}); | ||||||
|  |         wsNovedades['!cols'] = headersNov.map(h => { | ||||||
|  |             const maxLen = Math.max(...novedadesToExport.map(row => (row as any)[h]?.toString().length ?? 0), h.length); | ||||||
|  |              if (h === "Detalle") return { wch: Math.max(maxLen + 2, 50) }; | ||||||
|  |             return { wch: maxLen + 2 }; | ||||||
|  |         }); | ||||||
|  |         XLSX.utils.book_append_sheet(wb, wsNovedades, "DetalleNovedades"); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     let fileName = "ReporteNovedadesCanillas"; | ||||||
|  |     if (currentParams) { | ||||||
|  |       fileName += `_${currentParams.nombreEmpresa?.replace(/\s+/g, '') || `Emp${currentParams.idEmpresa}`}`; | ||||||
|  |       fileName += `_${currentParams.fechaDesde}_a_${currentParams.fechaHasta}`; | ||||||
|  |     } | ||||||
|  |     fileName += ".xlsx"; | ||||||
|  |     XLSX.writeFile(wb, fileName); | ||||||
|  |   }, [novedadesData, gananciasData, currentParams]); | ||||||
|  |  | ||||||
|  |   const handleGenerarYAbrirPdf = useCallback(async () => { | ||||||
|  |     // ... (sin cambios, ya que el PDF del backend ya debería estar manejando ambos DataSets) | ||||||
|  |     if (!currentParams) { | ||||||
|  |       setError("Primero debe generar el reporte en pantalla."); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     if (!puedeVerReporte) { | ||||||
|  |         setError("No tiene permiso para generar este PDF."); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |     setLoadingPdf(true); | ||||||
|  |     setError(null); | ||||||
|  |     try { | ||||||
|  |       const blob = await reportesService.getNovedadesCanillasReportePdf(currentParams); | ||||||
|  |       if (blob.type === "application/json") { | ||||||
|  |         const text = await blob.text(); | ||||||
|  |         const msg = JSON.parse(text).message ?? "Error inesperado al generar PDF."; | ||||||
|  |         setError(msg); | ||||||
|  |       } else { | ||||||
|  |         const url = URL.createObjectURL(blob); | ||||||
|  |         const w = window.open(url, '_blank'); | ||||||
|  |         if (!w) alert("Permita popups para ver el PDF."); | ||||||
|  |       } | ||||||
|  |     } catch (err: any){ | ||||||
|  |         const message = axios.isAxiosError(err) && err.response?.data?.message | ||||||
|  |         ? err.response.data.message | ||||||
|  |         : 'Ocurrió un error al generar el PDF.'; | ||||||
|  |       setError(message); | ||||||
|  |     } finally { | ||||||
|  |       setLoadingPdf(false); | ||||||
|  |     } | ||||||
|  |   }, [currentParams, puedeVerReporte]); | ||||||
|  |  | ||||||
|  |   // Columnas para la tabla de Resumen/Ganancias | ||||||
|  |   const columnsGanancias: GridColDef<CanillaGananciaGridItem>[] = [ | ||||||
|  |     { field: 'canilla', headerName: 'Canilla', width: 250, flex: 1.5 }, | ||||||
|  |     { field: 'legajo', headerName: 'Legajo', width: 100, type: 'number' }, | ||||||
|  |     { field: 'faltas', headerName: 'Faltas', width: 100, type: 'number', align: 'right', headerAlign: 'right' }, | ||||||
|  |     { field: 'francos', headerName: 'Francos', width: 100, type: 'number', align: 'right', headerAlign: 'right' }, | ||||||
|  |     { field: 'totalRendir', headerName: 'Comisiones', width: 150, type: 'number', align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(Number(value)) }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   // Columnas para la tabla de Detalles de Novedades (ya existentes) | ||||||
|  |   const columnsNovedades: GridColDef<NovedadesCanillasReporteGridItem>[] = [ | ||||||
|  |     { field: 'nomApe', headerName: 'Canillita', width: 250, flex: 1.5 }, | ||||||
|  |     {  | ||||||
|  |       field: 'fecha',  | ||||||
|  |       headerName: 'Fecha',  | ||||||
|  |       width: 120,  | ||||||
|  |       type: 'date', | ||||||
|  |       valueGetter: (value) => value ? new Date(value as string) : null, | ||||||
|  |       valueFormatter: (value) => value ? new Date(value as string).toLocaleDateString('es-AR', {timeZone: 'UTC'}) : '-', | ||||||
|  |     }, | ||||||
|  |     { field: 'detalle', headerName: 'Detalle Novedad', flex: 2, minWidth: 350, | ||||||
|  |         renderCell: (params) => ( | ||||||
|  |             <Tooltip title={params.value || ''} arrow placement="top"> | ||||||
|  |                 <Typography variant="body2" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', width: '100%' }}> | ||||||
|  |                     {params.value || '-'} | ||||||
|  |                 </Typography> | ||||||
|  |             </Tooltip> | ||||||
|  |         ) | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   const rowsGanancias = useMemo(() => gananciasData, [gananciasData]); | ||||||
|  |   const rowsNovedades = useMemo(() => novedadesData, [novedadesData]); | ||||||
|  |    | ||||||
|  |   const totalComisionesGanancias = useMemo(() =>  | ||||||
|  |     gananciasData.reduce((sum, item) => sum + (item.totalRendir ?? 0), 0),  | ||||||
|  |   [gananciasData]); | ||||||
|  |  | ||||||
|  |   // eslint-disable-next-line react/display-name | ||||||
|  |   const FooterGanancias = () => ( | ||||||
|  |     <GridFooterContainer sx={{ justifyContent: 'flex-end', borderTop: (theme) => `1px solid ${theme.palette.divider}` }}> | ||||||
|  |         <GridFooter /> | ||||||
|  |         <Box sx={{ p: 1, display: 'flex', alignItems: 'center', fontWeight: 'bold' }}> | ||||||
|  |             <Typography variant="subtitle1" sx={{ mr: 2 }}>Total Comisiones:</Typography> | ||||||
|  |             <Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{currencyFormatter(totalComisionesGanancias)}</Typography> | ||||||
|  |         </Box>          | ||||||
|  |     </GridFooterContainer> | ||||||
|  |   ); | ||||||
|  |   const FooterNovedades = () => ( <GridFooterContainer sx={{ justifyContent: 'flex-end', borderTop: (theme) => `1px solid ${theme.palette.divider}` }}><GridFooter /></GridFooterContainer>); | ||||||
|  |  | ||||||
|  |   if (showParamSelector) { | ||||||
|  |     if (!loading && !puedeVerReporte) { | ||||||
|  |         return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>; | ||||||
|  |     } | ||||||
|  |     return ( | ||||||
|  |       <Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}> | ||||||
|  |         <Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}> | ||||||
|  |           <SeleccionaReporteNovedadesCanillas | ||||||
|  |             onGenerarReporte={handleGenerarReporte} | ||||||
|  |             isLoading={loading} | ||||||
|  |             apiErrorMessage={apiErrorParams} | ||||||
|  |           /> | ||||||
|  |         </Paper> | ||||||
|  |       </Box> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!loading && !puedeVerReporte && !showParamSelector) { | ||||||
|  |       // ... (renderizado de "sin permiso" sin cambios) | ||||||
|  |       return ( | ||||||
|  |           <Box sx={{ p: 2 }}> | ||||||
|  |               <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para ver este reporte.</Alert> | ||||||
|  |               <Button onClick={handleVolverAParametros} variant="outlined" color="secondary" size="small"> | ||||||
|  |                   Volver | ||||||
|  |               </Button> | ||||||
|  |           </Box> | ||||||
|  |       ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box sx={{ p: 2 }}> | ||||||
|  |       <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}> | ||||||
|  |         <Typography variant="h5">Reporte: Listado de Novedades Canillitas</Typography> {/* Título más genérico */} | ||||||
|  |         <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> | ||||||
|  |           <Button onClick={handleGenerarYAbrirPdf} variant="contained" disabled={loadingPdf || (novedadesData.length === 0 && gananciasData.length === 0) || !!error} size="small"> | ||||||
|  |             {loadingPdf ? <CircularProgress size={20} color="inherit" /> : "Abrir PDF"} | ||||||
|  |           </Button> | ||||||
|  |           <Button onClick={handleExportToExcel} variant="outlined" disabled={(novedadesData.length === 0 && gananciasData.length === 0) || !!error} size="small"> | ||||||
|  |             Exportar a Excel | ||||||
|  |           </Button> | ||||||
|  |           <Button onClick={handleVolverAParametros} variant="outlined" color="secondary" size="small"> | ||||||
|  |             Nuevos Parámetros | ||||||
|  |           </Button> | ||||||
|  |         </Box> | ||||||
|  |       </Box> | ||||||
|  |       <Typography variant="subtitle1" gutterBottom> | ||||||
|  |         Empresa: {currentParams?.nombreEmpresa || '-'} |  | ||||||
|  |         Período: {currentParams?.fechaDesde ? new Date(currentParams.fechaDesde + 'T00:00:00').toLocaleDateString('es-AR') : ''} al {currentParams?.fechaHasta ? new Date(currentParams.fechaHasta + 'T00:00:00').toLocaleDateString('es-AR') : ''} | ||||||
|  |       </Typography> | ||||||
|  |  | ||||||
|  |       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} | ||||||
|  |       {(error && !loading) && <Alert severity="info" sx={{ my: 2 }}>{error}</Alert>} | ||||||
|  |  | ||||||
|  |       {/* Sección de Ganancias/Resumen */} | ||||||
|  |       {!loading && !error && currentParams && ( | ||||||
|  |         <> | ||||||
|  |           <Typography variant="h6" sx={{ mt: 3, mb: 1 }}>Resumen de Actividad</Typography> | ||||||
|  |           {gananciasData.length > 0 ? ( | ||||||
|  |             <Paper sx={{ height: 250, width: '100%', mb: 3 }}> | ||||||
|  |               <DataGrid | ||||||
|  |                 rows={rowsGanancias} | ||||||
|  |                 columns={columnsGanancias} | ||||||
|  |                 localeText={esES.components.MuiDataGrid.defaultProps.localeText} | ||||||
|  |                 slots={{ footer: FooterGanancias }} | ||||||
|  |                 density="compact" | ||||||
|  |                 initialState={{ pagination: { paginationModel: { pageSize: 25 } } }} | ||||||
|  |                 pageSizeOptions={[25, 50, 100]} | ||||||
|  |                 disableRowSelectionOnClick | ||||||
|  |                 rowHeight={48} | ||||||
|  |                 sx={{ | ||||||
|  |                     '& .MuiDataGrid-cell': { overflow: 'hidden', textOverflow: 'ellipsis' }, | ||||||
|  |                     '& .MuiDataGrid-columnHeaderTitleContainer': { overflow: 'hidden' }, | ||||||
|  |                 }} | ||||||
|  |               /> | ||||||
|  |             </Paper> | ||||||
|  |           ) : ( | ||||||
|  |              <Typography sx={{mt:1, mb:3, fontStyle:'italic'}}>No hay datos de resumen de actividad para mostrar.</Typography> | ||||||
|  |           )} | ||||||
|  |  | ||||||
|  |           {/* Sección de Detalle de Novedades */} | ||||||
|  |           <Typography variant="h6" sx={{ mt: 3, mb: 1 }}>Otras Novedades (Detalle)</Typography> | ||||||
|  |           {novedadesData.length > 0 ? ( | ||||||
|  |             <Paper sx={{ height: 250, width: '100%', mb: 3 }}> {/* Ajustar altura si es necesario */} | ||||||
|  |               <DataGrid | ||||||
|  |                 rows={rowsNovedades} | ||||||
|  |                 columns={columnsNovedades} | ||||||
|  |                 localeText={esES.components.MuiDataGrid.defaultProps.localeText} | ||||||
|  |                 slots={{ footer: FooterNovedades }} | ||||||
|  |                 density="compact" | ||||||
|  |                 initialState={{ pagination: { paginationModel: { pageSize: 25 } } }} | ||||||
|  |                 pageSizeOptions={[25, 50, 100]} | ||||||
|  |                 disableRowSelectionOnClick | ||||||
|  |                 rowHeight={48} | ||||||
|  |                 sx={{ | ||||||
|  |                     '& .MuiDataGrid-cell': { overflow: 'hidden', textOverflow: 'ellipsis' }, | ||||||
|  |                     '& .MuiDataGrid-columnHeaderTitleContainer': { overflow: 'hidden' }, | ||||||
|  |                 }} | ||||||
|  |               /> | ||||||
|  |             </Paper> | ||||||
|  |           ) : ( | ||||||
|  |              <Typography sx={{mt:1, fontStyle:'italic'}}>No hay detalles de otras novedades para mostrar.</Typography> | ||||||
|  |           )} | ||||||
|  |         </> | ||||||
|  |       )} | ||||||
|  |        | ||||||
|  |       {!loading && !error && novedadesData.length === 0 && gananciasData.length === 0 && currentParams && ( | ||||||
|  |           <Typography sx={{mt:2, fontStyle:'italic'}}>No se encontraron datos para los criterios seleccionados.</Typography> | ||||||
|  |       )} | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default ReporteNovedadesCanillasPage; | ||||||
| @@ -21,12 +21,15 @@ const allReportModules: { category: string; label: string; path: string }[] = [ | |||||||
|   { category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas' }, |   { category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas' }, | ||||||
|   { category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores' }, |   { category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores' }, | ||||||
|   { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' }, |   { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' }, | ||||||
|  |   { category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' }, | ||||||
|  |   { category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' }, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| const predefinedCategoryOrder = [ | const predefinedCategoryOrder = [ | ||||||
|   'Balance de Cuentas', |   'Balance de Cuentas', | ||||||
|   'Listados Distribución', |   'Listados Distribución', | ||||||
|   'Ctrl. Devoluciones', |   'Ctrl. Devoluciones', | ||||||
|  |   'Novedades de Canillitas', | ||||||
|   'Existencia Papel', |   'Existencia Papel', | ||||||
|   'Movimientos Bobinas', |   'Movimientos Bobinas', | ||||||
|   'Consumos Bobinas', |   'Consumos Bobinas', | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ import { | |||||||
|     FormControl, InputLabel, Select, MenuItem |     FormControl, InputLabel, Select, MenuItem | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import empresaService from '../../services/Distribucion/empresaService'; | import empresaService from '../../services/Distribucion/empresaService'; | ||||||
| import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto'; | import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto'; | ||||||
|  |  | ||||||
| interface SeleccionaReporteControlDevolucionesProps { | interface SeleccionaReporteControlDevolucionesProps { | ||||||
|   onGenerarReporte: (params: { |   onGenerarReporte: (params: { | ||||||
| @@ -24,7 +24,7 @@ const SeleccionaReporteControlDevoluciones: React.FC<SeleccionaReporteControlDev | |||||||
| }) => { | }) => { | ||||||
|   const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]); |   const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]); | ||||||
|   const [idEmpresa, setIdEmpresa] = useState<number | string>(''); |   const [idEmpresa, setIdEmpresa] = useState<number | string>(''); | ||||||
|   const [empresas, setEmpresas] = useState<EmpresaDto[]>([]); |   const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]); | ||||||
|   const [loadingEmpresas, setLoadingEmpresas] = useState(false); |   const [loadingEmpresas, setLoadingEmpresas] = useState(false); | ||||||
|   const [localError, setLocalError] = useState<string | null>(null); |   const [localError, setLocalError] = useState<string | null>(null); | ||||||
|  |  | ||||||
| @@ -32,7 +32,7 @@ const SeleccionaReporteControlDevoluciones: React.FC<SeleccionaReporteControlDev | |||||||
|     const fetchEmpresas = async () => { |     const fetchEmpresas = async () => { | ||||||
|       setLoadingEmpresas(true); |       setLoadingEmpresas(true); | ||||||
|       try { |       try { | ||||||
|         const data = await empresaService.getAllEmpresas(); // Solo habilitadas |         const data = await empresaService.getEmpresasDropdown(); // Solo habilitadas | ||||||
|         setEmpresas(data); |         setEmpresas(data); | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.error("Error al cargar empresas:", error); |         console.error("Error al cargar empresas:", error); | ||||||
|   | |||||||
| @@ -3,9 +3,9 @@ import { | |||||||
|     Box, Typography, TextField, Button, CircularProgress, Alert, |     Box, Typography, TextField, Button, CircularProgress, Alert, | ||||||
|     FormControl, InputLabel, Select, MenuItem |     FormControl, InputLabel, Select, MenuItem | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto'; | import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto'; | ||||||
| import distribuidorService from '../../services/Distribucion/distribuidorService'; | import distribuidorService from '../../services/Distribucion/distribuidorService'; | ||||||
| import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto'; | import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto'; | ||||||
| import empresaService from '../../services/Distribucion/empresaService'; | import empresaService from '../../services/Distribucion/empresaService'; | ||||||
|  |  | ||||||
| interface SeleccionaReporteCuentasDistribuidoresProps { | interface SeleccionaReporteCuentasDistribuidoresProps { | ||||||
| @@ -30,8 +30,8 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD | |||||||
|   const [fechaDesde, setFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]); |   const [fechaDesde, setFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]); | ||||||
|   const [fechaHasta, setFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]); |   const [fechaHasta, setFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]); | ||||||
|  |  | ||||||
|   const [distribuidores, setDistribuidores] = useState<DistribuidorDto[]>([]); |   const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]); | ||||||
|   const [empresas, setEmpresas] = useState<EmpresaDto[]>([]); |   const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]); | ||||||
|   const [loadingDropdowns, setLoadingDropdowns] = useState(false); |   const [loadingDropdowns, setLoadingDropdowns] = useState(false); | ||||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|  |  | ||||||
| @@ -40,8 +40,8 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD | |||||||
|       setLoadingDropdowns(true); |       setLoadingDropdowns(true); | ||||||
|       try { |       try { | ||||||
|         const [distData, empData] = await Promise.all([ |         const [distData, empData] = await Promise.all([ | ||||||
|           distribuidorService.getAllDistribuidores(), // Asume que este servicio existe |           distribuidorService.getAllDistribuidoresDropdown(), // Asume que este servicio existe | ||||||
|           empresaService.getAllEmpresas()          // Asume que este servicio existe |           empresaService.getEmpresasDropdown()          // Asume que este servicio existe | ||||||
|         ]); |         ]); | ||||||
|         setDistribuidores(distData.map(d => d)); // El servicio devuelve tupla |         setDistribuidores(distData.map(d => d)); // El servicio devuelve tupla | ||||||
|         setEmpresas(empData); |         setEmpresas(empData); | ||||||
|   | |||||||
| @@ -0,0 +1,118 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { | ||||||
|  |     Box, Typography, TextField, Button, CircularProgress, Alert, | ||||||
|  |     FormControl, | ||||||
|  |     ToggleButtonGroup, ToggleButton, RadioGroup, FormControlLabel, Radio | ||||||
|  | } from '@mui/material'; | ||||||
|  |  | ||||||
|  | export type TipoListadoDistMensual = 'diarios' | 'publicaciones'; | ||||||
|  |  | ||||||
|  | interface SeleccionaReporteListadoDistMensualProps { | ||||||
|  |   onGenerarReporte: (params: { | ||||||
|  |     fechaDesde: string; // yyyy-MM-dd (primer día del mes) | ||||||
|  |     fechaHasta: string; // yyyy-MM-dd (último día del mes) | ||||||
|  |     esAccionista: boolean; | ||||||
|  |     tipoReporte: TipoListadoDistMensual; | ||||||
|  |   }) => Promise<void>; | ||||||
|  |   onCancel?: () => void; | ||||||
|  |   isLoading?: boolean; | ||||||
|  |   apiErrorMessage?: string | null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const SeleccionaReporteListadoDistMensual: React.FC<SeleccionaReporteListadoDistMensualProps> = ({ | ||||||
|  |   onGenerarReporte, | ||||||
|  |   isLoading, | ||||||
|  |   apiErrorMessage | ||||||
|  | }) => { | ||||||
|  |   const [mesAnio, setMesAnio] = useState<string>(new Date().toISOString().substring(0, 7)); // Formato "YYYY-MM" | ||||||
|  |   const [esAccionista, setEsAccionista] = useState<boolean>(false); // Default a Canillitas | ||||||
|  |   const [tipoReporte, setTipoReporte] = useState<TipoListadoDistMensual>('publicaciones'); // Default a Por Publicación | ||||||
|  |  | ||||||
|  |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|  |  | ||||||
|  |   const validate = (): boolean => { | ||||||
|  |     const errors: { [key: string]: string | null } = {}; | ||||||
|  |     if (!mesAnio) errors.mesAnio = 'Debe seleccionar un Mes/Año.'; | ||||||
|  |     // esAccionista y tipoReporte siempre tendrán un valor debido a los defaults y ToggleButton/RadioGroup | ||||||
|  |     setLocalErrors(errors); | ||||||
|  |     return Object.keys(errors).length === 0; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleGenerar = () => { | ||||||
|  |     if (!validate()) return; | ||||||
|  |  | ||||||
|  |     const [year, month] = mesAnio.split('-').map(Number); | ||||||
|  |     const fechaDesde = new Date(year, month - 1, 1).toISOString().split('T')[0]; | ||||||
|  |     const fechaHasta = new Date(year, month, 0).toISOString().split('T')[0]; // Último día del mes | ||||||
|  |  | ||||||
|  |     onGenerarReporte({ | ||||||
|  |       fechaDesde, | ||||||
|  |       fechaHasta, | ||||||
|  |       esAccionista, | ||||||
|  |       tipoReporte | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 380 }}> | ||||||
|  |       <Typography variant="h6" gutterBottom> | ||||||
|  |         Parámetros: Listado Distribución Mensual | ||||||
|  |       </Typography> | ||||||
|  |       <TextField | ||||||
|  |         label="Mes y Año" | ||||||
|  |         type="month" | ||||||
|  |         value={mesAnio} | ||||||
|  |         onChange={(e) => { setMesAnio(e.target.value); setLocalErrors(p => ({ ...p, mesAnio: null })); }} | ||||||
|  |         margin="normal" | ||||||
|  |         fullWidth | ||||||
|  |         required | ||||||
|  |         error={!!localErrors.mesAnio} | ||||||
|  |         helperText={localErrors.mesAnio} | ||||||
|  |         disabled={isLoading} | ||||||
|  |         InputLabelProps={{ shrink: true }} | ||||||
|  |       /> | ||||||
|  |  | ||||||
|  |       <FormControl component="fieldset" margin="normal" fullWidth disabled={isLoading}> | ||||||
|  |         <Typography component="legend" variant="subtitle2" sx={{mb:0.5, color: 'rgba(0, 0, 0, 0.6)'}}>Tipo de Vendedor</Typography> | ||||||
|  |         <ToggleButtonGroup | ||||||
|  |             color="primary" | ||||||
|  |             value={esAccionista ? 'accionistas' : 'canillitas'} | ||||||
|  |             exclusive | ||||||
|  |             onChange={(_, newValue) => { | ||||||
|  |               if (newValue !== null) setEsAccionista(newValue === 'accionistas'); | ||||||
|  |             }} | ||||||
|  |             aria-label="Tipo de Vendedor" | ||||||
|  |             size="small" | ||||||
|  |           > | ||||||
|  |             <ToggleButton value="canillitas">Canillitas</ToggleButton> | ||||||
|  |             <ToggleButton value="accionistas">Accionistas</ToggleButton> | ||||||
|  |         </ToggleButtonGroup> | ||||||
|  |       </FormControl> | ||||||
|  |  | ||||||
|  |       <FormControl component="fieldset" margin="normal" fullWidth disabled={isLoading}> | ||||||
|  |         <Typography component="legend" variant="subtitle2" sx={{mb:0.5, color: 'rgba(0, 0, 0, 0.6)'}}>Variante del Reporte</Typography> | ||||||
|  |         <RadioGroup | ||||||
|  |             row | ||||||
|  |             aria-label="Variante del Reporte" | ||||||
|  |             name="tipoReporte" | ||||||
|  |             value={tipoReporte} | ||||||
|  |             onChange={(e) => setTipoReporte(e.target.value as TipoListadoDistMensual)} | ||||||
|  |         > | ||||||
|  |             <FormControlLabel value="publicaciones" control={<Radio size="small" />} label="Por Publicación" /> | ||||||
|  |             <FormControlLabel value="diarios" control={<Radio size="small" />} label="Por Diarios (El Día/El Plata)" /> | ||||||
|  |         </RadioGroup> | ||||||
|  |       </FormControl> | ||||||
|  |  | ||||||
|  |  | ||||||
|  |       {apiErrorMessage && <Alert severity="error" sx={{ mt: 2 }}>{apiErrorMessage}</Alert>} | ||||||
|  |  | ||||||
|  |       <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||||
|  |         <Button onClick={handleGenerar} variant="contained" disabled={isLoading}> | ||||||
|  |           {isLoading ? <CircularProgress size={24} /> : 'Generar Reporte'} | ||||||
|  |         </Button> | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default SeleccionaReporteListadoDistMensual; | ||||||
| @@ -3,9 +3,9 @@ import { | |||||||
|     Box, Typography, TextField, Button, CircularProgress, Alert, |     Box, Typography, TextField, Button, CircularProgress, Alert, | ||||||
|     FormControl, InputLabel, Select, MenuItem |     FormControl, InputLabel, Select, MenuItem | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import type { PublicacionDto } from '../../models/dtos/Distribucion/PublicacionDto'; | import type { PublicacionDropdownDto } from '../../models/dtos/Distribucion/PublicacionDropdownDto'; | ||||||
| import publicacionService from '../../services/Distribucion/publicacionService'; | import publicacionService from '../../services/Distribucion/publicacionService'; | ||||||
| import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto'; | import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto'; | ||||||
| import distribuidorService from '../../services/Distribucion/distribuidorService'; | import distribuidorService from '../../services/Distribucion/distribuidorService'; | ||||||
|  |  | ||||||
| interface SeleccionaReporteListadoDistribucionProps { | interface SeleccionaReporteListadoDistribucionProps { | ||||||
| @@ -30,8 +30,8 @@ const SeleccionaReporteListadoDistribucion: React.FC<SeleccionaReporteListadoDis | |||||||
|   const [fechaDesde, setFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]); |   const [fechaDesde, setFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]); | ||||||
|   const [fechaHasta, setFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]); |   const [fechaHasta, setFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]); | ||||||
|  |  | ||||||
|   const [distribuidores, setDistribuidores] = useState<DistribuidorDto[]>([]); |   const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]); | ||||||
|   const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]); |   const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]); | ||||||
|   const [loadingDropdowns, setLoadingDropdowns] = useState(false); |   const [loadingDropdowns, setLoadingDropdowns] = useState(false); | ||||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|  |  | ||||||
| @@ -40,8 +40,8 @@ const SeleccionaReporteListadoDistribucion: React.FC<SeleccionaReporteListadoDis | |||||||
|       setLoadingDropdowns(true); |       setLoadingDropdowns(true); | ||||||
|       try { |       try { | ||||||
|         const [distData, pubData] = await Promise.all([ |         const [distData, pubData] = await Promise.all([ | ||||||
|           distribuidorService.getAllDistribuidores(), |           distribuidorService.getAllDistribuidoresDropdown(), | ||||||
|           publicacionService.getAllPublicaciones(undefined, undefined, true) // Solo habilitadas |           publicacionService.getPublicacionesForDropdown(true) // Solo habilitadas | ||||||
|         ]); |         ]); | ||||||
|         setDistribuidores(distData.map(d => d)); |         setDistribuidores(distData.map(d => d)); | ||||||
|         setPublicaciones(pubData.map(p => p)); |         setPublicaciones(pubData.map(p => p)); | ||||||
|   | |||||||
| @@ -0,0 +1,131 @@ | |||||||
|  | import React, { useState, useEffect } from 'react'; | ||||||
|  | import { | ||||||
|  |     Box, Typography, TextField, Button, CircularProgress, Alert, | ||||||
|  |     FormControl, InputLabel, Select, MenuItem | ||||||
|  | } from '@mui/material'; | ||||||
|  | import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto'; | ||||||
|  | import empresaService from '../../services/Distribucion/empresaService'; | ||||||
|  |  | ||||||
|  | interface SeleccionaReporteNovedadesCanillasProps { | ||||||
|  |   onGenerarReporte: (params: { | ||||||
|  |     idEmpresa: number; | ||||||
|  |     fechaDesde: string; | ||||||
|  |     fechaHasta: string; | ||||||
|  |   }) => Promise<void>; | ||||||
|  |   onCancel?: () => void; // Opcional si se usa dentro de ReportesIndexPage | ||||||
|  |   isLoading?: boolean; | ||||||
|  |   apiErrorMessage?: string | null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const SeleccionaReporteNovedadesCanillas: React.FC<SeleccionaReporteNovedadesCanillasProps> = ({ | ||||||
|  |   onGenerarReporte, | ||||||
|  |   // onCancel, | ||||||
|  |   isLoading, | ||||||
|  |   apiErrorMessage | ||||||
|  | }) => { | ||||||
|  |   const [idEmpresa, setIdEmpresa] = useState<number | string>(''); | ||||||
|  |   const [fechaDesde, setFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]); | ||||||
|  |   const [fechaHasta, setFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]); | ||||||
|  |  | ||||||
|  |   const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]); | ||||||
|  |   const [loadingDropdowns, setLoadingDropdowns] = useState(false); | ||||||
|  |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     const fetchEmpresas = async () => { | ||||||
|  |       setLoadingDropdowns(true); | ||||||
|  |       try { | ||||||
|  |         const data = await empresaService.getEmpresasDropdown(); | ||||||
|  |         setEmpresas(data); | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error("Error al cargar empresas:", error); | ||||||
|  |         setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar empresas.' })); | ||||||
|  |       } finally { | ||||||
|  |         setLoadingDropdowns(false); | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |     fetchEmpresas(); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const validate = (): boolean => { | ||||||
|  |     const errors: { [key: string]: string | null } = {}; | ||||||
|  |     if (!idEmpresa) errors.idEmpresa = 'Debe seleccionar una empresa.'; | ||||||
|  |     if (!fechaDesde) errors.fechaDesde = 'Fecha Desde es obligatoria.'; | ||||||
|  |     if (!fechaHasta) errors.fechaHasta = 'Fecha Hasta es obligatoria.'; | ||||||
|  |     if (fechaDesde && fechaHasta && new Date(fechaDesde) > new Date(fechaHasta)) { | ||||||
|  |       errors.fechaHasta = 'Fecha Hasta no puede ser anterior a Fecha Desde.'; | ||||||
|  |     } | ||||||
|  |     setLocalErrors(errors); | ||||||
|  |     return Object.keys(errors).length === 0; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleGenerar = () => { | ||||||
|  |     if (!validate()) return; | ||||||
|  |     onGenerarReporte({ | ||||||
|  |       idEmpresa: Number(idEmpresa), | ||||||
|  |       fechaDesde, | ||||||
|  |       fechaHasta | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 380 }}> | ||||||
|  |       <Typography variant="h6" gutterBottom> | ||||||
|  |         Parámetros: Reporte Novedades de Canillitas | ||||||
|  |       </Typography> | ||||||
|  |       <FormControl fullWidth margin="normal" error={!!localErrors.idEmpresa} disabled={isLoading || loadingDropdowns}> | ||||||
|  |         <InputLabel id="empresa-novedades-select-label" required>Empresa</InputLabel> | ||||||
|  |         <Select | ||||||
|  |           labelId="empresa-novedades-select-label" | ||||||
|  |           label="Empresa" | ||||||
|  |           value={idEmpresa} | ||||||
|  |           onChange={(e) => { setIdEmpresa(e.target.value as number); setLocalErrors(p => ({ ...p, idEmpresa: null })); }} | ||||||
|  |         > | ||||||
|  |           <MenuItem value="" disabled><em>Seleccione una empresa</em></MenuItem> | ||||||
|  |           {empresas.map((e) => ( | ||||||
|  |             <MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem> | ||||||
|  |           ))} | ||||||
|  |         </Select> | ||||||
|  |         {localErrors.idEmpresa && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idEmpresa}</Typography>} | ||||||
|  |       </FormControl> | ||||||
|  |       <TextField | ||||||
|  |         label="Fecha Desde" | ||||||
|  |         type="date" | ||||||
|  |         value={fechaDesde} | ||||||
|  |         onChange={(e) => { setFechaDesde(e.target.value); setLocalErrors(p => ({ ...p, fechaDesde: null, fechaHasta: null })); }} | ||||||
|  |         margin="normal" | ||||||
|  |         fullWidth | ||||||
|  |         required | ||||||
|  |         error={!!localErrors.fechaDesde} | ||||||
|  |         helperText={localErrors.fechaDesde} | ||||||
|  |         disabled={isLoading} | ||||||
|  |         InputLabelProps={{ shrink: true }} | ||||||
|  |       /> | ||||||
|  |       <TextField | ||||||
|  |         label="Fecha Hasta" | ||||||
|  |         type="date" | ||||||
|  |         value={fechaHasta} | ||||||
|  |         onChange={(e) => { setFechaHasta(e.target.value); setLocalErrors(p => ({ ...p, fechaHasta: null })); }} | ||||||
|  |         margin="normal" | ||||||
|  |         fullWidth | ||||||
|  |         required | ||||||
|  |         error={!!localErrors.fechaHasta} | ||||||
|  |         helperText={localErrors.fechaHasta} | ||||||
|  |         disabled={isLoading} | ||||||
|  |         InputLabelProps={{ shrink: true }} | ||||||
|  |       /> | ||||||
|  |  | ||||||
|  |       {apiErrorMessage && <Alert severity="error" sx={{ mt: 2 }}>{apiErrorMessage}</Alert>} | ||||||
|  |       {localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</Alert>} | ||||||
|  |  | ||||||
|  |       <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||||
|  |         {/* {onCancel && <Button onClick={onCancel} color="secondary" disabled={isLoading}>Cancelar</Button>} */} | ||||||
|  |         <Button onClick={handleGenerar} variant="contained" disabled={isLoading || loadingDropdowns}> | ||||||
|  |           {isLoading ? <CircularProgress size={24} /> : 'Generar Reporte'} | ||||||
|  |         </Button> | ||||||
|  |       </Box> | ||||||
|  |     </Box> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default SeleccionaReporteNovedadesCanillas; | ||||||
| @@ -8,20 +8,72 @@ import SaveIcon from '@mui/icons-material/Save'; | |||||||
| import perfilService from '../../services/Usuarios/perfilService'; | import perfilService from '../../services/Usuarios/perfilService'; | ||||||
| import type { PermisoAsignadoDto } from '../../models/dtos/Usuarios/PermisoAsignadoDto'; | import type { PermisoAsignadoDto } from '../../models/dtos/Usuarios/PermisoAsignadoDto'; | ||||||
| import type { PerfilDto } from '../../models/dtos/Usuarios/PerfilDto'; | import type { PerfilDto } from '../../models/dtos/Usuarios/PerfilDto'; | ||||||
| import { usePermissions } from '../../hooks/usePermissions'; // Para verificar si el usuario actual puede estar aquí | import { usePermissions as usePagePermissions } from '../../hooks/usePermissions'; // Renombrar para evitar conflicto | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| import PermisosChecklist from '../../components/Modals/Usuarios/PermisosChecklist'; // Importar el componente | 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; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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 AsignarPermisosAPerfilPage: React.FC = () => { | const AsignarPermisosAPerfilPage: React.FC = () => { | ||||||
|   const { idPerfil } = useParams<{ idPerfil: string }>(); |   const { idPerfil } = useParams<{ idPerfil: string }>(); | ||||||
|   const navigate = useNavigate(); |   const navigate = useNavigate(); | ||||||
|   const { tienePermiso, isSuperAdmin } = usePermissions(); |   const { tienePermiso: tienePermisoPagina, isSuperAdmin } = usePagePermissions(); // Renombrado | ||||||
|  |  | ||||||
|   const puedeAsignar = isSuperAdmin || tienePermiso("PU004"); |   const puedeAsignar = isSuperAdmin || tienePermisoPagina("PU004"); | ||||||
|  |  | ||||||
|   const [perfil, setPerfil] = useState<PerfilDto | null>(null); |   const [perfil, setPerfil] = useState<PerfilDto | null>(null); | ||||||
|   const [permisosDisponibles, setPermisosDisponibles] = useState<PermisoAsignadoDto[]>([]); |   const [permisosDisponibles, setPermisosDisponibles] = useState<PermisoAsignadoDto[]>([]); | ||||||
|   // Usamos un Set para los IDs de los permisos seleccionados para eficiencia |  | ||||||
|   const [permisosSeleccionados, setPermisosSeleccionados] = useState<Set<number>>(new Set()); |   const [permisosSeleccionados, setPermisosSeleccionados] = useState<Set<number>>(new Set()); | ||||||
|   const [loading, setLoading] = useState(true); |   const [loading, setLoading] = useState(true); | ||||||
|   const [saving, setSaving] = useState(false); |   const [saving, setSaving] = useState(false); | ||||||
| @@ -45,11 +97,10 @@ const AsignarPermisosAPerfilPage: React.FC = () => { | |||||||
|     try { |     try { | ||||||
|       const [perfilData, permisosData] = await Promise.all([ |       const [perfilData, permisosData] = await Promise.all([ | ||||||
|         perfilService.getPerfilById(idPerfilNum), |         perfilService.getPerfilById(idPerfilNum), | ||||||
|         perfilService.getPermisosPorPerfil(idPerfilNum) |         perfilService.getPermisosPorPerfil(idPerfilNum) // Esto devuelve todos los permisos con su estado 'asignado' | ||||||
|       ]); |       ]); | ||||||
|       setPerfil(perfilData); |       setPerfil(perfilData); | ||||||
|       setPermisosDisponibles(permisosData); |       setPermisosDisponibles(permisosData); | ||||||
|       // Inicializar los permisos seleccionados basados en los que vienen 'asignado: true' |  | ||||||
|       setPermisosSeleccionados(new Set(permisosData.filter(p => p.asignado).map(p => p.id))); |       setPermisosSeleccionados(new Set(permisosData.filter(p => p.asignado).map(p => p.id))); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       console.error(err); |       console.error(err); | ||||||
| @@ -66,22 +117,83 @@ const AsignarPermisosAPerfilPage: React.FC = () => { | |||||||
|     cargarDatos(); |     cargarDatos(); | ||||||
|   }, [cargarDatos]); |   }, [cargarDatos]); | ||||||
|  |  | ||||||
|   const handlePermisoChange = (permisoId: number, asignado: boolean) => { |   const handlePermisoChange = useCallback(( | ||||||
|     setPermisosSeleccionados(prev => { |     permisoId: number, | ||||||
|       const next = new Set(prev); |     asignadoViaCheckboxHijo: boolean, // Este valor es el 'e.target.checked' si el clic fue en un hijo | ||||||
|       if (asignado) { |     esPermisoSeccionClick = false, | ||||||
|         next.add(permisoId); |     moduloConceptualAsociado?: string // Este es el módulo conceptual del padre SSxxx o del grupo del hijo | ||||||
|       } else { |   ) => { | ||||||
|         next.delete(permisoId); |     setPermisosSeleccionados(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); | ||||||
|  |             }) | ||||||
|  |             : []; | ||||||
|  |  | ||||||
|  |         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)); | ||||||
|             } |             } | ||||||
|       return next; |  | ||||||
|     }); |         } else if (!esPermisoSeccionClick && moduloConceptualAsociado) { // Clic en un permiso hijo | ||||||
|     // Limpiar mensajes al cambiar selección |             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 (successMessage) setSuccessMessage(null); | ||||||
|         if (error) setError(null); |         if (error) setError(null); | ||||||
|   }; |         return newSelected; | ||||||
|  |     }); | ||||||
|  |   }, [permisosDisponibles, successMessage, error]); | ||||||
|  |  | ||||||
|  |  | ||||||
|   const handleGuardarCambios = async () => { |   const handleGuardarCambios = async () => { | ||||||
|  |     // ... (sin cambios) ... | ||||||
|     if (!puedeAsignar || !perfil) return; |     if (!puedeAsignar || !perfil) return; | ||||||
|     setSaving(true); setError(null); setSuccessMessage(null); |     setSaving(true); setError(null); setSuccessMessage(null); | ||||||
|     try { |     try { | ||||||
| @@ -89,8 +201,7 @@ const AsignarPermisosAPerfilPage: React.FC = () => { | |||||||
|         permisosIds: Array.from(permisosSeleccionados) |         permisosIds: Array.from(permisosSeleccionados) | ||||||
|       }); |       }); | ||||||
|       setSuccessMessage('Permisos actualizados correctamente.'); |       setSuccessMessage('Permisos actualizados correctamente.'); | ||||||
|       // Opcional: recargar datos, aunque el estado local ya está actualizado |       await cargarDatos(); | ||||||
|       // cargarDatos(); |  | ||||||
|     } catch (err: any) { |     } catch (err: any) { | ||||||
|       console.error(err); |       console.error(err); | ||||||
|       const message = axios.isAxiosError(err) && err.response?.data?.message |       const message = axios.isAxiosError(err) && err.response?.data?.message | ||||||
| @@ -106,17 +217,16 @@ const AsignarPermisosAPerfilPage: React.FC = () => { | |||||||
|         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>; |         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   if (error && !perfil) { // Si hay un error crítico al cargar el perfil |     if (error && !perfil) { | ||||||
|         return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>; |         return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>; | ||||||
|     } |     } | ||||||
|     if (!puedeAsignar) { |     if (!puedeAsignar) { | ||||||
|         return <Alert severity="error" sx={{ m: 2 }}>Acceso denegado.</Alert>; |         return <Alert severity="error" sx={{ m: 2 }}>Acceso denegado.</Alert>; | ||||||
|     } |     } | ||||||
|   if (!perfil) { // Si no hay error, pero el perfil es null después de cargar (no debería pasar si no hay error) |     if (!perfil && !loading) { | ||||||
|       return <Alert severity="warning" sx={{ m: 2 }}>Perfil no encontrado.</Alert>; |         return <Alert severity="warning" sx={{ m: 2 }}>Perfil no encontrado o error al cargar.</Alert>; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <Box sx={{ p: 1 }}> |         <Box sx={{ p: 1 }}> | ||||||
|             <Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/usuarios/perfiles')} sx={{ mb: 2 }}> |             <Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/usuarios/perfiles')} sx={{ mb: 2 }}> | ||||||
| @@ -129,10 +239,10 @@ const AsignarPermisosAPerfilPage: React.FC = () => { | |||||||
|                 ID Perfil: {perfil?.id} |                 ID Perfil: {perfil?.id} | ||||||
|             </Typography> |             </Typography> | ||||||
|  |  | ||||||
|       {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>} |             {error && !successMessage && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>} | ||||||
|             {successMessage && <Alert severity="success" sx={{ mb: 2 }}>{successMessage}</Alert>} |             {successMessage && <Alert severity="success" sx={{ mb: 2 }}>{successMessage}</Alert>} | ||||||
|  |  | ||||||
|       <Paper sx={{ p: 2, mt: 2 }}> |             <Paper sx={{ p: { xs: 1, sm: 2 }, mt: 2 }}> | ||||||
|                 <PermisosChecklist |                 <PermisosChecklist | ||||||
|                     permisosDisponibles={permisosDisponibles} |                     permisosDisponibles={permisosDisponibles} | ||||||
|                     permisosSeleccionados={permisosSeleccionados} |                     permisosSeleccionados={permisosSeleccionados} | ||||||
| @@ -154,5 +264,4 @@ const AsignarPermisosAPerfilPage: React.FC = () => { | |||||||
|         </Box> |         </Box> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default AsignarPermisosAPerfilPage; | export default AsignarPermisosAPerfilPage; | ||||||
| @@ -6,6 +6,7 @@ import HomePage from '../pages/HomePage'; | |||||||
| import { useAuth } from '../contexts/AuthContext'; | import { useAuth } from '../contexts/AuthContext'; | ||||||
| import MainLayout from '../layouts/MainLayout'; | import MainLayout from '../layouts/MainLayout'; | ||||||
| import { Typography } from '@mui/material'; | import { Typography } from '@mui/material'; | ||||||
|  | import SectionProtectedRoute from './SectionProtectedRoute'; | ||||||
|  |  | ||||||
| // Distribución | // Distribución | ||||||
| import DistribucionIndexPage from '../pages/Distribucion/DistribucionIndexPage'; | import DistribucionIndexPage from '../pages/Distribucion/DistribucionIndexPage'; | ||||||
| @@ -38,6 +39,7 @@ import ContablesIndexPage from '../pages/Contables/ContablesIndexPage'; | |||||||
| import GestionarTiposPagoPage from '../pages/Contables/GestionarTiposPagoPage'; | import GestionarTiposPagoPage from '../pages/Contables/GestionarTiposPagoPage'; | ||||||
| import GestionarPagosDistribuidorPage from '../pages/Contables/GestionarPagosDistribuidorPage'; | import GestionarPagosDistribuidorPage from '../pages/Contables/GestionarPagosDistribuidorPage'; | ||||||
| import GestionarNotasCDPage from '../pages/Contables/GestionarNotasCDPage'; | import GestionarNotasCDPage from '../pages/Contables/GestionarNotasCDPage'; | ||||||
|  | import GestionarSaldosPage from '../pages/Contables/GestionarSaldosPage'; | ||||||
|  |  | ||||||
| // Usuarios | // Usuarios | ||||||
| import UsuariosIndexPage from '../pages/Usuarios/UsuariosIndexPage'; // Crear este componente | import UsuariosIndexPage from '../pages/Usuarios/UsuariosIndexPage'; // Crear este componente | ||||||
| @@ -69,10 +71,14 @@ import ReporteComparativaConsumoBobinasPage from '../pages/Reportes/ReporteCompa | |||||||
| import ReporteCuentasDistribuidoresPage from '../pages/Reportes/ReporteCuentasDistribuidoresPage'; | import ReporteCuentasDistribuidoresPage from '../pages/Reportes/ReporteCuentasDistribuidoresPage'; | ||||||
| import ReporteListadoDistribucionPage from '../pages/Reportes/ReporteListadoDistribucionPage'; | import ReporteListadoDistribucionPage from '../pages/Reportes/ReporteListadoDistribucionPage'; | ||||||
| import ReporteControlDevolucionesPage from '../pages/Reportes/ReporteControlDevolucionesPage'; | import ReporteControlDevolucionesPage from '../pages/Reportes/ReporteControlDevolucionesPage'; | ||||||
|  | import GestionarNovedadesCanillaPage from '../pages/Distribucion/GestionarNovedadesCanillaPage'; | ||||||
|  | import ReporteNovedadesCanillasPage from '../pages/Reportes/ReporteNovedadesCanillasPage'; | ||||||
|  | import ReporteListadoDistMensualPage from '../pages/Reportes/ReporteListadoDistMensualPage'; | ||||||
|  |  | ||||||
| // Auditorias | // Auditorias | ||||||
| import GestionarAuditoriaUsuariosPage from '../pages/Usuarios/Auditoria/GestionarAuditoriaUsuariosPage'; | import GestionarAuditoriaUsuariosPage from '../pages/Usuarios/Auditoria/GestionarAuditoriaUsuariosPage'; | ||||||
|  |  | ||||||
|  |  | ||||||
| // --- ProtectedRoute y PublicRoute SIN CAMBIOS --- | // --- ProtectedRoute y PublicRoute SIN CAMBIOS --- | ||||||
| const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => { | const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => { | ||||||
|   const { isAuthenticated, isLoading } = useAuth(); |   const { isAuthenticated, isLoading } = useAuth(); | ||||||
| @@ -107,7 +113,7 @@ const MainLayoutWrapper: React.FC = () => ( | |||||||
| const AppRoutes = () => { | const AppRoutes = () => { | ||||||
|   return ( |   return ( | ||||||
|     <BrowserRouter> |     <BrowserRouter> | ||||||
|       <Routes> {/* Un solo <Routes> de nivel superior */} |       <Routes> | ||||||
|         <Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} /> |         <Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} /> | ||||||
|  |  | ||||||
|         {/* Rutas Protegidas que usan el MainLayout */} |         {/* Rutas Protegidas que usan el MainLayout */} | ||||||
| @@ -123,13 +129,21 @@ const AppRoutes = () => { | |||||||
|           <Route index element={<HomePage />} /> {/* Para la ruta exacta "/" */} |           <Route index element={<HomePage />} /> {/* Para la ruta exacta "/" */} | ||||||
|  |  | ||||||
|           {/* Módulo de Distribución (anidado) */} |           {/* Módulo de Distribución (anidado) */} | ||||||
|           <Route path="distribucion" element={<DistribucionIndexPage />}> |           <Route | ||||||
|  |             path="distribucion" | ||||||
|  |             element={ | ||||||
|  |               <SectionProtectedRoute requiredPermission="SS001" sectionName="Distribución"> | ||||||
|  |                 <DistribucionIndexPage /> | ||||||
|  |               </SectionProtectedRoute> | ||||||
|  |             } | ||||||
|  |           > | ||||||
|             <Route index element={<Navigate to="es-canillas" replace />} /> |             <Route index element={<Navigate to="es-canillas" replace />} /> | ||||||
|             <Route path="es-canillas" element={<GestionarEntradasSalidasCanillaPage />} /> |             <Route path="es-canillas" element={<GestionarEntradasSalidasCanillaPage />} /> | ||||||
|             <Route path="control-devoluciones" element={<GestionarControlDevolucionesPage />} /> |             <Route path="control-devoluciones" element={<GestionarControlDevolucionesPage />} /> | ||||||
|             <Route path="es-distribuidores" element={<GestionarEntradasSalidasDistPage />} /> |             <Route path="es-distribuidores" element={<GestionarEntradasSalidasDistPage />} /> | ||||||
|             <Route path="salidas-otros-destinos" element={<GestionarSalidasOtrosDestinosPage />} /> |             <Route path="salidas-otros-destinos" element={<GestionarSalidasOtrosDestinosPage />} /> | ||||||
|             <Route path="canillas" element={<GestionarCanillitasPage />} /> |             <Route path="canillas" element={<GestionarCanillitasPage />} /> | ||||||
|  |             <Route path="canillas/:idCanilla/novedades" element={<GestionarNovedadesCanillaPage />} /> | ||||||
|             <Route path="distribuidores" element={<GestionarDistribuidoresPage />} /> |             <Route path="distribuidores" element={<GestionarDistribuidoresPage />} /> | ||||||
|             <Route path="otros-destinos" element={<GestionarOtrosDestinosPage />} /> |             <Route path="otros-destinos" element={<GestionarOtrosDestinosPage />} /> | ||||||
|             <Route path="zonas" element={<GestionarZonasPage />} /> |             <Route path="zonas" element={<GestionarZonasPage />} /> | ||||||
| @@ -146,15 +160,28 @@ const AppRoutes = () => { | |||||||
|           </Route> |           </Route> | ||||||
|  |  | ||||||
|           {/* Módulo Contable (anidado) */} |           {/* Módulo Contable (anidado) */} | ||||||
|           <Route path="contables" element={<ContablesIndexPage />}> |           <Route | ||||||
|  |             path="contables" | ||||||
|  |             element={ | ||||||
|  |               <SectionProtectedRoute requiredPermission="SS002" sectionName="Contables"> | ||||||
|  |                 <ContablesIndexPage /> | ||||||
|  |               </SectionProtectedRoute> | ||||||
|  |             } | ||||||
|  |           > | ||||||
|             <Route index element={<Navigate to="tipos-pago" replace />} /> |             <Route index element={<Navigate to="tipos-pago" replace />} /> | ||||||
|             <Route path="tipos-pago" element={<GestionarTiposPagoPage />} /> |             <Route path="tipos-pago" element={<GestionarTiposPagoPage />} /> | ||||||
|             <Route path="pagos-distribuidores" element={<GestionarPagosDistribuidorPage />} /> |             <Route path="pagos-distribuidores" element={<GestionarPagosDistribuidorPage />} /> | ||||||
|             <Route path="notas-cd" element={<GestionarNotasCDPage />} /> |             <Route path="notas-cd" element={<GestionarNotasCDPage />} /> | ||||||
|  |             <Route path="gestion-saldos" element={<GestionarSaldosPage />} /> | ||||||
|           </Route> |           </Route> | ||||||
|  |  | ||||||
|           {/* Módulo de Impresión (anidado) */} |           {/* Módulo de Impresión (anidado) */} | ||||||
|           <Route path="impresion" element={<ImpresionIndexPage />}> |           <Route path="impresion" | ||||||
|  |             element={ | ||||||
|  |               <SectionProtectedRoute requiredPermission="SS003" sectionName="Impresión"> | ||||||
|  |                 <ImpresionIndexPage /> | ||||||
|  |               </SectionProtectedRoute>} | ||||||
|  |           > | ||||||
|             <Route index element={<Navigate to="plantas" replace />} /> |             <Route index element={<Navigate to="plantas" replace />} /> | ||||||
|             <Route path="plantas" element={<GestionarPlantasPage />} /> |             <Route path="plantas" element={<GestionarPlantasPage />} /> | ||||||
|             <Route path="tipos-bobina" element={<GestionarTiposBobinaPage />} /> |             <Route path="tipos-bobina" element={<GestionarTiposBobinaPage />} /> | ||||||
| @@ -164,7 +191,12 @@ const AppRoutes = () => { | |||||||
|           </Route> |           </Route> | ||||||
|  |  | ||||||
|           {/* Módulo de Reportes */} |           {/* Módulo de Reportes */} | ||||||
|           <Route path="reportes" element={<ReportesIndexPage />}> {/* Página principal del módulo */} |           <Route path="reportes" | ||||||
|  |             element={ | ||||||
|  |               <SectionProtectedRoute requiredPermission="SS004" sectionName="Reportes"> | ||||||
|  |                 <ReportesIndexPage /> | ||||||
|  |               </SectionProtectedRoute>} | ||||||
|  |           > | ||||||
|             <Route index element={<Typography sx={{ p: 2 }}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */} |             <Route index element={<Typography sx={{ p: 2 }}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */} | ||||||
|             <Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} /> |             <Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} /> | ||||||
|             <Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} /> |             <Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} /> | ||||||
| @@ -181,10 +213,17 @@ const AppRoutes = () => { | |||||||
|             <Route path="cuentas-distribuidores" element={<ReporteCuentasDistribuidoresPage />} /> |             <Route path="cuentas-distribuidores" element={<ReporteCuentasDistribuidoresPage />} /> | ||||||
|             <Route path="listado-distribucion-distribuidores" element={<ReporteListadoDistribucionPage />} /> |             <Route path="listado-distribucion-distribuidores" element={<ReporteListadoDistribucionPage />} /> | ||||||
|             <Route path="control-devoluciones" element={<ReporteControlDevolucionesPage />} /> |             <Route path="control-devoluciones" element={<ReporteControlDevolucionesPage />} /> | ||||||
|  |             <Route path="novedades-canillas" element={<ReporteNovedadesCanillasPage />} /> | ||||||
|  |             <Route path="listado-distribucion-mensual" element={<ReporteListadoDistMensualPage />} /> | ||||||
|           </Route> |           </Route> | ||||||
|  |  | ||||||
|           {/* Módulo de Radios (anidado) */} |           {/* Módulo de Radios (anidado) */} | ||||||
|           <Route path="radios" element={<RadiosIndexPage />}> |           <Route path="radios" | ||||||
|  |             element={ | ||||||
|  |               <SectionProtectedRoute requiredPermission="SS005" sectionName="Radios"> | ||||||
|  |                 <RadiosIndexPage /> | ||||||
|  |               </SectionProtectedRoute>} | ||||||
|  |           > | ||||||
|             <Route index element={<Navigate to="ritmos" replace />} /> |             <Route index element={<Navigate to="ritmos" replace />} /> | ||||||
|             <Route path="ritmos" element={<GestionarRitmosPage />} /> |             <Route path="ritmos" element={<GestionarRitmosPage />} /> | ||||||
|             <Route path="canciones" element={<GestionarCancionesPage />} /> |             <Route path="canciones" element={<GestionarCancionesPage />} /> | ||||||
| @@ -192,7 +231,12 @@ const AppRoutes = () => { | |||||||
|           </Route> |           </Route> | ||||||
|  |  | ||||||
|           {/* Módulo de Usuarios (anidado) */} |           {/* Módulo de Usuarios (anidado) */} | ||||||
|           <Route path="usuarios" element={<UsuariosIndexPage />}> |           <Route path="usuarios" | ||||||
|  |             element={ | ||||||
|  |               <SectionProtectedRoute requiredPermission="SS006" sectionName="Usuarios"> | ||||||
|  |                 <UsuariosIndexPage /> | ||||||
|  |               </SectionProtectedRoute>} | ||||||
|  |           > | ||||||
|             <Route index element={<Navigate to="perfiles" replace />} /> {/* Redirigir a la primera subpestaña */} |             <Route index element={<Navigate to="perfiles" replace />} /> {/* Redirigir a la primera subpestaña */} | ||||||
|             <Route path="perfiles" element={<GestionarPerfilesPage />} /> |             <Route path="perfiles" element={<GestionarPerfilesPage />} /> | ||||||
|             <Route path="permisos" element={<GestionarPermisosPage />} /> |             <Route path="permisos" element={<GestionarPermisosPage />} /> | ||||||
|   | |||||||
							
								
								
									
										52
									
								
								Frontend/src/routes/SectionProtectedRoute.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								Frontend/src/routes/SectionProtectedRoute.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | |||||||
|  | // src/routes/SectionProtectedRoute.tsx | ||||||
|  | import React from 'react'; | ||||||
|  | import { Navigate, Outlet } from 'react-router-dom'; | ||||||
|  | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { usePermissions } from '../hooks/usePermissions'; | ||||||
|  | import { Box, CircularProgress } from '@mui/material'; | ||||||
|  |  | ||||||
|  | interface SectionProtectedRouteProps { | ||||||
|  |   requiredPermission: string; | ||||||
|  |   sectionName: string; | ||||||
|  |   children?: React.ReactNode; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const SectionProtectedRoute: React.FC<SectionProtectedRouteProps> = ({ requiredPermission, sectionName, children }) => { | ||||||
|  |   const { isAuthenticated, isLoading: authIsLoading } = useAuth(); // isLoading de AuthContext | ||||||
|  |   const { tienePermiso, isSuperAdmin, currentUser } = usePermissions(); | ||||||
|  |  | ||||||
|  |   if (authIsLoading) { // Esperar a que el AuthContext termine su carga inicial | ||||||
|  |     return ( | ||||||
|  |       <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '80vh' }}> | ||||||
|  |         <CircularProgress /> | ||||||
|  |       </Box> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!isAuthenticated) { | ||||||
|  |     return <Navigate to="/login" replace />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // En este punto, si está autenticado, currentUser debería estar disponible. | ||||||
|  |   // Si currentUser pudiera ser null aun estando autenticado (poco probable con tu AuthContext), | ||||||
|  |   // se necesitaría un manejo adicional o un spinner aquí. | ||||||
|  |   if (!currentUser) { | ||||||
|  |     // Esto sería un estado inesperado si isAuthenticated es true. | ||||||
|  |     // Podrías redirigir a login o mostrar un error genérico. | ||||||
|  |     console.error("SectionProtectedRoute: Usuario autenticado pero currentUser es null."); | ||||||
|  |     return <Navigate to="/login" replace />; // O un error más específico | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const canAccessSection = isSuperAdmin || tienePermiso(requiredPermission); | ||||||
|  |  | ||||||
|  |   if (!canAccessSection) { | ||||||
|  |     console.error('SectionProtectedRoute: Usuario autenticado pero sin acceso a sección ', sectionName); | ||||||
|  |     return <Navigate to="/" replace />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Si children se proporciona (como <SectionProtectedRoute><IndexPage/></SectionProtectedRoute>), renderiza children. | ||||||
|  |   // Si no (como <Route element={<SectionProtectedRoute ... />} > <Route .../> </Route>), renderiza Outlet. | ||||||
|  |   return children ? <>{children}</> : <Outlet />; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default SectionProtectedRoute; | ||||||
							
								
								
									
										31
									
								
								Frontend/src/services/Contables/saldoService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								Frontend/src/services/Contables/saldoService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | import apiClient from '../apiClient'; | ||||||
|  | import type { SaldoGestionDto } from '../../models/dtos/Contables/SaldoGestionDto'; | ||||||
|  | import type { AjusteSaldoRequestDto } from '../../models/dtos/Contables/AjusteSaldoRequestDto'; | ||||||
|  |  | ||||||
|  | interface GetSaldosParams { | ||||||
|  |     destino?: 'Distribuidores' | 'Canillas' | ''; | ||||||
|  |     idDestino?: number | string; // Puede ser string si viene de un input antes de convertir | ||||||
|  |     idEmpresa?: number | string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const getAllSaldosGestion = async (filters?: GetSaldosParams): Promise<SaldoGestionDto[]> => { | ||||||
|  |     const params: Record<string, string | number> = {}; | ||||||
|  |     if (filters?.destino) params.destino = filters.destino; | ||||||
|  |     if (filters?.idDestino) params.idDestino = Number(filters.idDestino); // Asegurar número | ||||||
|  |     if (filters?.idEmpresa) params.idEmpresa = Number(filters.idEmpresa); // Asegurar número | ||||||
|  |  | ||||||
|  |     const response = await apiClient.get<SaldoGestionDto[]>('/saldos', { params }); | ||||||
|  |     return response.data; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const ajustarSaldo = async (data: AjusteSaldoRequestDto): Promise<SaldoGestionDto> => { // Esperamos el saldo actualizado | ||||||
|  |     const response = await apiClient.post<SaldoGestionDto>('/saldos/ajustar', data); | ||||||
|  |     return response.data; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const saldoService = { | ||||||
|  |     getAllSaldosGestion, | ||||||
|  |     ajustarSaldo, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default saldoService; | ||||||
| @@ -5,11 +5,17 @@ import type { UpdateCanillaDto } from '../../models/dtos/Distribucion/UpdateCani | |||||||
| import type { ToggleBajaCanillaDto } from '../../models/dtos/Distribucion/ToggleBajaCanillaDto'; | import type { ToggleBajaCanillaDto } from '../../models/dtos/Distribucion/ToggleBajaCanillaDto'; | ||||||
|  |  | ||||||
|  |  | ||||||
| const getAllCanillas = async (nomApeFilter?: string, legajoFilter?: number, soloActivos?: boolean): Promise<CanillaDto[]> => { | const getAllCanillas = async ( | ||||||
|  |     nomApeFilter?: string, | ||||||
|  |     legajoFilter?: number, | ||||||
|  |     soloActivos?: boolean, | ||||||
|  |     esAccionistaFilter?: boolean // Asegúrate que esté aquí | ||||||
|  | ): Promise<CanillaDto[]> => { | ||||||
|     const params: Record<string, string | number | boolean> = {}; |     const params: Record<string, string | number | boolean> = {}; | ||||||
|     if (nomApeFilter) params.nomApe = nomApeFilter; |     if (nomApeFilter) params.nomApe = nomApeFilter; | ||||||
|     if (legajoFilter !== undefined && legajoFilter !== null) params.legajo = legajoFilter; |     if (legajoFilter !== undefined && legajoFilter !== null) params.legajo = legajoFilter; | ||||||
|     if (soloActivos !== undefined) params.soloActivos = soloActivos; |     if (soloActivos !== undefined) params.soloActivos = soloActivos; | ||||||
|  |     if (esAccionistaFilter !== undefined) params.esAccionista = esAccionistaFilter; // <<-- ¡CLAVE! Verifica esto. | ||||||
|  |  | ||||||
|     const response = await apiClient.get<CanillaDto[]>('/canillas', { params }); |     const response = await apiClient.get<CanillaDto[]>('/canillas', { params }); | ||||||
|     return response.data; |     return response.data; | ||||||
|   | |||||||
| @@ -2,6 +2,8 @@ import apiClient from '../apiClient'; | |||||||
| import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto'; | import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto'; | ||||||
| import type { CreateDistribuidorDto } from '../../models/dtos/Distribucion/CreateDistribuidorDto'; | import type { CreateDistribuidorDto } from '../../models/dtos/Distribucion/CreateDistribuidorDto'; | ||||||
| import type { UpdateDistribuidorDto } from '../../models/dtos/Distribucion/UpdateDistribuidorDto'; | import type { UpdateDistribuidorDto } from '../../models/dtos/Distribucion/UpdateDistribuidorDto'; | ||||||
|  | import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto'; | ||||||
|  | import type { DistribuidorLookupDto } from '../../models/dtos/Distribucion/DistribuidorLookupDto'; | ||||||
|  |  | ||||||
| const getAllDistribuidores = async (nombreFilter?: string, nroDocFilter?: string): Promise<DistribuidorDto[]> => { | const getAllDistribuidores = async (nombreFilter?: string, nroDocFilter?: string): Promise<DistribuidorDto[]> => { | ||||||
|     const params: Record<string, string> = {}; |     const params: Record<string, string> = {}; | ||||||
| @@ -17,6 +19,11 @@ const getDistribuidorById = async (id: number): Promise<DistribuidorDto> => { | |||||||
|     return response.data; |     return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const getDistribuidorLookupById = async (id: number): Promise<DistribuidorLookupDto> => { | ||||||
|  |     const response = await apiClient.get<DistribuidorLookupDto>(`/distribuidores/${id}/lookup`); | ||||||
|  |     return response.data; | ||||||
|  | }; | ||||||
|  |  | ||||||
| const createDistribuidor = async (data: CreateDistribuidorDto): Promise<DistribuidorDto> => { | const createDistribuidor = async (data: CreateDistribuidorDto): Promise<DistribuidorDto> => { | ||||||
|     const response = await apiClient.post<DistribuidorDto>('/distribuidores', data); |     const response = await apiClient.post<DistribuidorDto>('/distribuidores', data); | ||||||
|     return response.data; |     return response.data; | ||||||
| @@ -30,12 +37,19 @@ const deleteDistribuidor = async (id: number): Promise<void> => { | |||||||
|     await apiClient.delete(`/distribuidores/${id}`); |     await apiClient.delete(`/distribuidores/${id}`); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const getAllDistribuidoresDropdown = async (): Promise<DistribuidorDropdownDto[]> => { | ||||||
|  |     const response = await apiClient.get<DistribuidorDropdownDto[]>('/distribuidores/dropdown'); | ||||||
|  |     return response.data; | ||||||
|  | }; | ||||||
|  |  | ||||||
| const distribuidorService = { | const distribuidorService = { | ||||||
|     getAllDistribuidores, |     getAllDistribuidores, | ||||||
|     getDistribuidorById, |     getDistribuidorById, | ||||||
|     createDistribuidor, |     createDistribuidor, | ||||||
|     updateDistribuidor, |     updateDistribuidor, | ||||||
|     deleteDistribuidor, |     deleteDistribuidor, | ||||||
|  |     getAllDistribuidoresDropdown, | ||||||
|  |     getDistribuidorLookupById, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default distribuidorService; | export default distribuidorService; | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user