Refinamiento de permisos y ajustes en controles. Añade gestión sobre saldos y visualización. Entre otros..

This commit is contained in:
2025-06-06 18:33:09 -03:00
parent 8fb94f8cef
commit 35e24ab7d2
104 changed files with 5917 additions and 1205 deletions

View File

@@ -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.");
}
}
}
}

View File

@@ -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}

View File

@@ -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)]

View File

@@ -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]

View File

@@ -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.");
}
}
}
}

View File

@@ -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."); }
}
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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);
}
} }
} }

View File

@@ -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);

View File

@@ -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 = @"

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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);
}
}

View File

@@ -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
);
}
}
}

View File

@@ -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);
} }
} }

View File

@@ -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
);
}
} }
} }

View 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; }
}

View File

@@ -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
}
}

View File

@@ -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; }
}
}

View File

@@ -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"
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,8 @@
namespace GestionIntegral.Api.Dtos.Distribucion
{
public class DistribuidorDropdownDto
{
public int IdDistribuidor { get; set; }
public string Nombre { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,8 @@
namespace GestionIntegral.Api.Dtos.Distribucion
{
public class DistribuidorLookupDto
{
public int IdDistribuidor { get; set; }
public string Nombre { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,8 @@
namespace GestionIntegral.Api.Dtos.Empresas
{
public class EmpresaDropdownDto
{
public int IdEmpresa { get; set; }
public string Nombre { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,8 @@
namespace GestionIntegral.Api.Dtos.Empresas
{
public class EmpresaLookupDto
{
public int IdEmpresa { get; set; }
public string Nombre { get; set; } = string.Empty;
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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).
}
}

View File

@@ -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; }
}
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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 NotaC/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();
}
}
} }
} }
} }

View File

@@ -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();
}
}
} }
} }
} }

View 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(); }
}
}
}
}

View File

@@ -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)
@@ -82,7 +82,7 @@ namespace GestionIntegral.Api.Services.Distribucion
if (createDto.Empresa != 0) // Solo validar empresa si no es 0 if (createDto.Empresa != 0) // Solo validar empresa si no es 0
{ {
var empresa = await _empresaRepository.GetByIdAsync(createDto.Empresa); var empresa = await _empresaRepository.GetByIdAsync(createDto.Empresa);
if(empresa == null) if (empresa == null)
{ {
return (null, "La empresa seleccionada no es válida."); return (null, "La empresa seleccionada no es válida.");
} }
@@ -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);
@@ -144,7 +152,7 @@ namespace GestionIntegral.Api.Services.Distribucion
} }
catch (Exception ex) catch (Exception ex)
{ {
try { transaction.Rollback(); } catch {} try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error CrearAsync Canilla: {NomApe}", createDto.NomApe); _logger.LogError(ex, "Error CrearAsync Canilla: {NomApe}", createDto.NomApe);
return (null, $"Error interno al crear el canillita: {ex.Message}"); return (null, $"Error interno al crear el canillita: {ex.Message}");
} }
@@ -166,7 +174,7 @@ namespace GestionIntegral.Api.Services.Distribucion
if (updateDto.Empresa != 0) // Solo validar empresa si no es 0 if (updateDto.Empresa != 0) // Solo validar empresa si no es 0
{ {
var empresa = await _empresaRepository.GetByIdAsync(updateDto.Empresa); var empresa = await _empresaRepository.GetByIdAsync(updateDto.Empresa);
if(empresa == null) if (empresa == null)
{ {
return (false, "La empresa seleccionada no es válida."); return (false, "La empresa seleccionada no es válida.");
} }
@@ -205,13 +213,14 @@ 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.");
} }
catch (Exception ex) catch (Exception ex)
{ {
try { transaction.Rollback(); } catch {} try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error ActualizarAsync Canilla ID: {IdCanilla}", id); _logger.LogError(ex, "Error ActualizarAsync Canilla ID: {IdCanilla}", id);
return (false, $"Error interno al actualizar el canillita: {ex.Message}"); return (false, $"Error interno al actualizar el canillita: {ex.Message}");
} }
@@ -240,13 +249,14 @@ 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.");
} }
catch (Exception ex) catch (Exception ex)
{ {
try { transaction.Rollback(); } catch {} try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error ToggleBajaAsync Canilla ID: {IdCanilla}", id); _logger.LogError(ex, "Error ToggleBajaAsync Canilla ID: {IdCanilla}", id);
return (false, $"Error interno al cambiar estado de baja: {ex.Message}"); return (false, $"Error interno al cambiar estado de baja: {ex.Message}");
} }

View File

@@ -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))

View File

@@ -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

View File

@@ -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);

View File

@@ -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);
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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);
} }
} }

View File

@@ -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).");
}
}
} }
} }

View File

@@ -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")]

View File

@@ -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":{}}

View File

@@ -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":{}}

View 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;

View File

@@ -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,16 +284,17 @@ 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 &&
((parseInt(item.cantSalida, 10) >= 0 && parseInt(item.cantEntrada, 10) >= 0) && (parseInt(item.cantSalida, 10) > 0 || parseInt(item.cantEntrada, 10) > 0) || item.observacion.trim() !== '') ( (parseInt(item.cantSalida, 10) >= 0 && parseInt(item.cantEntrada, 10) >= 0) && (parseInt(item.cantSalida, 10) > 0 || parseInt(item.cantEntrada, 10) > 0) || item.observacion.trim() !== '' )
) )
.map(item => ({ .map(item => ({
idPublicacion: Number(item.idPublicacion), idPublicacion: Number(item.idPublicacion),
@@ -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>}

View File

@@ -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;

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );

View File

@@ -0,0 +1,7 @@
export interface AjusteSaldoRequestDto {
destino: 'Distribuidores' | 'Canillas';
idDestino: number;
idEmpresa: number;
montoAjuste: number;
justificacion: string;
}

View 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
}

View File

@@ -0,0 +1,5 @@
export interface CreateNovedadCanillaDto {
idCanilla: number;
fecha: string; // string dd/MM/yyyy
detalle?: string | null;
}

View File

@@ -0,0 +1,4 @@
export interface DistribuidorDropdownDto {
idDistribuidor: number;
nombre: string;
}

View File

@@ -0,0 +1,4 @@
export interface DistribuidorLookupDto {
idDistribuidor: number;
nombre: string;
}

View File

@@ -0,0 +1,4 @@
export interface EmpresaDropdownDto {
idEmpresa: number;
nombre: string;
}

View File

@@ -0,0 +1,4 @@
export interface EmpresaLookupDto {
idEmpresa: number;
nombre: string;
}

View File

@@ -0,0 +1,7 @@
export interface NovedadCanillaDto {
idNovedad: number;
idCanilla: number;
nombreCanilla: string;
fecha: string; // string dd/MM/yyyy
detalle?: string | null;
}

View File

@@ -0,0 +1,3 @@
export interface UpdateNovedadCanillaDto {
detalle?: string | null;
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,8 @@
export interface ListadoDistCanMensualPubDto {
publicacion: string;
canilla: string;
totalCantSalida: number | null;
totalCantEntrada: number | null;
totalRendir: number | null;
id?: string; // Para DataGrid
}

View File

@@ -0,0 +1,6 @@
export interface NovedadesCanillasReporteDto {
nomApe: string;
fecha: string;
detalle?: string | null;
id?: string; // Para el DataGrid
}

View File

@@ -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' },
]; ];

View File

@@ -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");

View 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;

View File

@@ -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

View File

@@ -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';

View File

@@ -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;
@@ -96,7 +107,7 @@ const GestionarCanillitasPage: React.FC = () => {
try { try {
await canillaService.toggleBajaCanilla(canillita.idCanilla, { darDeBaja: !canillita.baja }); await canillaService.toggleBajaCanilla(canillita.idCanilla, { darDeBaja: !canillita.baja });
cargarCanillitas(); cargarCanillitas();
} catch (err:any) { } catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${accion} el canillita.`; const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${accion} el canillita.`;
setApiErrorMessage(message); setApiErrorMessage(message);
} }
@@ -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,33 +177,40 @@ 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 */}
{apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>} {error && !apiErrorMessage && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</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' }}>
<TableCell>{c.legajo || '-'}</TableCell><TableCell>{c.nomApe}</TableCell> <TableCell>{c.legajo || '-'}</TableCell><TableCell>{c.nomApe}</TableCell>
<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

View File

@@ -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>
); );

View File

@@ -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>
); );
}; };

View File

@@ -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;

View File

@@ -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>
); );

View File

@@ -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>)}

View File

@@ -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>

View File

@@ -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);
@@ -172,7 +179,7 @@ const ReporteControlDevolucionesPage: React.FC = () => {
dataForExcel.push([]); // Fila vacía para espaciado dataForExcel.push([]); // Fila vacía para espaciado
dataForExcel.push([ dataForExcel.push([
`Fecha Consultada: ${currentParams.fecha ? new Date(currentParams.fecha + 'T00:00:00').toLocaleDateString('es-AR', {timeZone:'UTC'}) : ''}`, `Fecha Consultada: ${currentParams.fecha ? new Date(currentParams.fecha + 'T00:00:00').toLocaleDateString('es-AR', { timeZone: 'UTC' }) : ''}`,
"", // Celda vacía para espaciado o para alinear con la segunda columna si fuera necesario "", // Celda vacía para espaciado o para alinear con la segunda columna si fuera necesario
`Cantidad Canillas: ${calculatedValues.cantidadCanillas}` `Cantidad Canillas: ${calculatedValues.cantidadCanillas}`
]); ]);
@@ -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}>

View File

@@ -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 }}>

View 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;

View File

@@ -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,12 +54,17 @@ 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);
setReportData(null); setReportData(null);
setTotalesDetalle({ llevados:0, devueltos:0, ventaNeta:0, promedioGeneralVentaNeta:0, porcentajeDevolucionGeneral:0 }); setTotalesDetalle({ llevados: 0, devueltos: 0, ventaNeta: 0, promedioGeneralVentaNeta: 0, porcentajeDevolucionGeneral: 0 });
setTotalesPromedios({ cantDias:0, promLlevados:0, promDevueltos:0, promVentas:0, porcentajeDevolucionGeneral:0}); setTotalesPromedios({ cantDias: 0, promLlevados: 0, promDevueltos: 0, promVentas: 0, porcentajeDevolucionGeneral: 0 });
const pubService = (await import('../../services/Distribucion/publicacionService')).default; const pubService = (await import('../../services/Distribucion/publicacionService')).default;
@@ -301,10 +309,10 @@ const ReporteListadoDistribucionPage: React.FC = () => {
}}> }}>
{/* Mantén esta estructura, pero quizás necesitas jugar con los minWidth/flex de los Typography */} {/* Mantén esta estructura, pero quizás necesitas jugar con los minWidth/flex de los Typography */}
<Typography variant="subtitle2" sx={{ flexBasis: columnsPromedios[0].width || 'auto', minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold' }}>Generales:</Typography> <Typography variant="subtitle2" sx={{ flexBasis: columnsPromedios[0].width || 'auto', minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold' }}>Generales:</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[1].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.llevados.toLocaleString('es-AR')}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[1].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.llevados.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.devueltos.toLocaleString('es-AR')}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.devueltos.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.ventaNeta.toLocaleString('es-AR')}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.ventaNeta.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.promedioGeneralVentaNeta.toLocaleString('es-AR', { minimumFractionDigits: 3, maximumFractionDigits: 3 })}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.promedioGeneralVentaNeta.toLocaleString('es-AR', { minimumFractionDigits: 3, maximumFractionDigits: 3 })}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesDetalle.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesDetalle.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography>
</Box> </Box>
</GridFooterContainer> </GridFooterContainer>
@@ -333,10 +341,10 @@ const ReporteListadoDistribucionPage: React.FC = () => {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}}> }}>
<Typography variant="subtitle2" sx={{ flexBasis: columnsPromedios[0].width || 'auto', minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold' }}>Generales:</Typography> <Typography variant="subtitle2" sx={{ flexBasis: columnsPromedios[0].width || 'auto', minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold' }}>Generales:</Typography>
<Typography variant="subtitle2" sx={{ width: columnsPromedios[1].width || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.cantDias.toLocaleString('es-AR')}</Typography> <Typography variant="subtitle2" sx={{ width: columnsPromedios[1].width || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.cantDias.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[2].flex, minWidth: columnsPromedios[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.promLlevados.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography> <Typography variant="subtitle2" sx={{ flex: columnsPromedios[2].flex, minWidth: columnsPromedios[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.promLlevados.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[3].flex, minWidth: columnsPromedios[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.promDevueltos.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography> <Typography variant="subtitle2" sx={{ flex: columnsPromedios[3].flex, minWidth: columnsPromedios[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.promDevueltos.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[4].flex, minWidth: columnsPromedios[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.promVentas.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography> <Typography variant="subtitle2" sx={{ flex: columnsPromedios[4].flex, minWidth: columnsPromedios[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.promVentas.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[5].flex, minWidth: columnsPromedios[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography> <Typography variant="subtitle2" sx={{ flex: columnsPromedios[5].flex, minWidth: columnsPromedios[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography>
</Box> </Box>
</GridFooterContainer> </GridFooterContainer>
@@ -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}>

View 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;

View File

@@ -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',

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,8 +191,13 @@ 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"
<Route index element={<Typography sx={{p:2}}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */} element={
<SectionProtectedRoute requiredPermission="SS004" sectionName="Reportes">
<ReportesIndexPage />
</SectionProtectedRoute>}
>
<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 />} />
<Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} /> <Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} />
@@ -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 />} />

View 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;

View 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;

View File

@@ -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;

View File

@@ -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