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]
[ProducesResponseType(typeof(IEnumerable<CanillaDto>), StatusCodes.Status200OK)]
[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();
var canillas = await _canillaService.ObtenerTodosAsync(nomApe, legajo, soloActivos);
return Ok(canillas);
var canillitas = await _canillaService.ObtenerTodosAsync(nomApe, legajo, soloActivos, esAccionista); // <<-- Pasa el parámetro
return Ok(canillitas);
}
// GET: api/canillas/{id}
@@ -117,7 +117,7 @@ namespace GestionIntegral.Api.Controllers.Distribucion
public async Task<IActionResult> ToggleBajaCanilla(int id, [FromBody] ToggleBajaCanillaDto bajaDto)
{
if (!TienePermiso(PermisoBaja)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
if (!ModelState.IsValid) return BadRequest(ModelState);
var idUsuario = GetCurrentUserId();
if (idUsuario == null) return Unauthorized();

View File

@@ -47,6 +47,15 @@ namespace GestionIntegral.Api.Controllers.Distribucion
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")]
[ProducesResponseType(typeof(DistribuidorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -59,6 +68,17 @@ namespace GestionIntegral.Api.Controllers.Distribucion
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]
[ProducesResponseType(typeof(DistribuidorDto), StatusCodes.Status201Created)]
[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}
// Permiso Requerido: DE001 (Ver Empresas)
[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
// Permiso Requerido: DE002 (Agregar Empresas)
[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.Linq;
using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Services.Distribucion;
namespace GestionIntegral.Api.Controllers
{
@@ -25,6 +26,7 @@ namespace GestionIntegral.Api.Controllers
private readonly IPublicacionRepository _publicacionRepository;
private readonly IEmpresaRepository _empresaRepository;
private readonly IDistribuidorRepository _distribuidorRepository; // Para obtener el nombre del distribuidor
private readonly INovedadCanillaService _novedadCanillaService;
// Permisos
@@ -36,22 +38,25 @@ namespace GestionIntegral.Api.Controllers
private const string PermisoVerBalanceCuentas = "RR001";
private const string PermisoVerReporteTiradas = "RR008";
private const string PermisoVerReporteConsumoBobinas = "RR007";
private const string PermisoVerReporteNovedadesCanillas = "RR004";
private const string PermisoVerReporteListadoDistMensual = "RR009";
public ReportesController(
IReportesService reportesService, // <--- CORREGIDO
IReportesService reportesService,
INovedadCanillaService novedadCanillaService,
ILogger<ReportesController> logger,
IPlantaRepository plantaRepository,
IPublicacionRepository publicacionRepository,
IEmpresaRepository empresaRepository,
IDistribuidorRepository distribuidorRepository) // Añadido
IDistribuidorRepository distribuidorRepository)
{
_reportesService = reportesService; // <--- CORREGIDO
_reportesService = reportesService;
_novedadCanillaService = novedadCanillaService;
_logger = logger;
_plantaRepository = plantaRepository;
_publicacionRepository = publicacionRepository;
_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);
@@ -1457,5 +1462,277 @@ namespace GestionIntegral.Api.Controllers
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.Collections.Generic; // Para IEnumerable
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
{
@@ -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)
Task<bool> ModificarSaldoAsync(string destino, int idDestino, int idEmpresa, decimal montoAAgregar, IDbTransaction? transaction = null);
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

@@ -1,10 +1,12 @@
using Dapper;
using GestionIntegral.Api.Data.Repositories;
using GestionIntegral.Api.Data.Repositories;
using GestionIntegral.Api.Models;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
using GestionIntegral.Api.Models.Contables;
using System.Text;
namespace GestionIntegral.Api.Data.Repositories.Contables
{
@@ -57,61 +59,64 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
public async Task<bool> DeleteSaldosByEmpresaAsync(int idEmpresa, IDbTransaction transaction)
{
var sql = "DELETE FROM dbo.cue_Saldos WHERE Id_Empresa = @IdEmpresa";
try
{
await transaction.Connection!.ExecuteAsync(sql, new { IdEmpresa = idEmpresa }, transaction: transaction);
return true; // Asumir éxito si no hay excepción
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al eliminar saldos para Empresa ID {IdEmpresa}.", idEmpresa);
throw;
}
var sql = "DELETE FROM dbo.cue_Saldos WHERE Id_Empresa = @IdEmpresa";
try
{
await transaction.Connection!.ExecuteAsync(sql, new { IdEmpresa = idEmpresa }, transaction: transaction);
return true; // Asumir éxito si no hay excepción
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al eliminar saldos para Empresa ID {IdEmpresa}.", idEmpresa);
throw;
}
}
public async Task<bool> ModificarSaldoAsync(string destino, int idDestino, int idEmpresa, decimal montoAAgregar, IDbTransaction? transaction = null)
{
var sql = @"UPDATE dbo.cue_Saldos
SET Monto = Monto + @MontoAAgregar
WHERE Destino = @Destino AND Id_Destino = @IdDestino AND Id_Empresa = @IdEmpresa;";
{
var sql = @"UPDATE dbo.cue_Saldos
SET Monto = Monto + @MontoAAgregar,
FechaUltimaModificacion = @FechaActualizacion -- << AÑADIR
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();
bool ownConnection = transaction == null; // Saber si necesitamos cerrar la conexión nosotros
IDbConnection connection = transaction?.Connection ?? _connectionFactory.CreateConnection();
bool ownConnection = transaction == null;
try
{
if (ownConnection) await (connection as System.Data.Common.DbConnection)!.OpenAsync(); // Abrir solo si no hay transacción externa
try
{
if (ownConnection && connection.State != ConnectionState.Open)
{
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
}
var parameters = new {
MontoAAgregar = montoAAgregar,
Destino = destino,
IdDestino = idDestino,
IdEmpresa = idEmpresa
};
// Aplicar '!' aquí también si viene de la transacción
int rowsAffected = await connection.ExecuteAsync(sql, parameters, transaction: transaction);
return rowsAffected == 1;
}
catch (Exception ex)
{
_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
return false; // Devolver false si fue una operación aislada que falló
}
finally
{
// Cerrar la conexión solo si la abrimos nosotros (no había transacción externa)
if (ownConnection && connection.State == ConnectionState.Open)
{
await (connection as System.Data.Common.DbConnection)!.CloseAsync();
}
// Disponer de la conexión si la creamos nosotros
if(ownConnection) (connection as IDisposable)?.Dispose();
}
}
public async Task<bool> CheckIfSaldosExistForEmpresaAsync(int idEmpresa)
var parameters = new
{
MontoAAgregar = montoAAgregar,
Destino = destino,
IdDestino = idDestino,
IdEmpresa = idEmpresa,
FechaActualizacion = DateTime.Now // O DateTime.UtcNow si prefieres
};
int rowsAffected = await connection.ExecuteAsync(sql, parameters, transaction: transaction);
return rowsAffected == 1;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al modificar saldo para {Destino} ID {IdDestino}, Empresa ID {IdEmpresa}.", destino, idDestino, idEmpresa);
if (transaction != null) throw;
return false;
}
finally
{
if (ownConnection && connection.State == ConnectionState.Open)
{
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close();
}
if (ownConnection && connection is IDisposable d) d.Dispose(); // Mejorar dispose
}
}
public async Task<bool> CheckIfSaldosExistForEmpresaAsync(int idEmpresa)
{
var sql = "SELECT COUNT(1) FROM dbo.cue_Saldos WHERE Id_Empresa = @IdEmpresa";
try
@@ -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.
}
}
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;
}
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(@"
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,
z.Nombre AS NombreZona,
ISNULL(e.Nombre, 'N/A (Accionista)') AS NombreEmpresa
FROM dbo.dist_dtCanillas c
INNER JOIN dbo.dist_dtZonas z ON c.Id_Zona = z.Id_Zona
LEFT JOIN dbo.dist_dtEmpresas e ON c.Empresa = e.Id_Empresa
WHERE 1=1");
using var connection = _connectionFactory.CreateConnection();
var sqlBuilder = new System.Text.StringBuilder(@"
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,
z.Nombre AS NombreZona,
e.Nombre AS NombreEmpresa
FROM dbo.dist_dtCanillas c
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
WHERE 1=1 "); // Cláusula base para añadir AND fácilmente
var parameters = new DynamicParameters();
if (soloActivos.HasValue)
{
sqlBuilder.Append(soloActivos.Value ? " AND c.Baja = 0" : " AND c.Baja = 1");
}
if (!string.IsNullOrWhiteSpace(nomApeFilter))
{
sqlBuilder.Append(" AND c.NomApe LIKE @NomApeParam");
parameters.Add("NomApeParam", $"%{nomApeFilter}%");
sqlBuilder.Append(" AND c.NomApe LIKE @NomApeFilter ");
parameters.Add("NomApeFilter", $"%{nomApeFilter}%");
}
if (legajoFilter.HasValue)
{
sqlBuilder.Append(" AND c.Legajo = @LegajoParam");
parameters.Add("LegajoParam", legajoFilter.Value);
sqlBuilder.Append(" AND c.Legajo = @LegajoFilter ");
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;");
try
{
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Canilla, string, string, (Canilla, string, string)>(
sqlBuilder.ToString(),
(canilla, nombreZona, nombreEmpresa) => (canilla, nombreZona, nombreEmpresa),
parameters,
splitOn: "NombreZona,NombreEmpresa"
);
}
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)>();
}
var result = await connection.QueryAsync<Canilla, string, string, (Canilla, string?, string?)>(
sqlBuilder.ToString(),
(can, zona, emp) => (can, zona, emp),
parameters,
splitOn: "NombreZona,NombreEmpresa"
);
return result;
}
public async Task<(Canilla? Canilla, string? NombreZona, string? NombreEmpresa)> GetByIdAsync(int id)
@@ -83,12 +88,12 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
try
{
using var connection = _connectionFactory.CreateConnection();
var result = await connection.QueryAsync<Canilla, string, string, (Canilla?, string?, string?)>(
sql,
(canilla, nombreZona, nombreEmpresa) => (canilla, nombreZona, nombreEmpresa),
new { IdParam = id },
splitOn: "NombreZona,NombreEmpresa"
);
var result = await connection.QueryAsync<Canilla, string, string, (Canilla?, string?, string?)>(
sql,
(canilla, nombreZona, nombreEmpresa) => (canilla, nombreZona, nombreEmpresa),
new { IdParam = id },
splitOn: "NombreZona,NombreEmpresa"
);
return result.SingleOrDefault();
}
catch (Exception ex)
@@ -160,9 +165,19 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
await connection.ExecuteAsync(sqlInsertHistorico, new
{
IdCanillaParam = insertedCanilla.IdCanilla, LegajoParam = insertedCanilla.Legajo, 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"
IdCanillaParam = insertedCanilla.IdCanilla,
LegajoParam = insertedCanilla.Legajo,
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);
return insertedCanilla;
}
@@ -173,7 +188,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
var canillaActual = await connection.QuerySingleOrDefaultAsync<Canilla>(
@"SELECT Id_Canilla AS IdCanilla, Legajo, NomApe, Parada, Id_Zona AS IdZona,
Accionista, Obs, Empresa, Baja, FechaBaja
FROM dbo.dist_dtCanillas WHERE Id_Canilla = @IdCanillaParam",
FROM dbo.dist_dtCanillas WHERE Id_Canilla = @IdCanillaParam",
new { IdCanillaParam = canillaAActualizar.IdCanilla }, transaction);
if (canillaActual == null) throw new KeyNotFoundException("Canilla no encontrado para actualizar.");
@@ -187,13 +202,21 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
(Id_Canilla, Legajo, NomApe, Parada, Id_Zona, Accionista, Obs, Empresa, Baja, FechaBaja, Id_Usuario, FechaMod, TipoMod)
VALUES (@IdCanillaParam, @LegajoParam, @NomApeParam, @ParadaParam, @IdZonaParam, @AccionistaParam, @ObsParam, @EmpresaParam, @BajaParam, @FechaBajaParam, @Id_UsuarioParam, @FechaModParam, @TipoModParam);";
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,
AccionistaParam = canillaActual.Accionista, ObsParam = canillaActual.Obs, EmpresaParam = canillaActual.Empresa,
BajaParam = canillaActual.Baja, FechaBajaParam = canillaActual.FechaBaja,
Id_UsuarioParam = idUsuario, FechaModParam = DateTime.Now, TipoModParam = "Actualizado"
LegajoParam = canillaActual.Legajo,
NomApeParam = canillaActual.NomApe,
ParadaParam = canillaActual.Parada,
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);
var rowsAffected = await connection.ExecuteAsync(sqlUpdate, canillaAActualizar, transaction);
@@ -206,7 +229,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
var canillaActual = await connection.QuerySingleOrDefaultAsync<Canilla>(
@"SELECT Id_Canilla AS IdCanilla, Legajo, NomApe, Parada, Id_Zona AS IdZona,
Accionista, Obs, Empresa, Baja, FechaBaja
FROM dbo.dist_dtCanillas WHERE Id_Canilla = @IdCanillaParam",
FROM dbo.dist_dtCanillas WHERE Id_Canilla = @IdCanillaParam",
new { IdCanillaParam = id }, transaction);
if (canillaActual == null) throw new KeyNotFoundException("Canilla no encontrado para dar de baja/alta.");
@@ -218,10 +241,19 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
await connection.ExecuteAsync(sqlInsertHistorico, new
{
IdCanillaParam = canillaActual.IdCanilla, LegajoParam = canillaActual.Legajo, NomApeParam = canillaActual.NomApe, 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")
IdCanillaParam = canillaActual.IdCanilla,
LegajoParam = canillaActual.Legajo,
NomApeParam = canillaActual.NomApe,
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);
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 GestionIntegral.Api.Dtos.Distribucion;
using GestionIntegral.Api.Models.Distribucion;
using Microsoft.Extensions.Logging;
using System; // Añadido para Exception
@@ -61,6 +62,30 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
return Enumerable.Empty<(Distribuidor, string?)>();
}
}
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)
{
@@ -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)
{
const string sql = @"

View File

@@ -1,5 +1,6 @@
using Dapper;
using GestionIntegral.Api.Data.Repositories;
using GestionIntegral.Api.Dtos.Empresas;
using GestionIntegral.Api.Models.Distribucion;
using System.Collections.Generic;
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)
{
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)
{
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
await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new {
await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new
{
IdEmpresa = insertedEmpresa.IdEmpresa,
insertedEmpresa.Nombre,
insertedEmpresa.Detalle,
@@ -172,7 +210,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
VALUES (@IdEmpresa, @NombreActual, @DetalleActual, @IdUsuario, @FechaMod, @TipoMod);";
// Insertar en historial (estado anterior)
await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new {
await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new
{
IdEmpresa = empresaActual.IdEmpresa,
NombreActual = empresaActual.Nombre,
DetalleActual = empresaActual.Detalle,
@@ -182,7 +221,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
}, transaction: transaction);
// Actualizar principal
var rowsAffected = await transaction.Connection!.ExecuteAsync(sqlUpdate, new {
var rowsAffected = await transaction.Connection!.ExecuteAsync(sqlUpdate, new
{
empresaAActualizar.Nombre,
empresaAActualizar.Detalle,
empresaAActualizar.IdEmpresa
@@ -202,7 +242,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
VALUES (@IdEmpresa, @Nombre, @Detalle, @IdUsuario, @FechaMod, @TipoMod);";
// Insertar en historial (estado antes de borrar)
await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new {
await transaction.Connection!.ExecuteAsync(sqlInsertHistorico, new
{
IdEmpresa = empresaActual.IdEmpresa,
empresaActual.Nombre,
empresaActual.Detalle,

View File

@@ -7,7 +7,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
{
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?> GetByIdSimpleAsync(int id); // Para obtener solo la entidad Canilla
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.Threading.Tasks;
using System.Data;
using GestionIntegral.Api.Dtos.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> ExistsByNameAsync(string nombre, int? excludeIdDistribuidor = null);
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 System.Collections.Generic;
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
{
@@ -14,5 +15,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
Task<bool> DeleteAsync(int id, int idUsuario, IDbTransaction transaction); // Necesita transacción
Task<bool> ExistsByNameAsync(string nombre, int? excludeId = null);
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<LiquidacionCanillaDetalleDto>> GetLiquidacionCanillaDetalleAsync(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

@@ -481,39 +481,71 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
}
public async Task<IEnumerable<LiquidacionCanillaDetalleDto>> GetLiquidacionCanillaDetalleAsync(DateTime fecha, int idCanilla)
{
const string spName = "dbo.SP_DistCanillasLiquidacion";
var parameters = new DynamicParameters();
parameters.Add("@fecha", fecha, DbType.DateTime);
parameters.Add("@idCanilla", idCanilla, DbType.Int32);
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<LiquidacionCanillaDetalleDto>(spName, parameters, commandType: CommandType.StoredProcedure);
{
const string spName = "dbo.SP_DistCanillasLiquidacion";
var parameters = new DynamicParameters();
parameters.Add("@fecha", fecha, DbType.DateTime);
parameters.Add("@idCanilla", idCanilla, DbType.Int32);
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<LiquidacionCanillaDetalleDto>(spName, parameters, commandType: CommandType.StoredProcedure);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error SP {SPName} para Liquidacion Canilla Detalle. Fecha: {Fecha}, Canilla: {IdCanilla}", spName, fecha, idCanilla);
return Enumerable.Empty<LiquidacionCanillaDetalleDto>();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error SP {SPName} para Liquidacion Canilla Detalle. Fecha: {Fecha}, Canilla: {IdCanilla}", spName, fecha, idCanilla);
return Enumerable.Empty<LiquidacionCanillaDetalleDto>();
}
}
public async Task<IEnumerable<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla)
{
const string spName = "dbo.SP_DistCanillasLiquidacionGanancias";
var parameters = new DynamicParameters();
parameters.Add("@fecha", fecha, DbType.DateTime);
parameters.Add("@idCanilla", idCanilla, DbType.Int32);
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<LiquidacionCanillaGananciaDto>(spName, parameters, commandType: CommandType.StoredProcedure);
public async Task<IEnumerable<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla)
{
const string spName = "dbo.SP_DistCanillasLiquidacionGanancias";
var parameters = new DynamicParameters();
parameters.Add("@fecha", fecha, DbType.DateTime);
parameters.Add("@idCanilla", idCanilla, DbType.Int32);
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<LiquidacionCanillaGananciaDto>(spName, parameters, commandType: CommandType.StoredProcedure);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error SP {SPName} para Liquidacion Canilla Ganancias. Fecha: {Fecha}, Canilla: {IdCanilla}", spName, fecha, idCanilla);
return Enumerable.Empty<LiquidacionCanillaGananciaDto>();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error SP {SPName} para Liquidacion Canilla Ganancias. Fecha: {Fecha}, Canilla: {IdCanilla}", spName, fecha, idCanilla);
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<ICancionService, CancionService>();
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
builder.Services.AddScoped<IReportesRepository, ReportesRepository>();
// 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
// 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")
{
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";
}
@@ -95,7 +95,6 @@ namespace GestionIntegral.Api.Services.Contables
public async Task<(NotaCreditoDebitoDto? Nota, string? Error)> CrearAsync(CreateNotaDto createDto, int idUsuario)
{
// Validar Destinatario
if (createDto.Destino == "Distribuidores")
{
if (await _distribuidorRepo.GetByIdSimpleAsync(createDto.IdDestino) == null)
@@ -103,7 +102,7 @@ namespace GestionIntegral.Api.Services.Contables
}
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.");
}
else { return (null, "Tipo de destino inválido."); }
@@ -124,19 +123,29 @@ namespace GestionIntegral.Api.Services.Contables
};
using var connection = _connectionFactory.CreateConnection();
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
using var transaction = connection.BeginTransaction();
IDbTransaction? transaction = null;
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);
if (notaCreada == null) throw new DataException("Error al registrar la nota.");
// Afectar Saldo
// Nota de Crédito: Disminuye la deuda del destinatario (monto positivo para el servicio de saldo)
// 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;
decimal montoParaSaldo;
if (createDto.Tipo == "Credito")
{
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}.");
transaction.Commit();
@@ -145,32 +154,57 @@ namespace GestionIntegral.Api.Services.Contables
}
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.");
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)
{
using var connection = _connectionFactory.CreateConnection();
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
using var transaction = connection.BeginTransaction();
IDbTransaction? transaction = null;
try
{
var notaExistente = await _notaRepo.GetByIdAsync(idNota);
if (notaExistente == null) return (false, "Nota no encontrada.");
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);
if (notaExistente == null)
{
transaction.Rollback();
return (false, "Nota no encontrada.");
}
decimal impactoOriginalSaldo = notaExistente.Tipo == "Credito" ? -notaExistente.Monto : notaExistente.Monto;
decimal impactoNuevoSaldo = notaExistente.Tipo == "Credito" ? -updateDto.Monto : updateDto.Monto;
decimal diferenciaAjusteSaldo = impactoNuevoSaldo - impactoOriginalSaldo;
// Calcular diferencia de monto para ajustar saldo
decimal montoOriginal = notaExistente.Tipo == "Credito" ? notaExistente.Monto : -notaExistente.Monto;
decimal montoNuevo = notaExistente.Tipo == "Credito" ? updateDto.Monto : -updateDto.Monto; // Tipo no cambia
decimal diferenciaAjusteSaldo = montoNuevo - montoOriginal;
var notaParaActualizarEnRepo = new NotaCreditoDebito
{
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
};
notaExistente.Monto = updateDto.Monto;
notaExistente.Observaciones = updateDto.Observaciones;
var actualizado = await _notaRepo.UpdateAsync(notaExistente, idUsuario, transaction);
if (!actualizado) throw new DataException("Error al actualizar la nota.");
var actualizado = await _notaRepo.UpdateAsync(notaParaActualizarEnRepo, idUsuario, transaction);
if (!actualizado) throw new DataException("Error al actualizar la nota en la base de datos.");
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);
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)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error ActualizarAsync NotaC/D ID: {Id}", idNota);
try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ActualizarAsync NotaCreditoDebito."); }
_logger.LogError(ex, "Error ActualizarAsync Nota C/D ID: {Id}", idNota);
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)
{
using var connection = _connectionFactory.CreateConnection();
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
using var transaction = connection.BeginTransaction();
IDbTransaction? transaction = null;
try
{
var notaExistente = await _notaRepo.GetByIdAsync(idNota);
if (notaExistente == null) return (false, "Nota no encontrada.");
if (connection.State != ConnectionState.Open)
{
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
}
transaction = connection.BeginTransaction();
// Revertir el efecto en el saldo
decimal montoReversion = notaExistente.Tipo == "Credito" ? -notaExistente.Monto : notaExistente.Monto;
var notaExistente = await _notaRepo.GetByIdAsync(idNota);
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);
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);
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);
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)
{
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);
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))
return (null, $"Ya existe un pago '{createDto.TipoMovimiento}' con el número de recibo '{createDto.Recibo}'.");
var nuevoPago = new PagoDistribuidor
{
IdDistribuidor = createDto.IdDistribuidor,
@@ -109,19 +108,29 @@ namespace GestionIntegral.Api.Services.Contables
};
using var connection = _connectionFactory.CreateConnection();
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
using var transaction = connection.BeginTransaction();
IDbTransaction? transaction = null; // Declarar fuera para el finally
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);
if (pagoCreado == null) throw new DataException("Error al registrar el pago.");
// Afectar Saldo
// Si TipoMovimiento es "Recibido", el monto DISMINUYE la deuda del distribuidor (monto positivo para el servicio de saldo).
// 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;
decimal montoParaSaldo;
if (createDto.TipoMovimiento == "Recibido")
{
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.");
transaction.Commit();
@@ -130,37 +139,63 @@ namespace GestionIntegral.Api.Services.Contables
}
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.");
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)
{
using var connection = _connectionFactory.CreateConnection();
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
using var transaction = connection.BeginTransaction();
IDbTransaction? transaction = null;
try
{
var pagoExistente = await _pagoRepo.GetByIdAsync(idPago);
if (pagoExistente == null) return (false, "Pago no encontrado.");
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);
if (pagoExistente == null)
{
transaction.Rollback(); // Rollback si no se encuentra
return (false, "Pago no encontrado.");
}
if (await _tipoPagoRepo.GetByIdAsync(updateDto.IdTipoPago) == null)
{
transaction.Rollback();
return (false, "Tipo de pago no válido.");
}
decimal impactoOriginalSaldo = pagoExistente.TipoMovimiento == "Recibido" ? -pagoExistente.Monto : pagoExistente.Monto;
decimal impactoNuevoSaldo = pagoExistente.TipoMovimiento == "Recibido" ? -updateDto.Monto : updateDto.Monto;
decimal diferenciaAjusteSaldo = impactoNuevoSaldo - impactoOriginalSaldo;
// Calcular la diferencia de monto para ajustar el saldo
decimal montoOriginal = pagoExistente.TipoMovimiento == "Recibido" ? pagoExistente.Monto : -pagoExistente.Monto;
decimal montoNuevo = pagoExistente.TipoMovimiento == "Recibido" ? updateDto.Monto : -updateDto.Monto;
decimal diferenciaAjusteSaldo = montoNuevo - montoOriginal;
var pagoParaActualizarEnRepo = new PagoDistribuidor
{
IdPago = pagoExistente.IdPago,
IdDistribuidor = pagoExistente.IdDistribuidor,
Fecha = pagoExistente.Fecha,
TipoMovimiento = pagoExistente.TipoMovimiento,
Recibo = pagoExistente.Recibo,
Monto = updateDto.Monto,
IdTipoPago = updateDto.IdTipoPago,
Detalle = updateDto.Detalle,
IdEmpresa = pagoExistente.IdEmpresa
};
// Actualizar campos permitidos
pagoExistente.Monto = updateDto.Monto;
pagoExistente.IdTipoPago = updateDto.IdTipoPago;
pagoExistente.Detalle = updateDto.Detalle;
var actualizado = await _pagoRepo.UpdateAsync(pagoExistente, idUsuario, transaction);
if (!actualizado) throw new DataException("Error al actualizar el pago.");
var actualizado = await _pagoRepo.UpdateAsync(pagoParaActualizarEnRepo, idUsuario, transaction);
if (!actualizado) throw new DataException("Error al actualizar el pago en la base de datos.");
if (diferenciaAjusteSaldo != 0)
{
@@ -172,32 +207,45 @@ namespace GestionIntegral.Api.Services.Contables
_logger.LogInformation("PagoDistribuidor ID {Id} actualizado por Usuario ID {UserId}.", idPago, idUsuario);
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)
{
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);
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)
{
using var connection = _connectionFactory.CreateConnection();
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
using var transaction = connection.BeginTransaction();
IDbTransaction? transaction = null;
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);
if (pagoExistente == null) return (false, "Pago no encontrado.");
// Revertir el efecto en el saldo
// Si fue "Recibido", el saldo disminuyó (montoAjusteSaldo fue +Monto). Al eliminar, revertimos sumando -Monto (o restando +Monto).
// 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;
if (pagoExistente == null)
{
transaction.Rollback();
return (false, "Pago no encontrado.");
}
decimal montoReversion = pagoExistente.TipoMovimiento == "Recibido" ? pagoExistente.Monto : -pagoExistente.Monto;
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);
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);
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)
{
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);
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
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)
@@ -81,11 +81,11 @@ namespace GestionIntegral.Api.Services.Distribucion
}
if (createDto.Empresa != 0) // Solo validar empresa si no es 0
{
var empresa = await _empresaRepository.GetByIdAsync(createDto.Empresa);
if(empresa == null)
{
var empresa = await _empresaRepository.GetByIdAsync(createDto.Empresa);
if (empresa == null)
{
return (null, "La empresa seleccionada no es válida.");
}
}
}
// CORREGIDO: Usar directamente el valor booleano
@@ -122,7 +122,7 @@ namespace GestionIntegral.Api.Services.Distribucion
if (canillaCreado == null) throw new DataException("Error al crear el canillita.");
transaction.Commit();
// Para el DTO de respuesta, necesitamos NombreZona y NombreEmpresa
string nombreEmpresaParaDto = "N/A (Accionista)";
if (canillaCreado.Empresa != 0)
@@ -131,12 +131,20 @@ namespace GestionIntegral.Api.Services.Distribucion
nombreEmpresaParaDto = empresaData?.Nombre ?? "Empresa Desconocida";
}
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
Accionista = canillaCreado.Accionista, Obs = canillaCreado.Obs, Empresa = canillaCreado.Empresa,
NombreEmpresa = nombreEmpresaParaDto,
Baja = canillaCreado.Baja, FechaBaja = null
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
Accionista = canillaCreado.Accionista,
Obs = canillaCreado.Obs,
Empresa = canillaCreado.Empresa,
NombreEmpresa = nombreEmpresaParaDto,
Baja = canillaCreado.Baja,
FechaBaja = null
};
_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)
{
try { transaction.Rollback(); } catch {}
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error CrearAsync Canilla: {NomApe}", createDto.NomApe);
return (null, $"Error interno al crear el canillita: {ex.Message}");
}
@@ -165,11 +173,11 @@ namespace GestionIntegral.Api.Services.Distribucion
}
if (updateDto.Empresa != 0) // Solo validar empresa si no es 0
{
var empresa = await _empresaRepository.GetByIdAsync(updateDto.Empresa);
if(empresa == null)
{
var empresa = await _empresaRepository.GetByIdAsync(updateDto.Empresa);
if (empresa == null)
{
return (false, "La empresa seleccionada no es válida.");
}
}
}
// Usar directamente el valor booleano para Accionista
@@ -200,18 +208,19 @@ namespace GestionIntegral.Api.Services.Distribucion
try
{
var actualizado = await _canillaRepository.UpdateAsync(canillaExistente, idUsuario, transaction);
if (!actualizado) throw new DataException("Error al actualizar el canillita.");
if (!actualizado) throw new DataException("Error al actualizar el canillita.");
transaction.Commit();
_logger.LogInformation("Canilla ID {IdCanilla} actualizado por Usuario ID {IdUsuario}.", id, idUsuario);
return (true, null);
}
catch (KeyNotFoundException) {
try { transaction.Rollback(); } catch {}
catch (KeyNotFoundException)
{
try { transaction.Rollback(); } catch { }
return (false, "Canillita no encontrado durante la actualización.");
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch {}
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error ActualizarAsync Canilla ID: {IdCanilla}", id);
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);
return (true, null);
}
catch (KeyNotFoundException) {
try { transaction.Rollback(); } catch {}
catch (KeyNotFoundException)
{
try { transaction.Rollback(); } catch { }
return (false, "Canillita no encontrado durante el cambio de estado de baja.");
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch {}
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error ToggleBajaAsync Canilla ID: {IdCanilla}", id);
return (false, $"Error interno al cambiar estado de baja: {ex.Message}");
}

View File

@@ -66,11 +66,31 @@ namespace GestionIntegral.Api.Services.Distribucion
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)
{
var data = await _distribuidorRepository.GetByIdAsync(id);
// MapToDto ahora devuelve DistribuidorDto?
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)

View File

@@ -42,10 +42,22 @@ namespace GestionIntegral.Api.Services.Distribucion
Detalle = e.Detalle
});
}
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)
{
// El repositorio ya devuelve solo las activas si es necesario
// El repositorio ya devuelve solo las activas si es necesario
var empresa = await _empresaRepository.GetByIdAsync(id);
if (empresa == null) return null;
// Mapeo Entidad -> DTO
@@ -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)
{
// Validación de negocio: Nombre duplicado
@@ -234,5 +259,5 @@ namespace GestionIntegral.Api.Services.Distribucion
}
// --- Fin Transacción ---
}
}
}
}

View File

@@ -6,7 +6,7 @@ namespace GestionIntegral.Api.Services.Distribucion
{
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? Canilla, string? Error)> CrearAsync(CreateCanillaDto createDto, 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<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateDistribuidorDto updateDto, 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<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateEmpresaDto updateDto, 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,
string? Error
)> 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.AssemblyConfigurationAttribute("Debug")]
[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.AssemblyTitleAttribute("GestionIntegral.Api")]
[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 type { EntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/EntradaSalidaCanillaDto';
import type { UpdateEntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/UpdateEntradaSalidaCanillaDto';
import type { PublicacionDto } from '../../../models/dtos/Distribucion/PublicacionDto';
import type { CanillaDto } from '../../../models/dtos/Distribucion/CanillaDto';
// import type { CanillaDto } from '../../../models/dtos/Distribucion/CanillaDto'; // Ya no es necesario cargar todos los canillitas aquí
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 type { CreateBulkEntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/CreateBulkEntradaSalidaCanillaDto';
import type { EntradaSalidaCanillaItemDto } from '../../../models/dtos/Distribucion/EntradaSalidaCanillaItemDto';
import axios from 'axios';
import type { PublicacionDropdownDto } from '../../../models/dtos/Distribucion/PublicacionDropdownDto';
const modalStyle = {
position: 'absolute' as 'absolute',
@@ -34,14 +34,21 @@ const modalStyle = {
interface EntradaSalidaCanillaFormModalProps {
open: boolean;
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>;
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;
clearErrorMessage: () => void;
}
interface FormRowItem {
id: string;
id: string; // ID temporal para el frontend
idPublicacion: number | string;
cantSalida: string;
cantEntrada: string;
@@ -50,56 +57,62 @@ interface FormRowItem {
const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps> = ({
open,
onClose, // Este onClose es el que se pasa desde GestionarEntradasSalidasCanillaPage
onSubmit, // Este onSubmit es el que se pasa para la lógica de EDICIÓN
onClose,
onSubmit: onSubmitEdit, // Renombrar para claridad, ya que solo se usa para editar
initialData,
prefillData,
errorMessage: parentErrorMessage,
clearErrorMessage
}) => {
const [idCanilla, setIdCanilla] = useState<number | string>('');
const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]);
const [editIdPublicacion, setEditIdPublicacion] = useState<number | string>('');
// Estados para los campos que SÍ son editables o parte del formulario de items
const [editIdPublicacion, setEditIdPublicacion] = useState<number | string>(''); // Solo para modo edición
const [editCantSalida, setEditCantSalida] = useState<string>('0');
const [editCantEntrada, setEditCantEntrada] = useState<string>('0');
const [editObservacion, setEditObservacion] = useState('');
const [items, setItems] = useState<FormRowItem[]>([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); // Iniciar con una fila
const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]);
const [canillitas, setCanillitas] = useState<CanillaDto[]>([]);
const [loading, setLoading] = useState(false); // Loading para submit
const [loadingDropdowns, setLoadingDropdowns] = useState(false); // Loading para canillas/pubs
const [loadingItems, setLoadingItems] = useState(false); // Loading para pre-carga de items
const [items, setItems] = useState<FormRowItem[]>([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
// Estados para datos de dropdowns
const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]); // Sigue siendo necesario para la lista 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 [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(() => {
const fetchDropdownData = async () => {
const fetchPublicacionesDropdown = async () => {
setLoadingDropdowns(true);
setLocalErrors(prev => ({ ...prev, dropdowns: null }));
try {
const [pubsData, canillitasData] = await Promise.all([
publicacionService.getAllPublicaciones(undefined, undefined, true),
canillaService.getAllCanillas(undefined, undefined, true)
]);
// Usar getPublicacionesForDropdown si lo tienes, sino getAllPublicaciones
const pubsData = await publicacionService.getPublicacionesForDropdown(true);
setPublicaciones(pubsData);
setCanillitas(canillitasData);
} catch (error) {
console.error("Error al cargar datos para dropdowns", error);
setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar datos necesarios (publicaciones/canillitas).' }));
console.error("Error al cargar publicaciones para dropdown", error);
setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar publicaciones.' }));
} finally {
setLoadingDropdowns(false);
}
};
if (open) {
fetchDropdownData();
fetchPublicacionesDropdown();
}
}, [open]);
// Efecto para inicializar el formulario cuando se abre o cambia initialData
// Inicializar formulario y/o pre-cargar items
useEffect(() => {
if (open) {
clearErrorMessage();
@@ -107,65 +120,90 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
setLocalErrors({});
if (isEditing && initialData) {
setIdCanilla(initialData.idCanilla || '');
setFecha(initialData.fecha ? initialData.fecha.split('T')[0] : new Date().toISOString().split('T')[0]);
setEditIdPublicacion(initialData.idPublicacion || '');
setEditCantSalida(initialData.cantSalida?.toString() || '0');
setEditCantEntrada(initialData.cantEntrada?.toString() || '0');
setEditObservacion(initialData.observacion || '');
setItems([]); // En modo edición, no pre-cargamos items de la lista
} else {
// Modo NUEVO: resetear campos principales y dejar que el efecto de 'fecha' cargue los items
setIdCanilla('');
setFecha(new Date().toISOString().split('T')[0]); // Fecha actual por defecto
// Los items se cargarán por el siguiente useEffect basado en la fecha
setItems([]); // No hay lista de items en modo edición de un solo movimiento
} else { // Modo Creación
// Limpiar campos de edición
setEditIdPublicacion('');
setEditCantSalida('0');
setEditCantEntrada('0');
setEditObservacion('');
// 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, clearErrorMessage]);
}, [open, initialData, isEditing, prefillData, clearErrorMessage, publicaciones, loadingDropdowns, displayFecha]); // Añadir displayFecha
// Efecto para pre-cargar/re-cargar items cuando cambia la FECHA (en modo NUEVO)
// y cuando las publicaciones están disponibles.
// Efecto para pre-cargar items por defecto cuando cambia la FECHA (displayFecha) en modo NUEVO
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 }));
if (open && !isEditing && publicaciones.length > 0 && displayFecha) {
const diaSemana = new Date(displayFecha + 'T00:00:00Z').getUTCDay();
setLoadingItems(true);
publicacionService.getPublicacionesPorDiaSemana(diaSemana)
.then(pubsPorDefecto => {
if (pubsPorDefecto.length > 0) {
const itemsPorDefecto = pubsPorDefecto.map(pub => ({
id: `${Date.now().toString()}-${pub.idPublicacion}`,
idPublicacion: pub.idPublicacion,
cantSalida: '0',
cantEntrada: '0',
observacion: ''
}));
setItems(itemsPorDefecto);
} 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: '' }]);
}
const itemsPorDefecto = pubsPorDefecto.map(pub => ({
id: `${Date.now().toString()}-${pub.idPublicacion}`,
idPublicacion: pub.idPublicacion,
cantSalida: '0',
cantEntrada: '0',
observacion: ''
}));
setItems(itemsPorDefecto.length > 0 ? itemsPorDefecto : [{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
})
.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.' }));
setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
})
.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 => {
// ... (lógica de validación sin cambios, pero 'idCanilla' y 'fecha' ya no son estados del modal)
const currentErrors: { [key: string]: string | null } = {};
if (!idCanilla) currentErrors.idCanilla = 'Seleccione un canillita.';
if (!fecha.trim()) currentErrors.fecha = 'La fecha es obligatoria.';
else if (!/^\d{4}-\d{2}-\d{2}$/.test(fecha)) currentErrors.fecha = 'Formato de fecha inválido (YYYY-MM-DD).';
// Validar displayIdCanilla y displayFecha si es modo creación
if (!isEditing) {
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) {
const salidaNum = parseInt(editCantSalida, 10);
const entradaNum = parseInt(editCantEntrada, 10);
@@ -177,14 +215,15 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
} else if (!isNaN(salidaNum) && !isNaN(entradaNum) && entradaNum > salidaNum) {
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;
const publicacionIdsEnLote = new Set<number>();
if (items.length === 0) {
currentErrors.general = "Debe agregar al menos una publicación.";
}
items.forEach((item, index) => {
const salidaNum = parseInt(item.cantSalida, 10);
const entradaNum = parseInt(item.cantEntrada, 10);
@@ -199,9 +238,8 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
const pubIdNum = Number(item.idPublicacion);
if (publicacionIdsEnLote.has(pubIdNum)) {
currentErrors[`item_${item.id}_idPublicacion`] = `Pub. ${index + 1} duplicada.`;
} else {
publicacionIdsEnLote.add(pubIdNum);
}
} else { publicacionIdsEnLote.add(pubIdNum); }
if (item.cantSalida.trim() === '' || isNaN(salidaNum) || salidaNum < 0) {
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;
}
});
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() === ''
);
if (!isEditing && items.length > 0 && !hasValidItemWithQuantityOrPub && !allItemsAreEmptyAndNoPubSelected) {
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() === '');
if (!hasValidItemWithQuantityOrPub && !allItemsAreEmptyAndNoPubSelected) {
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) {
currentErrors.general = "Debe ingresar cantidades para al menos una publicación con datos significativos.";
} 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 datos significativos (cantidades u observación) para al menos una publicación seleccionada.";
}
}
setLocalErrors(currentErrors);
@@ -232,6 +263,7 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
};
const handleInputChange = (fieldName: string) => {
// ... (sin cambios)
if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null }));
if (parentErrorMessage) clearErrorMessage();
if (modalSpecificApiError) setModalSpecificApiError(null);
@@ -252,16 +284,17 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
cantEntrada: entradaNum,
observacion: editObservacion.trim() || undefined,
};
// Aquí se llama al onSubmit que viene de la página padre (GestionarEntradasSalidasCanillaPage)
// para la lógica de actualización.
await onSubmit(dataToSubmitSingle, initialData.idParte);
onClose(); // Cerrar el modal DESPUÉS de un submit de edición exitoso
} else {
// Lógica de creación BULK (se maneja internamente en el modal)
await onSubmitEdit(dataToSubmitSingle, initialData.idParte);
} else { // Modo Creación
if (!displayIdCanilla || !displayFecha) {
setModalSpecificApiError("Faltan datos del canillita o la fecha para crear los movimientos.");
setLoading(false);
return;
}
const itemsToSubmit: EntradaSalidaCanillaItemDto[] = items
.filter(item =>
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 => ({
idPublicacion: Number(item.idPublicacion),
@@ -271,37 +304,30 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
}));
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);
return;
}
const bulkData: CreateBulkEntradaSalidaCanillaDto = {
idCanilla: Number(idCanilla),
fecha,
idCanilla: Number(displayIdCanilla), // Usar el displayIdCanilla
fecha: displayFecha, // Usar el displayFecha
items: itemsToSubmit,
};
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) {
console.error("Error en submit de EntradaSalidaCanillaFormModal:", error);
if (axios.isAxiosError(error) && error.response) {
setModalSpecificApiError(error.response.data?.message || 'Error al procesar la solicitud.');
} else {
setModalSpecificApiError('Ocurrió un error inesperado.');
}
// 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).
const message = axios.isAxiosError(error) && error.response?.data?.message
? error.response.data.message
: 'Ocurrió un error inesperado al procesar la solicitud.';
setModalSpecificApiError(message);
} finally {
setLoading(false);
}
};
const handleAddRow = () => {
const handleAddRow = () => { /* ... (sin cambios) ... */
if (items.length >= publicaciones.length) {
setLocalErrors(prev => ({ ...prev, general: "No se pueden agregar más filas que publicaciones disponibles." }));
return;
@@ -309,15 +335,13 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
setItems([...items, { id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
setLocalErrors(prev => ({ ...prev, general: null }));
};
const handleRemoveRow = (idToRemove: string) => {
const handleRemoveRow = (idToRemove: string) => { /* ... (sin cambios) ... */
if (items.length <= 1 && !isEditing) return;
setItems(items.filter(item => item.id !== idToRemove));
};
const handleItemChange = (id: string, field: keyof Omit<FormRowItem, 'id'>, value: string | number) => {
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}`]) { // Aquí item se refiere al id del item.
const handleItemChange = (id: string, field: keyof Omit<FormRowItem, 'id'>, value: string | number) => { /* ... (sin cambios) ... */
setItems(items.map(itemRow => itemRow.id === id ? { ...itemRow, [field]: value } : itemRow));
if (localErrors[`item_${id}_${field}`]) {
setLocalErrors(prev => ({ ...prev, [`item_${id}_${field}`]: null }));
}
if (localErrors.general) setLocalErrors(prev => ({ ...prev, general: null }));
@@ -325,200 +349,102 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
if (modalSpecificApiError) setModalSpecificApiError(null);
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>
{isEditing ? 'Editar Movimiento Canillita' : 'Registrar Movimientos Canillita'}
{isEditing ? `Editar Movimiento (ID: ${initialData?.idParte})` : 'Registrar Nuevos Movimientos'}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth margin="dense" error={!!localErrors.idCanilla} required>
<InputLabel id="canilla-esc-select-label">Canillita</InputLabel>
<Select labelId="canilla-esc-select-label" label="Canillita" value={idCanilla}
onChange={(e) => { setIdCanilla(e.target.value as number); handleInputChange('idCanilla'); }}
disabled={loading || loadingDropdowns || isEditing}
>
<MenuItem value="" disabled><em>Seleccione un canillita</em></MenuItem>
{canillitas.map((c) => (<MenuItem key={c.idCanilla} value={c.idCanilla}>{`${c.nomApe} (Leg: ${c.legajo || 'S/L'})`}</MenuItem>))}
</Select>
{localErrors.idCanilla && <FormHelperText>{localErrors.idCanilla}</FormHelperText>}
</FormControl>
{/* --- MOSTRAR DATOS PRELLENADOS/FIJOS --- */}
<Paper variant="outlined" sx={{ p: 1.5, mb: 2, backgroundColor: 'grey.100' }}>
<Typography variant="body1" component="div" sx={{ display: 'flex', justifyContent: 'space-between', flexWrap:'wrap' }}>
<Box><strong>{isEditing ? "Canillita:" : "Para Canillita:"}</strong> {displayNombreCanilla || 'N/A'}</Box>
<Box><strong>{isEditing ? "Fecha Movimiento:" : "Para Fecha:"}</strong> {displayFecha ? new Date(displayFecha + 'T00:00:00Z').toLocaleDateString('es-AR', {timeZone: 'UTC'}) : 'N/A'}</Box>
</Typography>
{localErrors.idCanilla && <Typography color="error" variant="caption" display="block">{localErrors.idCanilla}</Typography>}
{localErrors.fecha && <Typography color="error" variant="caption" display="block">{localErrors.fecha}</Typography>}
</Paper>
{/* --- FIN DATOS PRELLENADOS --- */}
<TextField label="Fecha Movimientos" type="date" value={fecha} required
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
/>
{/* El Select de Canillita y TextField de Fecha se eliminan de aquí si son fijos */}
{isEditing && initialData && (
<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>
<Box sx={{ display: 'flex', gap: 2, mt: 0.5 }}>
<TextField label="Cant. Salida" type="number" value={editCantSalida}
onChange={(e) => { setEditCantSalida(e.target.value); handleInputChange('editCantSalida'); }}
margin="dense" fullWidth error={!!localErrors.editCantSalida} helperText={localErrors.editCantSalida || ''}
disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1 }}
/>
<TextField label="Cant. Entrada" type="number" value={editCantEntrada}
onChange={(e) => { setEditCantEntrada(e.target.value); handleInputChange('editCantEntrada'); }}
margin="dense" fullWidth error={!!localErrors.editCantEntrada} helperText={localErrors.editCantEntrada || ''}
disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1 }}
/>
</Box>
<TextField label="Observación (General)" value={editObservacion}
onChange={(e) => setEditObservacion(e.target.value)}
margin="dense" fullWidth multiline rows={2} disabled={loading} sx={{ mt: 1 }}
{isEditing && initialData && (
<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>
<Box sx={{ display: 'flex', gap: 2, mt: 0.5, flexWrap: 'wrap' }}>
<TextField label="Cant. Salida" type="number" value={editCantSalida}
onChange={(e) => { setEditCantSalida(e.target.value); handleInputChange('editCantSalida'); }}
margin="dense" error={!!localErrors.editCantSalida} helperText={localErrors.editCantSalida || ''}
disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1, minWidth:'120px' }}
/>
</Paper>
)}
<TextField label="Cant. Entrada" type="number" value={editCantEntrada}
onChange={(e) => { setEditCantEntrada(e.target.value); handleInputChange('editCantEntrada'); }}
margin="dense" error={!!localErrors.editCantEntrada} helperText={localErrors.editCantEntrada || ''}
disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1, minWidth:'120px' }}
/>
</Box>
<TextField label="Observación (Movimiento)" value={editObservacion} // Label cambiado
onChange={(e) => setEditObservacion(e.target.value)}
margin="dense" fullWidth multiline rows={2} disabled={loading} sx={{ mt: 1 }}
/>
</Paper>
)}
{!isEditing && (
<Box>
<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 && items.map((itemRow, index) => (
<Paper
key={itemRow.id}
elevation={1}
sx={{
p: 1.5,
mb: 1,
}}
>
{/* 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() !== ''
}
>
{!isEditing && (
<Box>
<Typography variant="subtitle2" sx={{ mt: 1.5, mb: 0.5 }}>Movimientos por Publicación:</Typography>
{loadingItems && <Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}><CircularProgress size={20} /></Box>}
{!loadingItems && items.map((itemRow, index) => (
// ... (Renderizado de la fila de items sin cambios significativos,
// solo asegúrate que el Select de Publicación use `publicaciones` y no `canillitas`)
<Paper key={itemRow.id} elevation={1} sx={{ p: 1.5, mb: 1, }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flexGrow: 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() !== '' } >
Pub. {index + 1}
</InputLabel>
<Select
value={itemRow.idPublicacion}
label={`Publicación ${index + 1}`}
onChange={(e) =>
handleItemChange(itemRow.id, 'idPublicacion', e.target.value as number)
}
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 value={itemRow.idPublicacion} label={`Publicación ${index + 1}`}
onChange={(e) => handleItemChange(itemRow.id, 'idPublicacion', e.target.value as number)}
disabled={loading || loadingDropdowns} sx={{ minWidth: 0 }} >
<MenuItem value="" disabled> <em>Seleccione</em> </MenuItem>
{publicaciones.map((p) => ( <MenuItem key={p.idPublicacion} value={p.idPublicacion}> {p.nombre} </MenuItem> ))}
</Select>
{localErrors[`item_${itemRow.id}_idPublicacion`] && (
<FormHelperText>
{localErrors[`item_${itemRow.id}_idPublicacion`]}
</FormHelperText>
)}
{localErrors[`item_${itemRow.id}_idPublicacion`] && ( <FormHelperText> {localErrors[`item_${itemRow.id}_idPublicacion`]} </FormHelperText> )}
</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)}
error={!!localErrors[`item_${itemRow.id}_cantSalida`]}
helperText={localErrors[`item_${itemRow.id}_cantSalida`]}
inputProps={{ min: 0 }}
sx={{
flexBasis: 'calc(15% - 8px)',
minWidth: '80px',
minHeight: 0,
}}
error={!!localErrors[`item_${itemRow.id}_cantSalida`]} helperText={localErrors[`item_${itemRow.id}_cantSalida`]}
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)}
error={!!localErrors[`item_${itemRow.id}_cantEntrada`]}
helperText={localErrors[`item_${itemRow.id}_cantEntrada`]}
inputProps={{ min: 0 }}
sx={{
flexBasis: 'calc(15% - 8px)',
minWidth: '80px',
minHeight: 0,
}}
error={!!localErrors[`item_${itemRow.id}_cantEntrada`]} helperText={localErrors[`item_${itemRow.id}_cantEntrada`]}
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)}
size="small"
sx={{
flexGrow: 1,
flexBasis: 'calc(25% - 8px)',
minWidth: '120px',
minHeight: 0,
}}
multiline
maxRows={1}
<TextField label="Obs." 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>
{/* Ícono de eliminar: siempre en la misma línea */}
{items.length > 1 && (
<IconButton
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
}}
>
<IconButton onClick={() => handleRemoveRow(itemRow.id)} color="error" aria-label="Quitar fila" sx={{ alignSelf: 'center', }} >
<DeleteIcon fontSize="medium" />
</IconButton>
)}
</Box>
</Paper>
))}
{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}>
Agregar Publicación
</Button>
</Box>
)}
</Box>
))}
{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}>
Agregar Publicación
</Button>
</Box>
)}
{parentErrorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{parentErrorMessage}</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 { Box, Checkbox, FormControlLabel, FormGroup, Typography, Paper } from '@mui/material'; // Quitar Grid
import React, { useState } from 'react';
import { Box, Checkbox, FormControlLabel, FormGroup, Typography, Paper, Divider, TextField } from '@mui/material';
import type { PermisoAsignadoDto } from '../../../models/dtos/Usuarios/PermisoAsignadoDto';
interface PermisosChecklistProps {
permisosDisponibles: PermisoAsignadoDto[];
permisosSeleccionados: Set<number>;
onPermisoChange: (permisoId: number, asignado: boolean) => void;
onPermisoChange: (permisoId: number, asignado: boolean, esPermisoSeccion?: boolean, moduloHijo?: string) => void;
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> = ({
permisosDisponibles,
permisosSeleccionados,
onPermisoChange,
disabled = false,
}) => {
const permisosAgrupados = permisosDisponibles.reduce((acc, permiso) => {
const modulo = permiso.modulo || 'Otros';
if (!acc[modulo]) {
acc[modulo] = [];
const [filtrosModulo, setFiltrosModulo] = useState<Record<string, string>>({});
const handleFiltroChange = (moduloConceptual: string, texto: string) => {
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;
}, {} 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 (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}> {/* Contenedor Flexbox */}
{Object.entries(permisosAgrupados).map(([modulo, permisosDelModulo]) => (
<Box
key={modulo}
sx={{
flexGrow: 1, // Para que las columnas crezcan
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.
// Alternativamente, usar porcentajes más simples y dejar que el flexWrap maneje el layout.
// flexBasis: '300px', // Un ancho base y dejar que flexWrap haga el resto
minWidth: '280px', // Ancho mínimo para cada columna
maxWidth: { xs: '100%', sm: '50%', md: '33.333%' }, // Máximo ancho
}}
>
<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 }}>
{modulo}
</Typography>
<FormGroup sx={{ flexGrow: 1}}> {/* Para que ocupe el espacio vertical */}
{permisosDelModulo.map((permiso) => (
<FormControlLabel
key={permiso.id}
control={
<Checkbox
checked={permisosSeleccionados.has(permiso.id)}
onChange={(e) => onPermisoChange(permiso.id, e.target.checked)}
disabled={disabled}
size="small"
/>
}
label={<Typography variant="body2">{`${permiso.descPermiso} (${permiso.codAcc})`}</Typography>}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5, justifyContent: 'center' }}>
{ordenFinalModulos.map(moduloConceptual => { // Usar ordenFinalModulos
const permisoSeccionAsociado = permisosDeSeccion.find(
ps => getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptual
);
const permisosDelModuloHijosOriginales = permisosAgrupadosConceptualmente[moduloConceptual] || [];
// Condición para renderizar la sección
if (!permisoSeccionAsociado && permisosDelModuloHijosOriginales.length === 0 && moduloConceptual !== "Permisos (Definición)") {
// No renderizar si no hay permiso de sección Y no hay hijos, EXCEPTO para "Permisos (Definición)" que es especial
return null;
}
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' }}>
<Typography variant="subtitle1" gutterBottom component="div" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 1 }}>
{moduloConceptual}
</Typography>
{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}
/>
))}
</FormGroup>
</Paper>
</Box>
))}
)}
{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
key={permiso.id}
control={
<Checkbox
checked={permisosSeleccionados.has(permiso.id)}
onChange={(e) => onPermisoChange(permiso.id, e.target.checked, false, moduloConceptual)}
disabled={disabled || (permisoSeccionAsociado && !esSeccionSeleccionada)}
size="small"
/>
}
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>
</Box>
</Paper>
</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 {
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';
import AccountCircle from '@mui/icons-material/AccountCircle'; // Icono de usuario
import LockResetIcon from '@mui/icons-material/LockReset'; // Icono para cambiar contraseña
import LogoutIcon from '@mui/icons-material/Logout'; // Icono para cerrar sesión
import AccountCircle from '@mui/icons-material/AccountCircle';
import LockResetIcon from '@mui/icons-material/LockReset';
import LogoutIcon from '@mui/icons-material/Logout';
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordModal from '../components/Modals/Usuarios/ChangePasswordModal';
import { useNavigate, useLocation } from 'react-router-dom';
import { usePermissions } from '../hooks/usePermissions'; // <<--- AÑADIR ESTA LÍNEA
interface MainLayoutProps {
children: ReactNode;
}
const modules = [
{ label: 'Inicio', path: '/' },
{ label: 'Distribución', path: '/distribucion' },
{ label: 'Contables', path: '/contables' },
{ label: 'Impresión', path: '/impresion' },
{ label: 'Reportes', path: '/reportes' },
{ label: 'Radios', path: '/radios' },
{ label: 'Usuarios', path: '/usuarios' },
// Definición original de módulos
const allAppModules = [
{ label: 'Inicio', path: '/', requiredPermission: null }, // Inicio siempre visible
{ label: 'Distribución', path: '/distribucion', requiredPermission: 'SS001' },
{ label: 'Contables', path: '/contables', requiredPermission: 'SS002' },
{ label: 'Impresión', path: '/impresion', requiredPermission: 'SS003' },
{ label: 'Reportes', path: '/reportes', requiredPermission: 'SS004' },
{ label: 'Radios', path: '/radios', requiredPermission: 'SS005' },
{ label: 'Usuarios', path: '/usuarios', requiredPermission: 'SS006' },
];
const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
const {
user,
user, // user ya está disponible aquí
logout,
isAuthenticated,
isPasswordChangeForced,
@@ -35,24 +40,40 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
passwordChangeCompleted
} = useAuth();
const { tienePermiso, isSuperAdmin } = usePermissions(); // <<--- OBTENER HOOK DE PERMISOS
const navigate = useNavigate();
const location = useLocation();
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(() => {
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 + '/'))
);
if (currentModulePath !== -1) {
setSelectedTab(currentModulePath);
} 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 {
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>) => {
setAnchorElUserMenu(event.currentTarget);
@@ -69,7 +90,7 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
const handleLogoutClick = () => {
logout();
handleCloseUserMenu(); // Cierra el menú antes de desloguear completamente
handleCloseUserMenu();
};
const handleModalClose = (passwordChangedSuccessfully: boolean) => {
@@ -77,23 +98,27 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
passwordChangeCompleted();
} else {
if (isPasswordChangeForced) {
logout();
logout(); // Si es forzado y cancela/falla, desloguear
} else {
setShowForcedPasswordChangeModal(false);
setShowForcedPasswordChangeModal(false); // Si no es forzado, solo cerrar modal
}
}
};
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setSelectedTab(newValue);
navigate(modules[newValue].path);
// --- INICIO DE CAMBIO: Navegar usando accessibleModules ---
if (accessibleModules[newValue]) {
setSelectedTab(newValue);
navigate(accessibleModules[newValue].path);
}
// --- FIN DE CAMBIO ---
};
// Determinar si el módulo actual es el de Reportes
const isReportesModule = location.pathname.startsWith('/reportes');
if (showForcedPasswordChangeModal && isPasswordChangeForced) {
return (
// ... (sin cambios)
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<ChangePasswordModal
open={showForcedPasswordChangeModal}
@@ -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 (
<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' }}>
<Typography variant="h6" component="div" noWrap sx={{ cursor: 'pointer' }} onClick={() => navigate('/')}>
Sistema de Gestión - El Día
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{user && (
<Typography sx={{ mr: 2, display: { xs: 'none', sm: 'block' } }} /* Ocultar en pantallas muy pequeñas */>
{/* ... (Menú de usuario sin cambios) ... */}
{user && (
<Typography sx={{ mr: 2, display: { xs: 'none', sm: 'block' } }} >
Hola, {user.nombreCompleto}
</Typography>
)}
@@ -125,9 +164,7 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
aria-label="Cuenta del usuario"
aria-controls="menu-appbar"
aria-haspopup="true"
sx={{
padding: '15px',
}}
sx={{ padding: '15px' }}
onClick={handleOpenUserMenu}
color="inherit"
>
@@ -143,15 +180,14 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
onClose={handleCloseUserMenu}
sx={{ '& .MuiPaper-root': { minWidth: 220, marginTop: '8px' } }}
>
{user && ( // Mostrar info del usuario en el menú
<Box sx={{ px: 2, py: 1.5, pointerEvents: 'none' /* Para que no sea clickeable */ }}>
{user && (
<Box sx={{ px: 2, py: 1.5, pointerEvents: 'none' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>{user.nombreCompleto}</Typography>
<Typography variant="body2" color="text.secondary">{user.username}</Typography>
</Box>
)}
{user && <Divider sx={{ mb: 1 }} />}
{!isPasswordChangeForced && ( // No mostrar si ya está forzado a cambiarla
{!isPasswordChangeForced && (
<MenuItem onClick={handleChangePasswordClick}>
<ListItemIcon><LockResetIcon fontSize="small" /></ListItemIcon>
<ListItemText>Cambiar Contraseña</ListItemText>
@@ -166,48 +202,45 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
)}
</Box>
</Toolbar>
<Paper square elevation={0} >
<Tabs
value={selectedTab}
onChange={handleTabChange}
indicatorColor="secondary" // O 'primary' si prefieres el mismo color que el fondo
textColor="inherit" // El texto de la pestaña hereda el color (blanco sobre fondo oscuro)
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
aria-label="módulos principales"
sx={{
backgroundColor: 'primary.main', // Color de fondo de las pestañas
color: 'white', // Color del texto de las pestañas
'& .MuiTabs-indicator': {
height: 3, // Un indicador un poco más grueso
},
'& .MuiTab-root': { // Estilo para cada pestaña
minWidth: 100, // Ancho mínimo para cada pestaña
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) => (
<Tab key={module.path} label={module.label} />
))}
</Tabs>
</Paper>
{/* --- INICIO DE CAMBIO: Renderizar Tabs solo si hay módulos accesibles y está autenticado --- */}
{isAuthenticated && accessibleModules.length > 0 && (
<Paper square elevation={0} >
<Tabs
value={selectedTab}
onChange={handleTabChange}
indicatorColor="secondary"
textColor="inherit"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
aria-label="módulos principales"
sx={{
backgroundColor: 'primary.main',
color: 'white',
'& .MuiTabs-indicator': { height: 3 },
'& .MuiTab-root': {
minWidth: 100, textTransform: 'none',
fontWeight: 'normal', opacity: 0.85,
'&.Mui-selected': { fontWeight: 'bold', opacity: 1 },
}
}}
>
{/* Mapear sobre accessibleModules en lugar de allAppModules */}
{accessibleModules.map((module) => (
<Tab key={module.path} label={module.label} />
))}
</Tabs>
</Paper>
)}
{/* --- FIN DE CAMBIO --- */}
</AppBar>
<Box
component="main"
sx={{
sx={{ /* ... (estilos sin cambios) ... */
flexGrow: 1,
py: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 }, // Padding vertical responsivo
px: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 }, // Padding horizontal responsivo
py: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 },
px: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 },
display: 'flex',
flexDirection: 'column'
}}
@@ -215,17 +248,19 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
{children}
</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">
{/* 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}`)}
</Typography>
</Box>
<ChangePasswordModal
open={showForcedPasswordChangeModal && !isPasswordChangeForced} // Solo mostrar si no es el forzado inicial
onClose={() => handleModalClose(false)} // Asumir que si se cierra sin cambiar, no fue exitoso
isFirstLogin={false} // Este modal no es para el primer login forzado
open={showForcedPasswordChangeModal && !isPasswordChangeForced}
onClose={() => handleModalClose(false)}
isFirstLogin={false}
/>
</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 = [
{ label: 'Pagos Distribuidores', path: 'pagos-distribuidores' },
{ label: 'Notas Crédito/Débito', path: 'notas-cd' },
{ label: 'Gestión de Saldos', path: 'gestion-saldos' },
{ 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 { tienePermiso, isSuperAdmin } = usePermissions();
// CN001 (Ver), CN002 (Crear), CN003 (Modificar), CN004 (Eliminar)
const puedeVer = isSuperAdmin || tienePermiso("CN001");
const puedeCrear = isSuperAdmin || tienePermiso("CN002");
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 {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem,
@@ -7,10 +6,10 @@ import {
ListItemIcon,
ListItemText
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; // Icono para agregar
import MoreVertIcon from '@mui/icons-material/MoreVert'; // Icono para más opciones
import EditIcon from '@mui/icons-material/Edit'; // Icono para modificar
import DeleteIcon from '@mui/icons-material/Delete'; // Icono para eliminar
import AddIcon from '@mui/icons-material/Add';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import tipoPagoService from '../../services/Contables/tipoPagoService';
import type { TipoPago } from '../../models/Entities/TipoPago';
import type { CreateTipoPagoDto } from '../../models/dtos/Contables/CreateTipoPagoDto';
@@ -30,20 +29,26 @@ const GestionarTiposPagoPage: React.FC = () => {
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
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 [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 puedeModificar = isSuperAdmin || tienePermiso("CT003");
const puedeEliminar = isSuperAdmin || tienePermiso("CT004");
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);
setError(null);
try {
@@ -55,7 +60,7 @@ const GestionarTiposPagoPage: React.FC = () => {
} finally {
setLoading(false);
}
}, [filtroNombre]);
}, [filtroNombre, puedeVer]); // << AÑADIR puedeVer A LAS DEPENDENCIAS
useEffect(() => {
cargarTiposPago();
@@ -73,15 +78,15 @@ const GestionarTiposPagoPage: React.FC = () => {
};
const handleSubmitModal = async (data: CreateTipoPagoDto | UpdateTipoPagoDto) => {
setApiErrorMessage(null); // Limpiar error previo
setApiErrorMessage(null);
try {
if (editingTipoPago && 'idTipoPago' in data) { // Es Update
if (editingTipoPago && editingTipoPago.idTipoPago) {
await tipoPagoService.updateTipoPago(editingTipoPago.idTipoPago, data as UpdateTipoPagoDto);
} else { // Es Create
} else {
await tipoPagoService.createTipoPago(data as CreateTipoPagoDto);
}
cargarTiposPago(); // Recargar lista
// onClose se llama desde el modal en caso de éxito
cargarTiposPago();
// onClose se llama desde el modal si todo va bien
} catch (err: any) {
console.error("Error en submit modal (padre):", err);
if (axios.isAxiosError(err) && err.response) {
@@ -89,11 +94,12 @@ const GestionarTiposPagoPage: React.FC = () => {
} else {
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) => {
// ... (sin cambios)
if (window.confirm('¿Está seguro de que desea eliminar este tipo de pago?')) {
setApiErrorMessage(null);
try {
@@ -126,12 +132,24 @@ const GestionarTiposPagoPage: React.FC = () => {
};
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);
};
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 (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>
@@ -146,10 +164,8 @@ const GestionarTiposPagoPage: React.FC = () => {
size="small"
value={filtroNombre}
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>
{puedeCrear && (
<Button
@@ -164,43 +180,50 @@ const GestionarTiposPagoPage: React.FC = () => {
</Paper>
{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>}
{!loading && !error && (
{!loading && !error && puedeVer && ( // Asegurar que puedeVer sea true para mostrar la tabla
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Nombre</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>
</TableHead>
<TableBody>
{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) => (
<TableRow key={tipo.idTipoPago}>
<TableCell>{tipo.nombre}</TableCell>
<TableCell>{tipo.detalle || '-'}</TableCell>
<TableCell align="right">
<IconButton
onClick={(e) => handleMenuOpen(e, tipo)}
disabled={!puedeModificar && !puedeEliminar}
>
<MoreVertIcon />
</IconButton>
</TableCell>
{(puedeModificar || puedeEliminar) && (
<TableCell align="right">
<IconButton
onClick={(e) => handleMenuOpen(e, tipo)}
disabled={!puedeModificar && !puedeEliminar}
>
<MoreVertIcon />
</IconButton>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]}
rowsPerPageOptions={[25, 50, 100]} // Opciones más estándar
component="div"
count={tiposPago.length}
rowsPerPage={rowsPerPage}
@@ -217,20 +240,19 @@ const GestionarTiposPagoPage: React.FC = () => {
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
{puedeModificar && (
<MenuItem onClick={() => { handleOpenModal(selectedTipoPagoRow!); handleMenuClose(); }}>
{puedeModificar && selectedTipoPagoRow && ( // Asegurar que selectedTipoPagoRow no sea null
<MenuItem onClick={() => { handleOpenModal(selectedTipoPagoRow); handleMenuClose(); }}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>Modificar</ListItemText>
</MenuItem>
)}
{puedeEliminar && (
<MenuItem onClick={() => handleDelete(selectedTipoPagoRow!.idTipoPago)}>
{puedeEliminar && selectedTipoPagoRow && ( // Asegurar que selectedTipoPagoRow no sea null
<MenuItem onClick={() => handleDelete(selectedTipoPagoRow.idTipoPago)}>
<ListItemIcon><DeleteIcon fontSize="small" /></ListItemIcon>
<ListItemText>Eliminar</ListItemText>
</MenuItem>
)}
{/* Si no tiene ningún permiso, el menú podría estar vacío o no mostrarse */}
{(!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>}
{selectedTipoPagoRow && (!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>}
</Menu>
<TipoPagoFormModal

View File

@@ -1,4 +1,3 @@
// src/pages/distribucion/DistribucionIndexPage.tsx
import React, { useState, useEffect } from 'react';
import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';

View File

@@ -1,14 +1,17 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
CircularProgress, Alert, Chip, FormControlLabel
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
CircularProgress, Alert, Chip, FormControlLabel, ListItemIcon, ListItemText // << AÑADIR ListItemIcon, ListItemText
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
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 type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto';
import type { CreateCanillaDto } from '../../models/dtos/Distribucion/CreateCanillaDto';
@@ -31,17 +34,24 @@ const GestionarCanillitasPage: React.FC = () => {
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
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 [selectedCanillitaRow, setSelectedCanillitaRow] = useState<CanillaDto | null>(null);
const navigate = useNavigate(); // << INICIALIZAR useNavigate
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVer = isSuperAdmin || tienePermiso("CG001");
const puedeCrear = isSuperAdmin || tienePermiso("CG002");
const puedeModificar = isSuperAdmin || tienePermiso("CG003");
// CG004 para Porcentajes/Montos, se gestionará por separado.
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 () => {
if (!puedeVer) {
@@ -51,12 +61,12 @@ const GestionarCanillitasPage: React.FC = () => {
}
setLoading(true); setError(null); setApiErrorMessage(null);
try {
const legajoNum = filtroLegajo ? parseInt(filtroLegajo, 25) : undefined;
const legajoNum = filtroLegajo ? parseInt(filtroLegajo, 10) : undefined; // << CORREGIDO: parseInt con base 10
if (filtroLegajo && isNaN(legajoNum!)) {
setApiErrorMessage("Legajo debe ser un número.");
setCanillitas([]); // Limpiar resultados si el filtro es inválido
setLoading(false);
return;
setApiErrorMessage("Legajo debe ser un número.");
setCanillitas([]);
setLoading(false);
return;
}
const data = await canillaService.getAllCanillas(filtroNomApe, legajoNum, filtroSoloActivos);
setCanillitas(data);
@@ -83,6 +93,7 @@ const GestionarCanillitasPage: React.FC = () => {
await canillaService.createCanilla(data as CreateCanillaDto);
}
cargarCanillitas();
// No es necesario llamar a handleCloseModal aquí si el modal se cierra solo en éxito
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el canillita.';
setApiErrorMessage(message); throw err;
@@ -93,17 +104,22 @@ const GestionarCanillitasPage: React.FC = () => {
setApiErrorMessage(null);
const accion = canillita.baja ? "reactivar" : "dar de baja";
if (window.confirm(`¿Está seguro de que desea ${accion} a ${canillita.nomApe}?`)) {
try {
await canillaService.toggleBajaCanilla(canillita.idCanilla, { darDeBaja: !canillita.baja });
cargarCanillitas();
} catch (err:any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${accion} el canillita.`;
setApiErrorMessage(message);
}
try {
await canillaService.toggleBajaCanilla(canillita.idCanilla, { darDeBaja: !canillita.baja });
cargarCanillitas();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${accion} el canillita.`;
setApiErrorMessage(message);
}
}
handleMenuClose();
};
const handleOpenNovedades = (idCan: number) => {
navigate(`/distribucion/canillas/${idCan}/novedades`);
handleMenuClose();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, canillita: CanillaDto) => {
setAnchorEl(event.currentTarget); setSelectedCanillitaRow(canillita);
};
@@ -118,98 +134,120 @@ const GestionarCanillitasPage: React.FC = () => {
const displayData = canillitas.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
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 (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Gestionar Canillitas</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
label="Filtrar por Nombre/Apellido"
variant="outlined"
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
label="Filtrar por Nombre/Apellido"
variant="outlined"
size="small"
value={filtroNomApe}
onChange={(e) => setFiltroNomApe(e.target.value)}
sx={{ flex: 2, minWidth: '250px' }}
/>
<TextField
label="Filtrar por Legajo"
type="number" // Mantener como number para el input, la conversión se hace al usarlo
variant="outlined"
size="small"
value={filtroLegajo}
onChange={(e) => setFiltroLegajo(e.target.value)}
sx={{ flex: 1, minWidth: '150px' }}
/>
<FormControlLabel
control={
<Switch
checked={filtroSoloActivos === undefined ? true : filtroSoloActivos} // Default a true
onChange={(e) => setFiltroSoloActivos(e.target.checked)}
size="small"
value={filtroNomApe}
onChange={(e) => setFiltroNomApe(e.target.value)}
sx={{ flex: 2, minWidth: '250px' }} // Dar más espacio al nombre
/>
<TextField
label="Filtrar por Legajo"
type="number"
variant="outlined"
size="small"
value={filtroLegajo}
onChange={(e) => setFiltroLegajo(e.target.value)}
sx={{ flex: 1, minWidth: '150px' }}
/>
<FormControlLabel
control={
<Switch
checked={filtroSoloActivos === undefined ? true : filtroSoloActivos}
onChange={(e) => setFiltroSoloActivos(e.target.checked)}
size="small"
/>
}
label="Ver Activos"
sx={{ flexShrink: 0 }} // Para que el label no se comprima demasiado
/>
{/* <Button variant="contained" onClick={cargarCanillitas} size="small">Buscar</Button> */}
</Box>
{puedeCrear && (
/>
}
label="Ver Activos" // Cambiado el label para más claridad
sx={{ flexShrink: 0 }}
/>
</Box>
{puedeCrear && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: 2 }}>Agregar Canillita</Button>
)}
)}
</Paper>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && <Alert severity="error" sx={{my: 2}}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>}
{/* Mostrar error general si no hay error de API específico */}
{error && !apiErrorMessage && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{!loading && !error && puedeVer && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell>Legajo</TableCell><TableCell>Nombre y Apellido</TableCell>
<TableCell>Zona</TableCell><TableCell>Empresa</TableCell>
<TableCell>Accionista</TableCell><TableCell>Estado</TableCell>
<TableCell align="right">Acciones</TableCell>
</TableRow></TableHead>
<TableBody>
{displayData.length === 0 ? (
<TableRow><TableCell colSpan={7} align="center">No se encontraron canillitas.</TableCell></TableRow>
) : (
displayData.map((c) => (
<TableRow key={c.idCanilla} hover sx={{ backgroundColor: c.baja ? '#ffebee' : 'inherit' }}>
<TableCell>{c.legajo || '-'}</TableCell><TableCell>{c.nomApe}</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.baja ? <Chip label="Baja" color="error" size="small" /> : <Chip label="Activo" color="success" size="small" />}</TableCell>
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, c)} disabled={!puedeModificar && !puedeDarBaja}>
<MoreVertIcon />
{!loading && !error && puedeVer && ( // Asegurar que puedeVer sea true también aquí
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell>Legajo</TableCell><TableCell>Nombre y Apellido</TableCell>
<TableCell>Zona</TableCell><TableCell>Empresa</TableCell>
<TableCell>Accionista</TableCell><TableCell>Estado</TableCell>
{/* Mostrar acciones solo si tiene algún permiso para el menú */}
{(puedeModificar || puedeDarBaja || puedeVerNovedadesCanilla) && <TableCell align="right">Acciones</TableCell>}
</TableRow></TableHead>
<TableBody>
{displayData.length === 0 ? (
<TableRow><TableCell colSpan={(puedeModificar || puedeDarBaja || puedeVerNovedadesCanilla) ? 7 : 6} align="center">No se encontraron canillitas.</TableCell></TableRow>
) : (
displayData.map((c) => (
<TableRow key={c.idCanilla} hover sx={{ backgroundColor: c.baja ? '#ffebee' : 'inherit' }}>
<TableCell>{c.legajo || '-'}</TableCell><TableCell>{c.nomApe}</TableCell>
<TableCell>{c.nombreZona}</TableCell><TableCell>{c.empresa === 0 ? '-' : c.nombreEmpresa}</TableCell>
<TableCell>{c.accionista ? <Chip label="" 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>
{(puedeModificar || puedeDarBaja || puedeVerNovedadesCanilla) && (
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, c)}
// Deshabilitar si NO tiene NINGUNO de los permisos para las acciones del menú
disabled={!puedeModificar && !puedeDarBaja && !puedeVerNovedadesCanilla}
>
<MoreVertIcon />
</IconButton>
</TableCell>
</TableRow>
)))}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]} component="div" count={canillitas.length}
rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage} labelRowsPerPage="Filas por página:"
/>
</TableContainer>
)}
</TableCell>
)}
</TableRow>
)))}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]} component="div" count={canillitas.length}
rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage} labelRowsPerPage="Filas por página:"
/>
</TableContainer>
)}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{puedeModificar && (<MenuItem onClick={() => { handleOpenModal(selectedCanillitaRow!); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} /> Modificar</MenuItem>)}
{puedeDarBaja && selectedCanillitaRow && (
<MenuItem onClick={() => handleToggleBaja(selectedCanillitaRow)}>
{selectedCanillitaRow.baja ? <ToggleOnIcon sx={{mr:1}}/> : <ToggleOffIcon sx={{mr:1}}/>}
{selectedCanillitaRow.baja ? 'Reactivar' : 'Dar de Baja'}
</MenuItem>
{/* Mostrar opción de Novedades si tiene permiso de ver canillitas o gestionar novedades */}
{puedeVerNovedadesCanilla && selectedCanillitaRow && (
<MenuItem onClick={() => handleOpenNovedades(selectedCanillitaRow.idCanilla)}>
<ListItemIcon><EventNoteIcon /></ListItemIcon>
<ListItemText>Novedades</ListItemText>
</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>
)}
{(!puedeModificar && !puedeDarBaja) && <MenuItem disabled>Sin acciones</MenuItem>}
</Menu>
<CanillaFormModal

View File

@@ -146,8 +146,8 @@ const GestionarEmpresasPage: React.FC = () => {
// Si no tiene permiso para ver, mostrar mensaje y salir
if (!loading && !puedeVer) {
return (
<Box sx={{ p: 2 }}>
<Typography variant="h4" gutterBottom>Gestionar Empresas</Typography>
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Gestionar Empresas</Typography>
<Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert>
</Box>
);

View File

@@ -3,7 +3,8 @@ import {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
CircularProgress, Alert, FormControl, InputLabel, Select, Checkbox, Tooltip,
Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle
Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle,
ToggleButtonGroup, ToggleButton
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
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 { 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 { LiquidarMovimientosCanillaRequestDto } from '../../models/dtos/Distribucion/LiquidarMovimientosCanillaDto';
@@ -28,25 +29,32 @@ import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import reportesService from '../../services/Reportes/reportesService';
type TipoDestinatarioFiltro = 'canillitas' | 'accionistas';
const GestionarEntradasSalidasCanillaPage: React.FC = () => {
const [movimientos, setMovimientos] = useState<EntradaSalidaCanillaDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // Para carga principal de movimientos
const [error, setError] = useState<string | null>(null); // Error general o de carga
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); // Para errores de modal/API
const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroFecha, setFiltroFecha] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroIdPublicacion, setFiltroIdPublicacion] = useState<number | string>('');
const [filtroIdCanilla, setFiltroIdCanilla] = useState<number | string>('');
const [filtroEstadoLiquidacion, setFiltroEstadoLiquidacion] = useState<'todos' | 'liquidados' | 'noLiquidados'>('noLiquidados');
const [loadingTicketPdf, setLoadingTicketPdf] = useState(false);
const [filtroIdCanillitaSeleccionado, setFiltroIdCanillitaSeleccionado] = useState<number | string>('');
const [filtroTipoDestinatario, setFiltroTipoDestinatario] = useState<TipoDestinatarioFiltro>('canillitas');
const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]);
const [canillitas, setCanillitas] = useState<CanillaDto[]>([]);
const [loadingTicketPdf, setLoadingTicketPdf] = useState(false);
const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]);
const [destinatariosDropdown, setDestinatariosDropdown] = useState<CanillaDto[]>([]);
const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
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 [rowsPerPage, setRowsPerPage] = useState(25);
@@ -64,70 +72,123 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
const puedeLiquidar = isSuperAdmin || tienePermiso("MC005");
const puedeEliminarLiquidados = isSuperAdmin || tienePermiso("MC006");
// Función para formatear fechas YYYY-MM-DD a DD/MM/YYYY
const formatDate = (dateString?: string | null): string => {
if (!dateString) return '-';
const datePart = dateString.split('T')[0];
const parts = datePart.split('-');
if (parts.length === 3) {
return `${parts[2]}/${parts[1]}/${parts[0]}`;
}
if (parts.length === 3) { return `${parts[2]}/${parts[1]}/${parts[0]}`; }
return datePart;
};
const fetchFiltersDropdownData = useCallback(async () => {
setLoadingFiltersDropdown(true);
try {
const [pubsData, canData] = await Promise.all([
publicacionService.getAllPublicaciones(undefined, undefined, true),
canillaService.getAllCanillas(undefined, undefined, true)
]);
setPublicaciones(pubsData);
setCanillitas(canData);
} catch (err) {
console.error(err); setError("Error al cargar opciones de filtro.");
} finally { setLoadingFiltersDropdown(false); }
useEffect(() => {
const fetchPublicaciones = async () => {
setLoadingFiltersDropdown(true); // Mover al inicio de la carga de pubs
try {
const pubsData = await publicacionService.getPublicacionesForDropdown(true);
setPublicaciones(pubsData);
} catch (err) {
console.error("Error cargando publicaciones para filtro:",err);
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 () => {
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);
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 = {
fechaDesde: filtroFechaDesde || null, fechaHasta: filtroFechaHasta || null,
fechaDesde: filtroFecha,
fechaHasta: filtroFecha,
idPublicacion: filtroIdPublicacion ? Number(filtroIdPublicacion) : null,
idCanilla: filtroIdCanilla ? Number(filtroIdCanilla) : null,
liquidados: liquidadosFilter,
incluirNoLiquidados: filtroEstadoLiquidacion === 'todos' ? null : incluirNoLiquidadosFilter,
idCanilla: Number(filtroIdCanillitaSeleccionado),
liquidados: null,
incluirNoLiquidados: null,
};
const data = await entradaSalidaCanillaService.getAllEntradasSalidasCanilla(params);
setMovimientos(data);
setSelectedIdsParaLiquidar(new Set()); // Limpiar selección al recargar
setSelectedIdsParaLiquidar(new Set());
} catch (err) {
console.error(err); setError('Error al cargar movimientos.');
} finally { setLoading(false); }
}, [puedeVer, filtroFechaDesde, filtroFechaHasta, filtroIdPublicacion, filtroIdCanilla, filtroEstadoLiquidacion]);
console.error("Error al cargar movimientos:", err);
setError('Error al cargar movimientos.');
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) => {
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) => {
if (window.confirm(`¿Seguro de eliminar este movimiento (ID: ${idParte})?`)) {
setApiErrorMessage(null);
@@ -138,7 +199,6 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
};
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());
setAnchorEl(event.currentTarget);
setSelectedRow(item);
@@ -154,7 +214,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
});
};
const handleSelectAllForLiquidar = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
if (event.target.checked) {
const newSelectedIds = new Set(movimientos.filter(m => !m.liquidado).map(m => m.idParte));
setSelectedIdsParaLiquidar(newSelectedIds);
} else {
@@ -170,74 +230,65 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
setOpenLiquidarDialog(true);
};
const handleCloseLiquidarDialog = () => setOpenLiquidarDialog(false);
const handleConfirmLiquidar = async () => {
if (selectedIdsParaLiquidar.size === 0) {
setApiErrorMessage("No hay movimientos seleccionados para liquidar.");
return;
}
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
if (selectedIdsParaLiquidar.size === 0) { /* ... */ return; }
if (!fechaLiquidacionDialog) { /* ... */ return; }
// ... (validación de fecha sin cambios)
const fechaLiquidacionDate = new Date(fechaLiquidacionDialog + 'T00:00:00Z');
let fechaMovimientoMasReciente: Date | null = null;
selectedIdsParaLiquidar.forEach(idParte => {
const movimiento = movimientos.find(m => m.idParte === idParte);
if (movimiento && movimiento.fecha) { // Asegurarse que movimiento.fecha existe
const movFecha = new Date(movimiento.fecha.split('T')[0] + 'T00:00:00Z'); // Consistencia con Z
if (fechaMovimientoMasReciente === null || movFecha.getTime() > (fechaMovimientoMasReciente as Date).getTime()) { // Comparar usando getTime()
if (movimiento && movimiento.fecha) {
const movFecha = new Date(movimiento.fecha.split('T')[0] + 'T00:00:00Z');
if (fechaMovimientoMasReciente === null || movFecha.getTime() > (fechaMovimientoMasReciente as Date).getTime()) {
fechaMovimientoMasReciente = movFecha;
}
}
});
if (fechaMovimientoMasReciente !== null && fechaLiquidacionDate.getTime() < (fechaMovimientoMasReciente as Date).getTime()) { // Comparar usando getTime()
if (fechaMovimientoMasReciente !== null && fechaLiquidacionDate.getTime() < (fechaMovimientoMasReciente as Date).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'})}).`);
return;
}
setApiErrorMessage(null);
setLoading(true); // Usar el loading general para la operación de liquidar
setLoading(true);
const liquidarDto: LiquidarMovimientosCanillaRequestDto = {
idsPartesALiquidar: Array.from(selectedIdsParaLiquidar),
fechaLiquidacion: fechaLiquidacionDialog // El backend espera YYYY-MM-DD
fechaLiquidacion: fechaLiquidacionDialog
};
try {
await entradaSalidaCanillaService.liquidarMovimientos(liquidarDto);
setOpenLiquidarDialog(false);
setOpenLiquidarDialog(false);
const primerIdParteLiquidado = Array.from(selectedIdsParaLiquidar)[0];
const movimientoParaTicket = movimientos.find(m => m.idParte === primerIdParteLiquidado);
await cargarMovimientos();
await cargarMovimientos();
if (movimientoParaTicket) {
console.log("Liquidación exitosa, intentando generar ticket para canillita:", movimientoParaTicket.idCanilla);
// --- CAMBIO: NO IMPRIMIR TICKET SI ES ACCIONISTA ---
if (movimientoParaTicket && !movimientoParaTicket.canillaEsAccionista) {
console.log("Liquidación exitosa, generando ticket para canillita NO accionista:", movimientoParaTicket.idCanilla);
await handleImprimirTicketLiquidacion(
movimientoParaTicket.idCanilla,
fechaLiquidacionDialog,
movimientoParaTicket.canillaEsAccionista
fechaLiquidacionDialog,
false // esAccionista = false
);
} else if (movimientoParaTicket && movimientoParaTicket.canillaEsAccionista) {
console.log("Liquidación exitosa para accionista. No se genera ticket automáticamente.");
} 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) {
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al liquidar.';
setApiErrorMessage(msg);
setApiErrorMessage(msg);
} finally {
setLoading(false);
}
};
// 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) => {
// ... (sin cambios)
setApiErrorMessage(null);
try {
await entradaSalidaCanillaService.updateEntradaSalidaCanilla(idParte, data);
@@ -251,32 +302,21 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
const handleCloseModal = () => {
setModalOpen(false);
setEditingMovimiento(null);
// Recargar siempre que se cierre el modal y no haya un error pendiente a nivel de página
// Opcionalmente, podrías tener una bandera ' cambiosGuardados' que el modal active
// para ser más selectivo con la recarga.
setPrefillModalData(null);
if (!apiErrorMessage) {
cargarMovimientos();
}
};
const handleImprimirTicketLiquidacion = useCallback(async (
// Parámetros necesarios para el ticket
idCanilla: number,
fecha: string, // Fecha para la que se genera el ticket (probablemente fechaLiquidacionDialog)
esAccionista: boolean
idCanilla: number, fecha: string, esAccionista: boolean
) => {
// ... (sin cambios)
setLoadingTicketPdf(true);
setApiErrorMessage(null);
try {
const params = {
fecha: fecha.split('T')[0], // Asegurar formato YYYY-MM-DD
idCanilla: idCanilla,
esAccionista: esAccionista,
};
const params = { fecha: fecha.split('T')[0], idCanilla, esAccionista };
const blob = await reportesService.getTicketLiquidacionCanillaPdf(params);
if (blob.type === "application/json") {
const text = await blob.text();
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.");
}
} catch (error: any) {
console.error("Error al generar ticket de liquidación:", error);
const message = axios.isAxiosError(error) && error.response?.data?.message
? error.response.data.message
: 'Ocurrió un error al generar el ticket.';
console.error("Error al generar ticket:", error);
const message = axios.isAxiosError(error) && error.response?.data?.message ? error.response.data.message : 'Error al generar ticket.';
setApiErrorMessage(message);
} finally {
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
} finally { setLoadingTicketPdf(false); }
}, []);
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);
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;
// 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;
return (
<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 }}>
<Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography>
<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 Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} />
<TextField label="Fecha" type="date" size="small" value={filtroFecha}
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}>
<InputLabel>Publicación</InputLabel>
<Select value={filtroIdPublicacion} label="Publicación" onChange={(e) => setFiltroIdPublicacion(e.target.value as number | string)}>
<InputLabel>Publicación (Opcional)</InputLabel>
<Select value={filtroIdPublicacion} label="Publicación (Opcional)" onChange={(e) => setFiltroIdPublicacion(e.target.value as number | string)}>
<MenuItem value=""><em>Todas</em></MenuItem>
{publicaciones.map(p => <MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre}</MenuItem>)}
</Select>
</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 sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{puedeCrear && (<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Registrar Movimiento</Button>)}
{puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' && numSelectedToLiquidate > 0 && (
{/* --- CAMBIO: DESHABILITAR BOTÓN SI FILTROS OBLIGATORIOS NO ESTÁN --- */}
{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}>
Liquidar Seleccionados ({numSelectedToLiquidate})
</Button>
@@ -353,8 +418,12 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
</Box>
</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>}
{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>}
{loadingTicketPdf &&
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', my: 2 }}>
@@ -364,12 +433,13 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
}
{!loading && !error && puedeVer && (
<TableContainer component={Paper}>
{!loading && !error && puedeVer && filtroFecha && filtroIdCanillitaSeleccionado && (
// ... (Tabla y Paginación sin cambios)
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
{puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' && (
{puedeLiquidar && (
<TableCell padding="checkbox">
<Checkbox
indeterminate={numSelectedToLiquidate > 0 && numSelectedToLiquidate < numNotLiquidatedOnPage && numNotLiquidatedOnPage > 0}
@@ -381,7 +451,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
)}
<TableCell>Fecha</TableCell>
<TableCell>Publicación</TableCell>
<TableCell>Canillita</TableCell>
<TableCell>{filtroTipoDestinatario === 'canillitas' ? 'Canillita' : 'Accionista'}</TableCell>
<TableCell align="right">Salida</TableCell>
<TableCell align="right">Entrada</TableCell>
<TableCell align="right">Vendidos</TableCell>
@@ -397,19 +467,17 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
<TableRow>
<TableCell
colSpan={
(puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' ? 1 : 0) +
9 +
((puedeModificar || puedeEliminar || puedeLiquidar) ? 1 : 0)
(puedeLiquidar ? 1 : 0) + 9 + ((puedeModificar || puedeEliminar || puedeLiquidar) ? 1 : 0)
}
align="center"
>
No se encontraron movimientos.
No se encontraron movimientos con los filtros aplicados.
</TableCell>
</TableRow>
) : (
displayData.map((m) => (
<TableRow key={m.idParte} hover selected={selectedIdsParaLiquidar.has(m.idParte)}>
{puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' && (
{puedeLiquidar && (
<TableCell padding="checkbox">
<Checkbox
checked={selectedIdsParaLiquidar.has(m.idParte)}
@@ -440,8 +508,8 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
<TableCell align="right">
<IconButton
onClick={(e) => handleMenuOpen(e, m)}
data-rowid={m.idParte.toString()} // Guardar el id de la fila aquí
disabled={m.liquidado && !puedeEliminarLiquidados && !puedeLiquidar} // Lógica simplificada, refinar si es necesario
data-rowid={m.idParte.toString()}
disabled={m.liquidado && !puedeEliminarLiquidados && !puedeLiquidar}
>
<MoreVertIcon />
</IconButton>
@@ -462,19 +530,17 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{puedeModificar && selectedRow && !selectedRow.liquidado && (
<MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} /> Modificar</MenuItem>)}
{/* Opción de Imprimir Ticket Liq. */}
{selectedRow && selectedRow.liquidado && ( // Solo mostrar si ya está liquidado (para reimprimir)
{/* --- CAMBIO: MOSTRAR REIMPRIMIR TICKET SIEMPRE SI ESTÁ LIQUIDADO --- */}
{selectedRow && selectedRow.liquidado && puedeLiquidar && ( // Usar puedeLiquidar para consistencia
<MenuItem
onClick={() => {
if (selectedRow) { // selectedRow no será null aquí debido a la condición anterior
if (selectedRow) {
handleImprimirTicketLiquidacion(
selectedRow.idCanilla,
selectedRow.fechaLiquidado || selectedRow.fecha, // Usar fechaLiquidado si existe, sino la fecha del movimiento
selectedRow.canillaEsAccionista
selectedRow.fechaLiquidado || selectedRow.fecha,
selectedRow.canillaEsAccionista // Pasar si es accionista
);
}
// handleMenuClose() es llamado por handleImprimirTicketLiquidacion
}}
disabled={loadingTicketPdf}
>
@@ -483,13 +549,10 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
Reimprimir Ticket Liq.
</MenuItem>
)}
{selectedRow && ( // Opción de Eliminar
{selectedRow && (
((!selectedRow.liquidado && puedeEliminar) || (selectedRow.liquidado && puedeEliminarLiquidados))
) && (
<MenuItem onClick={() => {
if (selectedRow) handleDelete(selectedRow.idParte);
}}>
<MenuItem onClick={() => { if (selectedRow) handleDelete(selectedRow.idParte); }}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Eliminar
</MenuItem>
)}
@@ -498,13 +561,15 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
<EntradaSalidaCanillaFormModal
open={modalOpen}
onClose={handleCloseModal}
onSubmit={handleModalEditSubmit}
onSubmit={handleModalEditSubmit} // Este onSubmit es solo para edición
initialData={editingMovimiento}
prefillData={prefillModalData}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
<Dialog open={openLiquidarDialog} onClose={handleCloseLiquidarDialog}>
{/* ... (Dialog de Liquidación sin cambios) ... */}
<Dialog open={openLiquidarDialog} onClose={handleCloseLiquidarDialog}>
<DialogTitle>Confirmar Liquidación</DialogTitle>
<DialogContent>
<DialogContentText>
@@ -523,7 +588,6 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
</Button>
</DialogActions>
</Dialog>
</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();
// 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 puedeModificar = isSuperAdmin || tienePermiso("OD003");
const puedeEliminar = isSuperAdmin || tienePermiso("OD004");
const cargarOtrosDestinos = useCallback(async () => {
if (!puedeVer) {
setError("No tiene permiso para ver esta sección.");
setError("No tiene permiso para ver los 'Otros Destinos'.");
setLoading(false);
setOtrosDestinos([]);
return;
}
setLoading(true);
@@ -131,8 +132,8 @@ const GestionarOtrosDestinosPage: React.FC = () => {
if (!loading && !puedeVer) {
return (
<Box sx={{ p: 2 }}>
<Typography variant="h4" gutterBottom>Gestionar Otros Destinos</Typography>
<Box sx={{ p: 1 }}>
<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>
</Box>
);

View File

@@ -241,7 +241,7 @@ const GestionarPublicacionesPage: React.FC = () => {
</FormControl>
<FormControlLabel
control={<Switch checked={filtroSoloHabilitadas === undefined ? true : filtroSoloHabilitadas} onChange={(e) => setFiltroSoloHabilitadas(e.target.checked)} size="small" />}
label="Solo Habilitadas"
label="Ver Habilitadas"
/>
</Box>
{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();
// Ajustar códigos de permiso para Zonas
const puedeVer = isSuperAdmin || tienePermiso("ZD001"); // Permiso para ver Zonas
const puedeCrear = isSuperAdmin || tienePermiso("ZD002");
const puedeModificar = isSuperAdmin || tienePermiso("ZD003");
const puedeEliminar = isSuperAdmin || tienePermiso("ZD004");
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);
setError(null);
try {
@@ -134,6 +140,17 @@ const GestionarZonasPage: React.FC = () => {
// Adaptar para paginación
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 (
<Box sx={{ p: 1 }}>
@@ -150,7 +167,6 @@ const GestionarZonasPage: React.FC = () => {
value={filtroNombre}
onChange={(e) => setFiltroNombre(e.target.value)}
/>
{/* <TextField label="Filtrar por Descripción" ... /> */}
</Box>
{puedeCrear && (
<Button
@@ -165,11 +181,11 @@ const GestionarZonasPage: React.FC = () => {
</Paper>
{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>}
{!loading && !error && (
{!loading && !error && puedeVer && (
<TableContainer component={Paper}>
<Table>
<TableHead>

View File

@@ -283,8 +283,8 @@ const ReporteComparativaConsumoBobinasPage: React.FC = () => {
if (showParamSelector) {
if (!loading && !puedeVerReporte) {
return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>;
}
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}>

View File

@@ -4,12 +4,12 @@ import { Box, Typography, Paper, CircularProgress, Alert, Button, Divider, type
import reportesService from '../../services/Reportes/reportesService';
import type { ControlDevolucionesDataResponseDto } from '../../models/dtos/Reportes/ControlDevolucionesDataResponseDto';
import SeleccionaReporteControlDevoluciones from './SeleccionaReporteControlDevoluciones';
import { usePermissions } from '../../hooks/usePermissions';
import * as XLSX from 'xlsx';
import axios from 'axios';
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 [loading, setLoading] = useState(false);
const [loadingPdf, setLoadingPdf] = useState(false);
@@ -17,6 +17,8 @@ const ReporteControlDevolucionesPage: React.FC = () => {
const [apiErrorParams, setApiErrorParams] = useState<string | null>(null);
const [showParamSelector, setShowParamSelector] = useState(true);
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 => {
if (value == null) return '';
@@ -26,6 +28,11 @@ const ReporteControlDevolucionesPage: React.FC = () => {
};
const handleGenerarReporte = useCallback(async (params: { fecha: string; idEmpresa: number }) => {
if (!puedeVerReporte) {
setError("No tiene permiso para generar este reporte.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
setApiErrorParams(null);
@@ -159,92 +166,92 @@ const ReporteControlDevolucionesPage: React.FC = () => {
}, [reportData]);
const handleExportToExcel = useCallback(() => {
if (!reportData || !calculatedValues || !currentParams) {
alert("No hay datos para exportar.");
return;
}
if (!reportData || !calculatedValues || !currentParams) {
alert("No hay datos para exportar.");
return;
}
const dataForExcel: any[][] = [];
const dataForExcel: any[][] = [];
// --- Títulos y Cabecera ---
dataForExcel.push(["Control de Devoluciones"]); // Título Principal
dataForExcel.push(["Canillas / Accionistas"]); // Subtítulo
dataForExcel.push([]); // Fila vacía para espaciado
// --- Títulos y Cabecera ---
dataForExcel.push(["Control de Devoluciones"]); // Título Principal
dataForExcel.push(["Canillas / Accionistas"]); // Subtítulo
dataForExcel.push([]); // Fila vacía para espaciado
dataForExcel.push([
`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
`Cantidad Canillas: ${calculatedValues.cantidadCanillas}`
]);
dataForExcel.push([]);
dataForExcel.push([
`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
`Cantidad Canillas: ${calculatedValues.cantidadCanillas}`
]);
dataForExcel.push([]);
dataForExcel.push([currentParams.nombreEmpresa || 'EL DIA']); // Nombre de la Empresa/Publicación
dataForExcel.push([]);
dataForExcel.push([currentParams.nombreEmpresa || 'EL DIA']); // Nombre de la Empresa/Publicación
dataForExcel.push([]);
// --- Cuerpo del Reporte ---
dataForExcel.push(["Ingresados por Remito:", calculatedValues.ingresadosPorRemito]);
dataForExcel.push(["----------------------------------", "-------------------"]); // Línea divisoria (estilo simple)
// --- Cuerpo del Reporte ---
dataForExcel.push(["Ingresados por Remito:", calculatedValues.ingresadosPorRemito]);
dataForExcel.push(["----------------------------------", "-------------------"]); // Línea divisoria (estilo simple)
dataForExcel.push(["Accionistas"]);
dataForExcel.push(["Llevados", -calculatedValues.llevadosAcc]);
dataForExcel.push(["Devueltos", calculatedValues.devueltosAcc]);
dataForExcel.push(["Total", -calculatedValues.totalAcc]); // Fila de Total con estilo
dataForExcel.push([]);
dataForExcel.push(["Accionistas"]);
dataForExcel.push(["Llevados", -calculatedValues.llevadosAcc]);
dataForExcel.push(["Devueltos", calculatedValues.devueltosAcc]);
dataForExcel.push(["Total", -calculatedValues.totalAcc]); // Fila de Total con estilo
dataForExcel.push([]);
dataForExcel.push(["Canillitas"]);
dataForExcel.push(["Llevados", -calculatedValues.llevadosCan]);
dataForExcel.push(["Devueltos", calculatedValues.devueltosCan]);
dataForExcel.push(["Total", -calculatedValues.totalCan]); // Fila de Total con estilo
dataForExcel.push(["==================================", "==================="]); // Línea divisoria sólida
dataForExcel.push(["Canillitas"]);
dataForExcel.push(["Llevados", -calculatedValues.llevadosCan]);
dataForExcel.push(["Devueltos", calculatedValues.devueltosCan]);
dataForExcel.push(["Total", -calculatedValues.totalCan]); // Fila de Total con estilo
dataForExcel.push(["==================================", "==================="]); // Línea divisoria sólida
dataForExcel.push(["Total Devolución a la Fecha", calculatedValues.totalDevolucionFecha]);
dataForExcel.push(["Total Devolución Días Anteriores", calculatedValues.totalDevolucionOtrosDias]);
dataForExcel.push(["Total Devolución", calculatedValues.totalDevolucion]); // Fila de Total con estilo
dataForExcel.push(["----------------------------------", "-------------------"]);
dataForExcel.push(["Total Devolución a la Fecha", calculatedValues.totalDevolucionFecha]);
dataForExcel.push(["Total Devolución Días Anteriores", calculatedValues.totalDevolucionOtrosDias]);
dataForExcel.push(["Total Devolución", calculatedValues.totalDevolucion]); // Fila de Total con estilo
dataForExcel.push(["----------------------------------", "-------------------"]);
dataForExcel.push(["Sin Cargo", calculatedValues.sinCargo]);
dataForExcel.push(["Sobrantes", -calculatedValues.sobrantes]);
dataForExcel.push(["Diferencia", calculatedValues.diferencia]); // Fila de Total con estilo
dataForExcel.push(["Sin Cargo", calculatedValues.sinCargo]);
dataForExcel.push(["Sobrantes", -calculatedValues.sobrantes]);
dataForExcel.push(["Diferencia", calculatedValues.diferencia]); // Fila de Total con estilo
// --- Crear Hoja y Libro ---
const ws = XLSX.utils.aoa_to_sheet(dataForExcel);
// --- Crear Hoja y Libro ---
const ws = XLSX.utils.aoa_to_sheet(dataForExcel);
// Ajustar anchos de columna (opcional, pero recomendado)
// Esto es un cálculo aproximado, puedes ajustarlo
const colWidths = [
{ wch: 40 }, // Columna A (Etiquetas)
{ wch: 15 }, // Columna B (Valores)
{ wch: 25 } // Columna C (para Cantidad Canillas)
];
ws['!cols'] = colWidths;
// Ajustar anchos de columna (opcional, pero recomendado)
// Esto es un cálculo aproximado, puedes ajustarlo
const colWidths = [
{ wch: 40 }, // Columna A (Etiquetas)
{ wch: 15 }, // Columna B (Valores)
{ wch: 25 } // Columna C (para Cantidad Canillas)
];
ws['!cols'] = colWidths;
// Fusionar celdas para títulos (opcional, requiere más trabajo con la estructura de 'ws')
// Ejemplo para el título principal (ocuparía A1:C1)
if (!ws['!merges']) ws['!merges'] = [];
ws['!merges'].push({ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }); // Fusionar A1 a C1
ws['!merges'].push({ s: { r: 1, c: 0 }, e: { r: 1, c: 2 } }); // Fusionar A2 a C2
ws['!merges'].push({ s: { r: 5, c: 0 }, e: { r: 5, c: 2 } }); // Fusionar celda de Nombre Empresa
// Fusionar celdas para títulos (opcional, requiere más trabajo con la estructura de 'ws')
// Ejemplo para el título principal (ocuparía A1:C1)
if (!ws['!merges']) ws['!merges'] = [];
ws['!merges'].push({ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }); // Fusionar A1 a C1
ws['!merges'].push({ s: { r: 1, c: 0 }, e: { r: 1, c: 2 } }); // Fusionar A2 a C2
ws['!merges'].push({ s: { r: 5, c: 0 }, e: { r: 5, c: 2 } }); // Fusionar celda de Nombre Empresa
// Aplicar formato numérico (esto es más avanzado y depende de cómo quieras los números en Excel)
// Por ahora, los números se exportarán como números si son de tipo number en dataForExcel.
// Para formato de moneda o miles, tendrías que modificar las celdas en el objeto 'ws'
// o asegurarte de que los valores en 'dataForExcel' ya estén como strings formateados si Excel no los interpreta bien.
// Por simplicidad, los dejamos como números y Excel usará su formato por defecto.
// Aplicar formato numérico (esto es más avanzado y depende de cómo quieras los números en Excel)
// Por ahora, los números se exportarán como números si son de tipo number en dataForExcel.
// Para formato de moneda o miles, tendrías que modificar las celdas en el objeto 'ws'
// o asegurarte de que los valores en 'dataForExcel' ya estén como strings formateados si Excel no los interpreta bien.
// Por simplicidad, los dejamos como números y Excel usará su formato por defecto.
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "ControlDevoluciones");
let fileName = "ReporteControlDevoluciones";
fileName += `_${currentParams.nombreEmpresa?.replace(/\s+/g, '') ?? `Emp${currentParams.idEmpresa}`}`;
fileName += `_${currentParams.fecha}`;
fileName += ".xlsx";
XLSX.writeFile(wb, fileName);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "ControlDevoluciones");
let fileName = "ReporteControlDevoluciones";
fileName += `_${currentParams.nombreEmpresa?.replace(/\s+/g, '') ?? `Emp${currentParams.idEmpresa}`}`;
fileName += `_${currentParams.fecha}`;
fileName += ".xlsx";
XLSX.writeFile(wb, fileName);
}, [reportData, calculatedValues, currentParams]);
}, [reportData, calculatedValues, currentParams]);
// Componente para una fila del reporte usando Box con Flexbox
interface ReportRowProps {
@@ -306,6 +313,9 @@ const ReporteControlDevolucionesPage: React.FC = () => {
);
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}>

View File

@@ -1,6 +1,7 @@
import React, { useState, useCallback } from 'react';
import {
Box, Typography, Paper, CircularProgress, Button
Box, Typography, Paper, CircularProgress, Button,
Alert
} from '@mui/material';
import { DataGrid, type GridColDef, GridFooterContainer, GridFooter } from '@mui/x-data-grid';
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 { BalanceCuentaDebCredDto } from '../../models/dtos/Reportes/BalanceCuentaDebCredDto';
import type { BalanceCuentaPagosDto } from '../../models/dtos/Reportes/BalanceCuentaPagosDto';
import { usePermissions } from '../../hooks/usePermissions';
import SeleccionaReporteCuentasDistribuidores from './SeleccionaReporteCuentasDistribuidores';
import * as XLSX from 'xlsx';
import axios from 'axios';
@@ -20,6 +22,7 @@ type PagoConSaldo = BalanceCuentaPagosDto & { id: string; saldoAcumulado: number
const ReporteCuentasDistribuidoresPage: React.FC = () => {
const [originalReportData, setOriginalReportData] = useState<ReporteCuentasDistribuidorResponseDto | null>(null);
const [movimientosConSaldo, setMovimientosConSaldo] = useState<MovimientoConSaldo[]>([]);
const [error, setError] = useState<string | null>(null);
const [notasConSaldo, setNotasConSaldo] = useState<NotaConSaldo[]>([]);
const [pagosConSaldo, setPagosConSaldo] = useState<PagoConSaldo[]>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -34,6 +37,8 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
nombreDistribuidor?: string;
nombreEmpresa?: string;
} | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR001");
// Calcula saldos acumulados seccion por seccion
const calcularSaldosPorSeccion = (data: ReporteCuentasDistribuidorResponseDto) => {
@@ -227,6 +232,12 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
fechaDesde: string;
fechaHasta: string;
}) => {
if (!puedeVerReporte) {
setError("No tiene permiso para generar este reporte.");
setLoading(false);
return;
}
setError(null);
setLoading(true);
setApiErrorParams(null);
setOriginalReportData(null);
@@ -237,8 +248,8 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
const distSvc = (await import('../../services/Distribucion/distribuidorService')).default;
const empSvc = (await import('../../services/Distribucion/empresaService')).default;
const [distData, empData] = await Promise.all([
distSvc.getDistribuidorById(params.idDistribuidor),
empSvc.getEmpresaById(params.idEmpresa)
distSvc.getDistribuidorLookupById(params.idDistribuidor),
empSvc.getEmpresaLookupById(params.idEmpresa)
]);
setCurrentParams({
...params,
@@ -273,20 +284,39 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
}, []);
const handleExportToExcel = useCallback(() => {
if (!originalReportData) return;
const wb = XLSX.utils.book_new();
if (movimientosConSaldo.length) {
if (
!originalReportData ||
(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));
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));
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));
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`);
}, [originalReportData, movimientosConSaldo, notasConSaldo, pagosConSaldo]);
@@ -297,13 +327,16 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
const blob = await reportesService.getReporteCuentasDistribuidorPdf(currentParams);
window.open(URL.createObjectURL(blob), '_blank');
} catch {
/* manejar error */
setError('Ocurrió un error al generar el PDF.');
} finally {
setLoadingPdf(false);
}
}, [currentParams]);
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' }}>
<Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}>
@@ -328,6 +361,11 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
return (
<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 }}>
<Typography variant="h5">Cuenta Corriente Distribuidor</Typography>
<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 type { ListadoDistribucionDistribuidoresResponseDto } from '../../models/dtos/Reportes/ListadoDistribucionDistribuidoresResponseDto';
import SeleccionaReporteListadoDistribucion from './SeleccionaReporteListadoDistribucion';
import { usePermissions } from '../../hooks/usePermissions';
import * as XLSX from 'xlsx';
import axios from 'axios';
@@ -26,6 +27,8 @@ const ReporteListadoDistribucionPage: React.FC = () => {
nombrePublicacion?: string;
nombreDistribuidor?: string;
} | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR002");
// --- ESTADO PARA TOTALES CALCULADOS (PARA EL FOOTER DEL DETALLE) ---
const [totalesDetalle, setTotalesDetalle] = useState({
@@ -35,7 +38,7 @@ const ReporteListadoDistribucionPage: React.FC = () => {
promedioGeneralVentaNeta: 0,
porcentajeDevolucionGeneral: 0,
});
const [totalesPromedios, setTotalesPromedios] = useState({
cantDias: 0,
promLlevados: 0,
@@ -51,12 +54,17 @@ const ReporteListadoDistribucionPage: React.FC = () => {
fechaDesde: string;
fechaHasta: string;
}) => {
if (!puedeVerReporte) {
setError("No tiene permiso para generar este reporte.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
setApiErrorParams(null);
setReportData(null);
setTotalesDetalle({ llevados:0, devueltos:0, ventaNeta:0, promedioGeneralVentaNeta:0, porcentajeDevolucionGeneral:0 });
setTotalesPromedios({ cantDias:0, promLlevados:0, promDevueltos:0, promVentas: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 });
const pubService = (await import('../../services/Distribucion/publicacionService')).default;
@@ -74,17 +82,17 @@ const ReporteListadoDistribucionPage: React.FC = () => {
try {
const data = await reportesService.getListadoDistribucionDistribuidores(params);
let acumuladoVentaNeta = 0;
let diasConVenta = 0;
const detalleConCalculos = data.detalleSimple.map((item, index) => {
const llevados = item.llevados || 0;
const devueltos = item.devueltos || 0;
const ventaNeta = llevados - devueltos;
if (llevados > 0) diasConVenta++; // O si ventaNeta > 0, dependiendo de la definición de "día con actividad"
acumuladoVentaNeta += ventaNeta;
return {
...item,
id: `simple-${index}`,
@@ -97,13 +105,13 @@ const ReporteListadoDistribucionPage: React.FC = () => {
const totalLlevadosDetalle = detalleConCalculos.reduce((sum, item) => sum + (item.llevados || 0), 0);
const totalDevueltosDetalle = detalleConCalculos.reduce((sum, item) => sum + (item.devueltos || 0), 0);
const totalVentaNetaDetalle = totalLlevadosDetalle - totalDevueltosDetalle;
setTotalesDetalle({
llevados: totalLlevadosDetalle,
devueltos: totalDevueltosDetalle,
ventaNeta: totalVentaNetaDetalle,
promedioGeneralVentaNeta: diasConVenta > 0 ? totalVentaNetaDetalle / diasConVenta : 0,
porcentajeDevolucionGeneral: totalLlevadosDetalle > 0 ? (totalDevueltosDetalle / totalLlevadosDetalle) * 100 : 0
llevados: totalLlevadosDetalle,
devueltos: totalDevueltosDetalle,
ventaNeta: totalVentaNetaDetalle,
promedioGeneralVentaNeta: diasConVenta > 0 ? totalVentaNetaDetalle / diasConVenta : 0,
porcentajeDevolucionGeneral: totalLlevadosDetalle > 0 ? (totalDevueltosDetalle / totalLlevadosDetalle) * 100 : 0
});
@@ -112,7 +120,7 @@ const ReporteListadoDistribucionPage: React.FC = () => {
id: `prom-${index}`,
porcentajeDevolucion: item.promedio_Llevados > 0 ? (item.promedio_Devueltos / item.promedio_Llevados) * 100 : 0,
}));
// Calcular totales para la tabla de promedios (ponderados por Cant. Días)
const totalDiasPromedios = promediosConCalculos.reduce((sum, item) => sum + (item.cant || 0), 0);
const totalPonderadoLlevados = promediosConCalculos.reduce((sum, item) => sum + ((item.promedio_Llevados || 0) * (item.cant || 0)), 0);
@@ -190,7 +198,7 @@ const ReporteListadoDistribucionPage: React.FC = () => {
"Prom. Ventas": rest.promedio_Ventas,
"% Devolución": (rest as any).porcentajeDevolucion, // Ya calculado
}));
// Fila de totales para promedios
// Fila de totales para promedios
promediosToExport.push({
"Día Semana": "General",
"Cant. Días": totalesPromedios.cantDias,
@@ -222,7 +230,7 @@ const ReporteListadoDistribucionPage: React.FC = () => {
setError(null);
try {
const blob = await reportesService.getListadoDistribucionDistribuidoresPdf(currentParams);
if (blob.type === "application/json") {
if (blob.type === "application/json") {
const text = await blob.text();
const msg = JSON.parse(text).message ?? "Error inesperado al generar PDF.";
setError(msg);
@@ -261,15 +269,15 @@ const ReporteListadoDistribucionPage: React.FC = () => {
// --- Custom Footer para Detalle Diario ---
const CustomFooterDetalle = () => (
<GridFooterContainer
sx={{
<GridFooterContainer
sx={{
// Asegurar que el contenedor pueda usar todo el ancho
// y que los items internos puedan distribuirse.
// justifyContent: 'space-between' // Esto podría ayudar
}}
>
{/* Contenedor para los elementos del footer por defecto (paginación, etc.) */}
<Box sx={{
<Box sx={{
// flexGrow: 1, // Originalmente teníamos esto, puede ser muy agresivo
display: 'flex', // Para alinear los items del paginador por defecto
alignItems: 'center',
@@ -277,66 +285,66 @@ const ReporteListadoDistribucionPage: React.FC = () => {
minWidth: '400px', // AJUSTA ESTE VALOR según lo que necesites
flexShrink: 0, // Evita que se encoja demasiado si los totales son muy anchos
}}>
<GridFooter sx={{
borderTop: 'none',
// '& .MuiTablePagination-toolbar': { // Para los elementos dentro del paginador
// flexWrap: 'wrap', // Permitir que los elementos internos del paginador se envuelvan
// justifyContent: 'flex-start',
// },
// '& .MuiTablePagination-spacer': { // El espaciador puede ser un problema
// display: 'none', // Prueba quitándolo
// }
}} />
<GridFooter sx={{
borderTop: 'none',
// '& .MuiTablePagination-toolbar': { // Para los elementos dentro del paginador
// flexWrap: 'wrap', // Permitir que los elementos internos del paginador se envuelvan
// justifyContent: 'flex-start',
// },
// '& .MuiTablePagination-spacer': { // El espaciador puede ser un problema
// display: 'none', // Prueba quitándolo
// }
}} />
</Box>
{/* Contenedor para tus totales, alineado a la derecha */}
<Box sx={{
p: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
marginLeft: 'auto', // Empuja a la derecha
flexShrink: 1, // Permite que este se encoja si es necesario, pero no demasiado
overflowX: 'auto', // Si los totales son muchos, que tengan su propio scroll
whiteSpace: 'nowrap', // Evitar que los textos de totales se partan
<Box sx={{
p: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
marginLeft: 'auto', // Empuja a la derecha
flexShrink: 1, // Permite que este se encoja si es necesario, pero no demasiado
overflowX: 'auto', // Si los totales son muchos, que tengan su propio scroll
whiteSpace: 'nowrap', // Evitar que los textos de totales se partan
}}>
{/* 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={{ 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[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[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[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[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesDetalle.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography>
</Box>
</GridFooterContainer>
);
// --- Custom Footer para Promedios por Día (Ajustado para flex) ---
const CustomFooterPromedios = () => (
<GridFooterContainer sx={{ /* justifyContent: 'space-between' */ }}>
<Box sx={{
<Box sx={{
// flexGrow: 1,
display: 'flex',
display: 'flex',
alignItems: 'center',
minWidth: '400px', // AJUSTA ESTE VALOR
flexShrink: 0,
}}>
<GridFooter sx={{ borderTop: 'none' }} />
</Box>
<Box sx={{
p: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
marginLeft: 'auto',
flexShrink: 1,
overflowX: 'auto',
whiteSpace: 'nowrap',
<Box sx={{
p: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
marginLeft: 'auto',
flexShrink: 1,
overflowX: 'auto',
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={{ 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[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={{ 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[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[5].flex, minWidth: columnsPromedios[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography>
</Box>
</GridFooterContainer>
@@ -344,6 +352,9 @@ const ReporteListadoDistribucionPage: React.FC = () => {
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}>

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: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores' },
{ 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 = [
'Balance de Cuentas',
'Listados Distribución',
'Ctrl. Devoluciones',
'Novedades de Canillitas',
'Existencia Papel',
'Movimientos Bobinas',
'Consumos Bobinas',

View File

@@ -4,7 +4,7 @@ import {
FormControl, InputLabel, Select, MenuItem
} from '@mui/material';
import empresaService from '../../services/Distribucion/empresaService';
import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
interface SeleccionaReporteControlDevolucionesProps {
onGenerarReporte: (params: {
@@ -24,7 +24,7 @@ const SeleccionaReporteControlDevoluciones: React.FC<SeleccionaReporteControlDev
}) => {
const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]);
const [idEmpresa, setIdEmpresa] = useState<number | string>('');
const [empresas, setEmpresas] = useState<EmpresaDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loadingEmpresas, setLoadingEmpresas] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
@@ -32,7 +32,7 @@ const SeleccionaReporteControlDevoluciones: React.FC<SeleccionaReporteControlDev
const fetchEmpresas = async () => {
setLoadingEmpresas(true);
try {
const data = await empresaService.getAllEmpresas(); // Solo habilitadas
const data = await empresaService.getEmpresasDropdown(); // Solo habilitadas
setEmpresas(data);
} catch (error) {
console.error("Error al cargar empresas:", error);

View File

@@ -3,9 +3,9 @@ import {
Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem
} 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 type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
import empresaService from '../../services/Distribucion/empresaService';
interface SeleccionaReporteCuentasDistribuidoresProps {
@@ -30,8 +30,8 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
const [fechaDesde, setFechaDesde] = 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 [empresas, setEmpresas] = useState<EmpresaDto[]>([]);
const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
@@ -40,8 +40,8 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
setLoadingDropdowns(true);
try {
const [distData, empData] = await Promise.all([
distribuidorService.getAllDistribuidores(), // Asume que este servicio existe
empresaService.getAllEmpresas() // Asume que este servicio existe
distribuidorService.getAllDistribuidoresDropdown(), // Asume que este servicio existe
empresaService.getEmpresasDropdown() // Asume que este servicio existe
]);
setDistribuidores(distData.map(d => d)); // El servicio devuelve tupla
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,
FormControl, InputLabel, Select, MenuItem
} 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 type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto';
import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto';
import distribuidorService from '../../services/Distribucion/distribuidorService';
interface SeleccionaReporteListadoDistribucionProps {
@@ -30,8 +30,8 @@ const SeleccionaReporteListadoDistribucion: React.FC<SeleccionaReporteListadoDis
const [fechaDesde, setFechaDesde] = 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 [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]);
const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]);
const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
@@ -40,8 +40,8 @@ const SeleccionaReporteListadoDistribucion: React.FC<SeleccionaReporteListadoDis
setLoadingDropdowns(true);
try {
const [distData, pubData] = await Promise.all([
distribuidorService.getAllDistribuidores(),
publicacionService.getAllPublicaciones(undefined, undefined, true) // Solo habilitadas
distribuidorService.getAllDistribuidoresDropdown(),
publicacionService.getPublicacionesForDropdown(true) // Solo habilitadas
]);
setDistribuidores(distData.map(d => d));
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

@@ -1,27 +1,79 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, Paper, CircularProgress, Alert
Box, Typography, Button, Paper, CircularProgress, Alert
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import perfilService from '../../services/Usuarios/perfilService';
import type { PermisoAsignadoDto } from '../../models/dtos/Usuarios/PermisoAsignadoDto';
import type { PerfilDto } from '../../models/dtos/Usuarios/PerfilDto';
import { usePermissions } 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 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 { idPerfil } = useParams<{ idPerfil: string }>();
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 [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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -32,29 +84,28 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
const cargarDatos = useCallback(async () => {
if (!puedeAsignar) {
setError("Acceso denegado. No tiene permiso para asignar permisos.");
setLoading(false);
return;
setError("Acceso denegado. No tiene permiso para asignar permisos.");
setLoading(false);
return;
}
if (isNaN(idPerfilNum)) {
setError("ID de Perfil inválido.");
setLoading(false);
return;
setError("ID de Perfil inválido.");
setLoading(false);
return;
}
setLoading(true); setError(null); setSuccessMessage(null);
try {
const [perfilData, permisosData] = await Promise.all([
perfilService.getPerfilById(idPerfilNum),
perfilService.getPermisosPorPerfil(idPerfilNum)
perfilService.getPermisosPorPerfil(idPerfilNum) // Esto devuelve todos los permisos con su estado 'asignado'
]);
setPerfil(perfilData);
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)));
} catch (err) {
console.error(err);
setError('Error al cargar datos del perfil o permisos.');
if (axios.isAxiosError(err) && err.response?.status === 404) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
setError(`Perfil con ID ${idPerfilNum} no encontrado.`);
}
} finally {
@@ -66,22 +117,83 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
cargarDatos();
}, [cargarDatos]);
const handlePermisoChange = (permisoId: number, asignado: boolean) => {
setPermisosSeleccionados(prev => {
const next = new Set(prev);
if (asignado) {
next.add(permisoId);
} else {
next.delete(permisoId);
}
return next;
const handlePermisoChange = useCallback((
permisoId: number,
asignadoViaCheckboxHijo: boolean, // Este valor es el 'e.target.checked' si el clic fue en un hijo
esPermisoSeccionClick = false,
moduloConceptualAsociado?: string // Este es el módulo conceptual del padre SSxxx o del grupo del hijo
) => {
setPermisosSeleccionados(prevSelected => {
const newSelected = new Set(prevSelected);
const permisoActual = permisosDisponibles.find(p => p.id === permisoId);
if (!permisoActual) return prevSelected;
const 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));
}
} else if (!esPermisoSeccionClick && moduloConceptualAsociado) { // Clic en un permiso hijo
if (asignadoViaCheckboxHijo) {
newSelected.add(permisoId);
const permisoSeccionPadre = permisosDisponibles.find(
ps => ps.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX) && getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptualAsociado
);
if (permisoSeccionPadre && !newSelected.has(permisoSeccionPadre.id)) {
newSelected.add(permisoSeccionPadre.id); // Marcar padre si no estaba
}
} else { // Desmarcando un hijo
newSelected.delete(permisoId);
const permisoSeccionPadre = permisosDisponibles.find(
ps => ps.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX) && getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptualAsociado
);
if (permisoSeccionPadre) {
const algunOtroHijoSeleccionado = permisosDelModuloHijo.some(p => p.id !== permisoId && newSelected.has(p.id));
if (!algunOtroHijoSeleccionado && newSelected.has(permisoSeccionPadre.id)) {
// Si era el último hijo y el padre estaba marcado, NO desmarcamos el padre automáticamente.
// El estado indeterminate se encargará visualmente.
// Si quisiéramos que se desmarque el padre, aquí iría: newSelected.delete(permisoSeccionPadre.id);
}
}
}
} else { // Permiso sin módulo conceptual asociado (ej: "Permisos (Definición)")
if (asignadoViaCheckboxHijo) {
newSelected.add(permisoId);
} else {
newSelected.delete(permisoId);
}
}
if (successMessage) setSuccessMessage(null);
if (error) setError(null);
return newSelected;
});
// Limpiar mensajes al cambiar selección
if (successMessage) setSuccessMessage(null);
if (error) setError(null);
};
}, [permisosDisponibles, successMessage, error]);
const handleGuardarCambios = async () => {
// ... (sin cambios) ...
if (!puedeAsignar || !perfil) return;
setSaving(true); setError(null); setSuccessMessage(null);
try {
@@ -89,13 +201,12 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
permisosIds: Array.from(permisosSeleccionados)
});
setSuccessMessage('Permisos actualizados correctamente.');
// Opcional: recargar datos, aunque el estado local ya está actualizado
// cargarDatos();
await cargarDatos();
} catch (err: any) {
console.error(err);
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Error al guardar los permisos.';
? err.response.data.message
: 'Error al guardar los permisos.';
setError(message);
} finally {
setSaving(false);
@@ -103,56 +214,54 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
};
if (loading) {
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
return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>;
}
if (!puedeAsignar) {
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)
return <Alert severity="warning" sx={{ m: 2 }}>Perfil no encontrado.</Alert>;
}
if (error && !perfil) {
return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>;
}
if (!puedeAsignar) {
return <Alert severity="error" sx={{ m: 2 }}>Acceso denegado.</Alert>;
}
if (!perfil && !loading) {
return <Alert severity="warning" sx={{ m: 2 }}>Perfil no encontrado o error al cargar.</Alert>;
}
return (
<Box sx={{ p: 1 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/usuarios/perfiles')} sx={{ mb: 2 }}>
Volver a Perfiles
</Button>
<Typography variant="h5" gutterBottom>
Asignar Permisos al Perfil: {perfil?.nombrePerfil || 'Cargando...'}
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
ID Perfil: {perfil?.id}
</Typography>
return (
<Box sx={{ p: 1 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/usuarios/perfiles')} sx={{ mb: 2 }}>
Volver a Perfiles
</Button>
<Typography variant="h5" gutterBottom>
Asignar Permisos al Perfil: {perfil?.nombrePerfil || 'Cargando...'}
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
ID Perfil: {perfil?.id}
</Typography>
{error && !successMessage && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{successMessage && <Alert severity="success" sx={{ mb: 2 }}>{successMessage}</Alert>}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{successMessage && <Alert severity="success" sx={{ mb: 2 }}>{successMessage}</Alert>}
<Paper sx={{ p: 2, mt: 2 }}>
<PermisosChecklist
permisosDisponibles={permisosDisponibles}
permisosSeleccionados={permisosSeleccionados}
onPermisoChange={handlePermisoChange}
disabled={saving}
/>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
startIcon={saving ? <CircularProgress size={20} color="inherit" /> : <SaveIcon />}
onClick={handleGuardarCambios}
disabled={saving || !puedeAsignar}
>
Guardar Cambios
</Button>
<Paper sx={{ p: { xs: 1, sm: 2 }, mt: 2 }}>
<PermisosChecklist
permisosDisponibles={permisosDisponibles}
permisosSeleccionados={permisosSeleccionados}
onPermisoChange={handlePermisoChange}
disabled={saving}
/>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
startIcon={saving ? <CircularProgress size={20} color="inherit" /> : <SaveIcon />}
onClick={handleGuardarCambios}
disabled={saving || !puedeAsignar}
>
Guardar Cambios
</Button>
</Box>
</Paper>
</Box>
</Paper>
</Box>
);
);
};
export default AsignarPermisosAPerfilPage;

View File

@@ -6,6 +6,7 @@ import HomePage from '../pages/HomePage';
import { useAuth } from '../contexts/AuthContext';
import MainLayout from '../layouts/MainLayout';
import { Typography } from '@mui/material';
import SectionProtectedRoute from './SectionProtectedRoute';
// Distribución
import DistribucionIndexPage from '../pages/Distribucion/DistribucionIndexPage';
@@ -38,6 +39,7 @@ import ContablesIndexPage from '../pages/Contables/ContablesIndexPage';
import GestionarTiposPagoPage from '../pages/Contables/GestionarTiposPagoPage';
import GestionarPagosDistribuidorPage from '../pages/Contables/GestionarPagosDistribuidorPage';
import GestionarNotasCDPage from '../pages/Contables/GestionarNotasCDPage';
import GestionarSaldosPage from '../pages/Contables/GestionarSaldosPage';
// Usuarios
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 ReporteListadoDistribucionPage from '../pages/Reportes/ReporteListadoDistribucionPage';
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
import GestionarAuditoriaUsuariosPage from '../pages/Usuarios/Auditoria/GestionarAuditoriaUsuariosPage';
// --- ProtectedRoute y PublicRoute SIN CAMBIOS ---
const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
@@ -107,7 +113,7 @@ const MainLayoutWrapper: React.FC = () => (
const AppRoutes = () => {
return (
<BrowserRouter>
<Routes> {/* Un solo <Routes> de nivel superior */}
<Routes>
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
{/* Rutas Protegidas que usan el MainLayout */}
@@ -123,13 +129,21 @@ const AppRoutes = () => {
<Route index element={<HomePage />} /> {/* Para la ruta exacta "/" */}
{/* 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 path="es-canillas" element={<GestionarEntradasSalidasCanillaPage />} />
<Route path="control-devoluciones" element={<GestionarControlDevolucionesPage />} />
<Route path="es-distribuidores" element={<GestionarEntradasSalidasDistPage />} />
<Route path="salidas-otros-destinos" element={<GestionarSalidasOtrosDestinosPage />} />
<Route path="canillas" element={<GestionarCanillitasPage />} />
<Route path="canillas/:idCanilla/novedades" element={<GestionarNovedadesCanillaPage />} />
<Route path="distribuidores" element={<GestionarDistribuidoresPage />} />
<Route path="otros-destinos" element={<GestionarOtrosDestinosPage />} />
<Route path="zonas" element={<GestionarZonasPage />} />
@@ -146,15 +160,28 @@ const AppRoutes = () => {
</Route>
{/* 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 path="tipos-pago" element={<GestionarTiposPagoPage />} />
<Route path="pagos-distribuidores" element={<GestionarPagosDistribuidorPage />} />
<Route path="notas-cd" element={<GestionarNotasCDPage />} />
<Route path="gestion-saldos" element={<GestionarSaldosPage />} />
</Route>
{/* 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 path="plantas" element={<GestionarPlantasPage />} />
<Route path="tipos-bobina" element={<GestionarTiposBobinaPage />} />
@@ -164,8 +191,13 @@ const AppRoutes = () => {
</Route>
{/* Módulo de Reportes */}
<Route path="reportes" element={<ReportesIndexPage />}> {/* Página principal del módulo */}
<Route index element={<Typography sx={{p:2}}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */}
<Route path="reportes"
element={
<SectionProtectedRoute requiredPermission="SS004" sectionName="Reportes">
<ReportesIndexPage />
</SectionProtectedRoute>}
>
<Route index element={<Typography sx={{ p: 2 }}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */}
<Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} />
<Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} />
<Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} />
@@ -181,10 +213,17 @@ const AppRoutes = () => {
<Route path="cuentas-distribuidores" element={<ReporteCuentasDistribuidoresPage />} />
<Route path="listado-distribucion-distribuidores" element={<ReporteListadoDistribucionPage />} />
<Route path="control-devoluciones" element={<ReporteControlDevolucionesPage />} />
<Route path="novedades-canillas" element={<ReporteNovedadesCanillasPage />} />
<Route path="listado-distribucion-mensual" element={<ReporteListadoDistMensualPage />} />
</Route>
{/* 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 path="ritmos" element={<GestionarRitmosPage />} />
<Route path="canciones" element={<GestionarCancionesPage />} />
@@ -192,7 +231,12 @@ const AppRoutes = () => {
</Route>
{/* 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 path="perfiles" element={<GestionarPerfilesPage />} />
<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';
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> = {};
if (nomApeFilter) params.nomApe = nomApeFilter;
if (legajoFilter !== undefined && legajoFilter !== null) params.legajo = legajoFilter;
if (soloActivos !== undefined) params.soloActivos = soloActivos;
if (esAccionistaFilter !== undefined) params.esAccionista = esAccionistaFilter; // <<-- ¡CLAVE! Verifica esto.
const response = await apiClient.get<CanillaDto[]>('/canillas', { params });
return response.data;

Some files were not shown because too many files have changed in this diff Show More