Refactor: Mejora la lógica de facturación y la UI

Este commit introduce una refactorización significativa en el módulo de
suscripciones para alinear el sistema con reglas de negocio clave:
facturación consolidada por empresa, cobro a mes adelantado con
imputación de ajustes diferida, y una interfaz de usuario más clara.

Backend:
- **Facturación por Empresa:** Se modifica `FacturacionService` para
  agrupar las suscripciones por cliente y empresa, generando una
  factura consolidada para cada combinación. Esto asegura la correcta
  separación fiscal.
- **Imputación de Ajustes:** Se ajusta la lógica para que la facturación
  de un período (ej. Septiembre) aplique únicamente los ajustes
  pendientes cuya fecha corresponde al período anterior (Agosto).
- **Cierre Secuencial:** Se implementa una validación en
  `GenerarFacturacionMensual` que impide generar la facturación de un
  período si el anterior no ha sido cerrado, garantizando el orden
  cronológico.
- **Emails Consolidados:** El proceso de notificación automática al
  generar el cierre ahora envía un único email consolidado por
  suscriptor, detallando los cargos de todas sus facturas/empresas.
- **Envío de PDF Individual:** Se refactoriza el endpoint de envío manual
  para que opere sobre una `idFactura` individual y adjunte el PDF
  correspondiente si existe.
- **Repositorios Mejorados:** Se optimizan y añaden métodos en
  `FacturaRepository` y `AjusteRepository` para soportar los nuevos
  requisitos de filtrado y consulta de datos consolidados.

Frontend:
- **Separación de Vistas:** La página de "Facturación" se divide en dos:
  - `ProcesosPage`: Para acciones masivas (generar cierre, archivo de
    débito, procesar respuesta).
  - `ConsultaFacturasPage`: Una nueva página dedicada a buscar,
    filtrar y gestionar facturas individuales con una interfaz de doble
    acordeón (Suscriptor -> Empresa).
- **Filtros Avanzados:** La página `ConsultaFacturasPage` ahora incluye
  filtros por nombre de suscriptor, estado de pago y estado de
  facturación.
- **Filtros de Fecha por Defecto:** La página de "Cuenta Corriente"
  ahora filtra por el mes actual por defecto para mejorar el rendimiento
  y la usabilidad.
- **Validación de Fechas:** Se añade lógica en los filtros de fecha para
  impedir la selección de rangos inválidos.
- **Validación de Monto de Pago:** El modal de pago manual ahora impide
  registrar un monto superior al saldo pendiente de la factura.
This commit is contained in:
2025-08-08 09:48:15 -03:00
parent 9cfe9d012e
commit 899e0a173f
87 changed files with 2947 additions and 1231 deletions

View File

@@ -1,12 +1,9 @@
// --- REEMPLAZAR ARCHIVO: Controllers/Reportes/PdfTemplates/DistribucionCanillasDocument.cs ---
using GestionIntegral.Api.Dtos.Reportes;
using GestionIntegral.Api.Dtos.Reportes.ViewModels;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
{

View File

@@ -0,0 +1,121 @@
using GestionIntegral.Api.Dtos.Reportes.ViewModels;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using System.Globalization;
using System.Linq;
namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
{
public class FacturasPublicidadDocument : IDocument
{
public FacturasPublicidadViewModel Model { get; }
public FacturasPublicidadDocument(FacturasPublicidadViewModel model)
{
Model = model;
}
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
public void Compose(IDocumentContainer container)
{
container.Page(page =>
{
page.Margin(1, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontFamily("Arial").FontSize(9));
page.Header().Element(ComposeHeader);
page.Content().Element(ComposeContent);
page.Footer().AlignCenter().Text(x => { x.Span("Página "); x.CurrentPageNumber(); });
});
}
void ComposeHeader(IContainer container)
{
// Se envuelve todo el contenido del header en una única Columna.
container.Column(column =>
{
// El primer item de la columna es la fila con los títulos.
column.Item().Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text($"Reporte de Suscripciones a Facturar").SemiBold().FontSize(14);
col.Item().Text($"Período: {Model.Periodo}").FontSize(11);
});
row.ConstantItem(150).AlignRight().Column(col => {
col.Item().AlignRight().Text($"Fecha de Generación:");
col.Item().AlignRight().Text(Model.FechaGeneracion);
});
});
// El segundo item de la columna es el separador.
column.Item().PaddingTop(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten2);
});
}
void ComposeContent(IContainer container)
{
container.PaddingTop(10).Column(column =>
{
column.Spacing(20);
foreach (var empresaData in Model.DatosPorEmpresa)
{
column.Item().Element(c => ComposeTablaPorEmpresa(c, empresaData));
}
column.Item().AlignRight().PaddingTop(15).Text($"Total General a Facturar: {Model.TotalGeneral.ToString("C", new CultureInfo("es-AR"))}").Bold().FontSize(12);
});
}
void ComposeTablaPorEmpresa(IContainer container, DatosEmpresaViewModel empresaData)
{
container.Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3); // Nombre Suscriptor
columns.ConstantColumn(100); // Documento
columns.ConstantColumn(100, Unit.Point); // Importe
});
table.Header(header =>
{
header.Cell().ColumnSpan(3).Background(Colors.Grey.Lighten2)
.Padding(5).Text(empresaData.NombreEmpresa).Bold().FontSize(12);
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Padding(2).Text("Suscriptor").SemiBold();
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Padding(2).Text("Documento").SemiBold();
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten3).Padding(2).AlignRight().Text("Importe a Facturar").SemiBold();
});
var facturasPorSuscriptor = empresaData.Facturas.GroupBy(f => f.NombreSuscriptor);
foreach (var grupoSuscriptor in facturasPorSuscriptor.OrderBy(g => g.Key))
{
foreach(var item in grupoSuscriptor)
{
table.Cell().Padding(2).Text(item.NombreSuscriptor);
table.Cell().Padding(2).Text($"{item.TipoDocumento} {item.NroDocumento}");
table.Cell().Padding(2).AlignRight().Text(item.ImporteFinal.ToString("C", new CultureInfo("es-AR")));
}
if(grupoSuscriptor.Count() > 1)
{
var subtotal = grupoSuscriptor.Sum(i => i.ImporteFinal);
table.Cell().ColumnSpan(2).AlignRight().Padding(2).Text($"Subtotal {grupoSuscriptor.Key}:").Italic();
table.Cell().AlignRight().Padding(2).Text(subtotal.ToString("C", new CultureInfo("es-AR"))).Italic().SemiBold();
}
}
table.Cell().ColumnSpan(2).BorderTop(1).BorderColor(Colors.Grey.Darken1).AlignRight()
.PaddingTop(5).Text("Total Empresa:").Bold();
table.Cell().BorderTop(1).BorderColor(Colors.Grey.Darken1).AlignRight()
.PaddingTop(5).Text(empresaData.TotalEmpresa.ToString("C", new CultureInfo("es-AR"))).Bold();
});
}
}
}

View File

@@ -1,15 +1,8 @@
using GestionIntegral.Api.Services.Reportes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Reporting.NETCore;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using GestionIntegral.Api.Dtos.Reportes;
using GestionIntegral.Api.Data.Repositories.Impresion;
using System.IO;
using System.Linq;
using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Services.Distribucion;
using GestionIntegral.Api.Services.Pdf;
@@ -45,6 +38,7 @@ namespace GestionIntegral.Api.Controllers
private const string PermisoVerReporteConsumoBobinas = "RR007";
private const string PermisoVerReporteNovedadesCanillas = "RR004";
private const string PermisoVerReporteListadoDistMensual = "RR009";
private const string PermisoVerReporteFacturasPublicidad = "RR010";
public ReportesController(
IReportesService reportesService,
@@ -1676,5 +1670,54 @@ namespace GestionIntegral.Api.Controllers
return StatusCode(500, "Error interno al generar el PDF del reporte.");
}
}
[HttpGet("suscripciones/facturas-para-publicidad/pdf")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GetReporteFacturasPublicidadPdf([FromQuery] int anio, [FromQuery] int mes)
{
if (!TienePermiso(PermisoVerReporteFacturasPublicidad)) return Forbid();
var (data, error) = await _reportesService.ObtenerFacturasParaReportePublicidad(anio, mes);
if (error != null) return BadRequest(new { message = error });
if (data == null || !data.Any())
{
return NotFound(new { message = "No hay facturas pagadas y pendientes de facturar para el período seleccionado." });
}
try
{
// --- INICIO DE LA LÓGICA DE AGRUPACIÓN ---
var datosAgrupados = data
.GroupBy(f => f.IdEmpresa)
.Select(g => new DatosEmpresaViewModel
{
NombreEmpresa = g.First().NombreEmpresa,
Facturas = g.ToList()
})
.OrderBy(e => e.NombreEmpresa);
var viewModel = new FacturasPublicidadViewModel
{
DatosPorEmpresa = datosAgrupados,
Periodo = new DateTime(anio, mes, 1).ToString("MMMM yyyy", new CultureInfo("es-ES")),
FechaGeneracion = DateTime.Now.ToString("dd/MM/yyyy HH:mm")
};
// --- FIN DE LA LÓGICA DE AGRUPACIÓN ---
var document = new FacturasPublicidadDocument(viewModel);
byte[] pdfBytes = await _pdfGenerator.GeneratePdfAsync(document);
string fileName = $"ReportePublicidad_Suscripciones_{anio}-{mes:D2}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al generar PDF para Reporte de Facturas a Publicidad.");
return StatusCode(500, "Error interno al generar el PDF del reporte.");
}
}
}
}

View File

@@ -34,10 +34,10 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
// GET: api/suscriptores/{idSuscriptor}/ajustes
[HttpGet("~/api/suscriptores/{idSuscriptor:int}/ajustes")]
[ProducesResponseType(typeof(IEnumerable<AjusteDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAjustesPorSuscriptor(int idSuscriptor)
public async Task<IActionResult> GetAjustesPorSuscriptor(int idSuscriptor, [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta)
{
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor);
var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor, fechaDesde, fechaHasta);
return Ok(ajustes);
}
@@ -74,5 +74,21 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
return Ok(new { message = "Ajuste anulado correctamente." });
}
// PUT: api/ajustes/{id}
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateAjuste(int id, [FromBody] UpdateAjusteDto updateDto)
{
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var (exito, error) = await _ajusteService.ActualizarAjuste(id, updateDto);
if (!exito)
{
if (error != null && error.Contains("no encontrado")) return NotFound(new { message = error });
return BadRequest(new { message = error });
}
return NoContent();
}
}
}

View File

@@ -13,9 +13,8 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
{
private readonly IFacturacionService _facturacionService;
private readonly ILogger<FacturacionController> _logger;
// Permiso para generar facturación (a crear en la BD)
private const string PermisoGenerarFacturacion = "SU006";
private const string PermisoGestionarFacturacion = "SU006";
private const string PermisoEnviarEmail = "SU009";
public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger)
{
@@ -28,67 +27,94 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
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 FacturacionController.");
return null;
}
// POST: api/facturacion/{anio}/{mes}
[HttpPost("{anio:int}/{mes:int}")]
public async Task<IActionResult> GenerarFacturacion(int anio, int mes)
[HttpPut("{idFactura:int}/numero-factura")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> UpdateNumeroFactura(int idFactura, [FromBody] string numeroFactura)
{
if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid();
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
if (anio < 2020 || mes < 1 || mes > 12)
{
return BadRequest(new { message = "El año y el mes proporcionados no son válidos." });
}
var (exito, mensaje, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
var (exito, error) = await _facturacionService.ActualizarNumeroFactura(idFactura, numeroFactura, userId.Value);
if (!exito)
{
return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje });
if (error != null && error.Contains("no existe")) return NotFound(new { message = error });
return BadRequest(new { message = error });
}
return Ok(new { message = mensaje, facturasGeneradas });
return NoContent();
}
// GET: api/facturacion/{anio}/{mes}
[HttpGet("{anio:int}/{mes:int}")]
[ProducesResponseType(typeof(IEnumerable<FacturaDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetFacturas(int anio, int mes)
// POST: api/facturacion/{idFactura}/enviar-factura-pdf
[HttpPost("{idFactura:int}/enviar-factura-pdf")]
public async Task<IActionResult> EnviarFacturaPdf(int idFactura)
{
// Usamos el permiso de generar facturación también para verlas.
if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid();
if (anio < 2020 || mes < 1 || mes > 12)
{
return BadRequest(new { message = "El período no es válido." });
}
var facturas = await _facturacionService.ObtenerFacturasPorPeriodo(anio, mes);
return Ok(facturas);
}
// POST: api/facturacion/{idFactura}/enviar-email
[HttpPost("{idFactura:int}/enviar-email")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> EnviarEmail(int idFactura)
{
// Usaremos un nuevo permiso para esta acción
if (!TienePermiso("SU009")) return Forbid();
var (exito, error) = await _facturacionService.EnviarFacturaPorEmail(idFactura);
var (exito, error) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura);
if (!exito)
{
return BadRequest(new { message = error });
}
return Ok(new { message = "Email con factura PDF enviado a la cola de procesamiento." });
}
return Ok(new { message = "Email enviado a la cola de procesamiento." });
// POST: api/facturacion/{anio}/{mes}/suscriptor/{idSuscriptor}/enviar-aviso
[HttpPost("{anio:int}/{mes:int}/suscriptor/{idSuscriptor:int}/enviar-aviso")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> EnviarAvisoPorEmail(int anio, int mes, int idSuscriptor)
{
// Usamos el permiso de enviar email
if (!TienePermiso("SU009")) return Forbid();
var (exito, error) = await _facturacionService.EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor);
if (!exito)
{
if (error != null && (error.Contains("no encontrada") || error.Contains("no es válido")))
{
return NotFound(new { message = error });
}
return BadRequest(new { message = error });
}
return Ok(new { message = "Email consolidado para el suscriptor ha sido enviado a la cola de procesamiento." });
}
// GET: api/facturacion/{anio}/{mes}
[HttpGet("{anio:int}/{mes:int}")]
public async Task<IActionResult> GetFacturas(
int anio, int mes,
[FromQuery] string? nombreSuscriptor,
[FromQuery] string? estadoPago,
[FromQuery] string? estadoFacturacion)
{
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." });
var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion);
return Ok(resumenes);
}
[HttpPost("{anio:int}/{mes:int}")]
public async Task<IActionResult> GenerarFacturacion(int anio, int mes)
{
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El año y el mes proporcionados no son válidos." });
var (exito, mensaje, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value);
if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje });
return Ok(new { message = mensaje, facturasGeneradas });
}
}
}

View File

@@ -112,15 +112,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
return Ok(promos);
}
// POST: api/suscripciones/{idSuscripcion}/promociones/{idPromocion}
[HttpPost("{idSuscripcion:int}/promociones/{idPromocion:int}")]
public async Task<IActionResult> AsignarPromocion(int idSuscripcion, int idPromocion)
// POST: api/suscripciones/{idSuscripcion}/promociones
[HttpPost("{idSuscripcion:int}/promociones")]
public async Task<IActionResult> AsignarPromocion(int idSuscripcion, [FromBody] AsignarPromocionDto dto)
{
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, idPromocion, userId.Value);
var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, dto, userId.Value);
if (!exito) return BadRequest(new { message = error });
return Ok();
}

View File

@@ -45,5 +45,6 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
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);
Task<IEnumerable<FacturasParaReporteDto>> GetDatosReportePublicidadAsync(string periodo);
}
}

View File

@@ -547,5 +547,50 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
commandType: CommandType.StoredProcedure, commandTimeout: 120
);
}
public async Task<IEnumerable<FacturasParaReporteDto>> GetDatosReportePublicidadAsync(string periodo)
{
// Esta consulta une todas las tablas necesarias para obtener los datos del reporte
const string sql = @"
SELECT
f.IdFactura,
f.Periodo,
s.NombreCompleto AS NombreSuscriptor,
s.TipoDocumento,
s.NroDocumento,
f.ImporteFinal,
e.Id_Empresa AS IdEmpresa,
e.Nombre AS NombreEmpresa
FROM dbo.susc_Facturas f
JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor
-- Usamos una subconsulta para obtener la empresa de forma segura
JOIN (
SELECT DISTINCT
fd.IdFactura,
p.Id_Empresa
FROM dbo.susc_FacturaDetalles fd
JOIN dbo.susc_Suscripciones sub ON fd.IdSuscripcion = sub.IdSuscripcion
JOIN dbo.dist_dtPublicaciones p ON sub.IdPublicacion = p.Id_Publicacion
) AS FacturaEmpresa ON f.IdFactura = FacturaEmpresa.IdFactura
JOIN dbo.dist_dtEmpresas e ON FacturaEmpresa.Id_Empresa = e.Id_Empresa
WHERE
f.Periodo = @Periodo
AND f.EstadoPago = 'Pagada'
AND f.EstadoFacturacion = 'Pendiente de Facturar'
ORDER BY
e.Nombre, s.NombreCompleto;
";
try
{
using var connection = _dbConnectionFactory.CreateConnection();
return await connection.QueryAsync<FacturasParaReporteDto>(sql, new { Periodo = periodo });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al ejecutar la consulta para el Reporte de Publicidad para el período {Periodo}", periodo);
return Enumerable.Empty<FacturasParaReporteDto>();
}
}
}
}

View File

@@ -1,6 +1,7 @@
using Dapper;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
using System.Text;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
@@ -15,36 +16,72 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
_logger = logger;
}
public async Task<bool> UpdateAsync(Ajuste ajuste, IDbTransaction transaction)
{
const string sql = @"
UPDATE dbo.susc_Ajustes SET
FechaAjuste = @FechaAjuste,
TipoAjuste = @TipoAjuste,
Monto = @Monto,
Motivo = @Motivo
WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';"; // Solo se pueden editar los pendientes
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
var rows = await transaction.Connection.ExecuteAsync(sql, ajuste, transaction);
return rows == 1;
}
// Actualizar también el CreateAsync
public async Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction)
{
const string sql = @"
INSERT INTO dbo.susc_Ajustes (IdSuscriptor, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta)
INSERT INTO dbo.susc_Ajustes (IdSuscriptor, FechaAjuste, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta)
OUTPUT INSERTED.*
VALUES (@IdSuscriptor, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());";
VALUES (@IdSuscriptor, @FechaAjuste, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
return await transaction.Connection.QuerySingleOrDefaultAsync<Ajuste>(sql, nuevoAjuste, transaction);
}
public async Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor)
public async Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta)
{
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor ORDER BY FechaAlta DESC;";
var sqlBuilder = new StringBuilder("SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor");
var parameters = new DynamicParameters();
parameters.Add("IdSuscriptor", idSuscriptor);
if (fechaDesde.HasValue)
{
sqlBuilder.Append(" AND FechaAjuste >= @FechaDesde");
parameters.Add("FechaDesde", fechaDesde.Value.Date);
}
if (fechaHasta.HasValue)
{
sqlBuilder.Append(" AND FechaAjuste <= @FechaHasta");
parameters.Add("FechaHasta", fechaHasta.Value.Date);
}
sqlBuilder.Append(" ORDER BY FechaAlta DESC;");
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Ajuste>(sql, new { IdSuscriptor = idSuscriptor });
return await connection.QueryAsync<Ajuste>(sqlBuilder.ToString(), parameters);
}
public async Task<IEnumerable<Ajuste>> GetAjustesPendientesPorSuscriptorAsync(int idSuscriptor, IDbTransaction transaction)
public async Task<IEnumerable<Ajuste>> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, DateTime fechaHasta, IDbTransaction transaction)
{
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor AND Estado = 'Pendiente';";
const string sql = @"
SELECT * FROM dbo.susc_Ajustes
WHERE IdSuscriptor = @IdSuscriptor
AND Estado = 'Pendiente'
AND FechaAjuste <= @FechaHasta;"; // La condición clave es que la fecha del ajuste sea HASTA la fecha límite
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
return await transaction.Connection.QueryAsync<Ajuste>(sql, new { IdSuscriptor = idSuscriptor }, transaction);
return await transaction.Connection.QueryAsync<Ajuste>(sql, new { idSuscriptor, FechaHasta = fechaHasta }, transaction);
}
public async Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction)
@@ -89,5 +126,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
var rows = await transaction.Connection.ExecuteAsync(sql, new { IdAjuste = idAjuste, IdUsuario = idUsuario }, transaction);
return rows == 1;
}
public async Task<IEnumerable<Ajuste>> GetAjustesPorIdFacturaAsync(int idFactura)
{
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdFacturaAplicado = @IdFactura;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Ajuste>(sql, new { IdFactura = idFactura });
}
}
}

View File

@@ -0,0 +1,58 @@
using Dapper;
using System.Data;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public class FacturaDetalleRepository : IFacturaDetalleRepository
{
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<FacturaDetalleRepository> _logger;
public FacturaDetalleRepository(DbConnectionFactory connectionFactory, ILogger<FacturaDetalleRepository> logger)
{
_connectionFactory = connectionFactory;
_logger = logger;
}
public async Task<FacturaDetalle?> CreateAsync(FacturaDetalle nuevoDetalle, IDbTransaction transaction)
{
if (transaction == null || transaction.Connection == null)
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sqlInsert = @"
INSERT INTO dbo.susc_FacturaDetalles (IdFactura, IdSuscripcion, Descripcion, ImporteBruto, DescuentoAplicado, ImporteNeto)
OUTPUT INSERTED.*
VALUES (@IdFactura, @IdSuscripcion, @Descripcion, @ImporteBruto, @DescuentoAplicado, @ImporteNeto);";
return await transaction.Connection.QuerySingleOrDefaultAsync<FacturaDetalle>(sqlInsert, nuevoDetalle, transaction);
}
public async Task<IEnumerable<FacturaDetalle>> GetDetallesPorFacturaIdAsync(int idFactura)
{
const string sql = "SELECT * FROM dbo.susc_FacturaDetalles WHERE IdFactura = @IdFactura;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<FacturaDetalle>(sql, new { IdFactura = idFactura });
}
public async Task<IEnumerable<FacturaDetalle>> GetDetallesPorPeriodoAsync(string periodo)
{
const string sql = @"
SELECT fd.*
FROM dbo.susc_FacturaDetalles fd
JOIN dbo.susc_Facturas f ON fd.IdFactura = f.IdFactura
WHERE f.Periodo = @Periodo;";
try
{
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<FacturaDetalle>(sql, new { Periodo = periodo });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener los detalles de factura para el período {Periodo}", periodo);
return Enumerable.Empty<FacturaDetalle>();
}
}
}
}

View File

@@ -1,8 +1,13 @@
// Archivo: GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs
using Dapper;
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
@@ -19,7 +24,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<Factura?> GetByIdAsync(int idFactura)
{
const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura = @IdFactura;";
const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura = @idFactura;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idFactura });
}
@@ -31,14 +36,21 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return await connection.QueryAsync<Factura>(sql, new { Periodo = periodo });
}
public async Task<Factura?> GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction)
public async Task<Factura?> GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction)
{
const string sql = "SELECT TOP 1 * FROM dbo.susc_Facturas WHERE IdSuscripcion = @IdSuscripcion AND Periodo = @Periodo;";
const string sql = "SELECT TOP 1 * FROM dbo.susc_Facturas WHERE IdSuscriptor = @IdSuscriptor AND Periodo = @Periodo;";
if (transaction == null || transaction.Connection == null)
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
return await transaction.Connection.QuerySingleOrDefaultAsync<Factura>(sql, new { IdSuscripcion = idSuscripcion, Periodo = periodo }, transaction);
return await transaction.Connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idSuscriptor, Periodo = periodo }, transaction);
}
public async Task<IEnumerable<Factura>> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo)
{
const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdSuscriptor = @IdSuscriptor AND Periodo = @Periodo;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Factura>(sql, new { idSuscriptor, Periodo = periodo });
}
public async Task<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction)
@@ -48,25 +60,21 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sqlInsert = @"
INSERT INTO dbo.susc_Facturas
(IdSuscripcion, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,
DescuentoAplicado, ImporteFinal, Estado)
INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion)
OUTPUT INSERTED.*
VALUES
(@IdSuscripcion, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,
@DescuentoAplicado, @ImporteFinal, @Estado);";
VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);";
return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction);
}
public async Task<bool> UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction)
public async Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction)
{
if (transaction == null || transaction.Connection == null)
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = "UPDATE dbo.susc_Facturas SET Estado = @NuevoEstado WHERE IdFactura = @IdFactura;";
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstado = nuevoEstado, IdFactura = idFactura }, transaction);
const string sql = "UPDATE dbo.susc_Facturas SET EstadoPago = @NuevoEstadoPago WHERE IdFactura = @IdFactura;";
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, idFactura }, transaction);
return rowsAffected == 1;
}
@@ -76,8 +84,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = "UPDATE dbo.susc_Facturas SET NumeroFactura = @NumeroFactura, Estado = 'Pendiente de Cobro' WHERE IdFactura = @IdFactura;";
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, IdFactura = idFactura }, transaction);
const string sql = @"
UPDATE dbo.susc_Facturas SET
NumeroFactura = @NumeroFactura,
EstadoFacturacion = 'Facturado'
WHERE IdFactura = @IdFactura;";
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, idFactura }, transaction);
return rowsAffected == 1;
}
@@ -87,59 +99,116 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = "UPDATE dbo.susc_Facturas SET IdLoteDebito = @IdLoteDebito, Estado = 'Enviada a Débito' WHERE IdFactura IN @IdsFacturas;";
const string sql = "UPDATE dbo.susc_Facturas SET IdLoteDebito = @IdLoteDebito WHERE IdFactura IN @IdsFacturas;";
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdLoteDebito = idLoteDebito, IdsFacturas = idsFacturas }, transaction);
return rowsAffected == idsFacturas.Count();
}
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo)
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion)
{
const string sql = @"
SELECT f.*, s.NombreCompleto AS NombreSuscriptor, p.Nombre AS NombrePublicacion
FROM dbo.susc_Facturas f
JOIN dbo.susc_Suscripciones sc ON f.IdSuscripcion = sc.IdSuscripcion
JOIN dbo.susc_Suscriptores s ON sc.IdSuscriptor = s.IdSuscriptor
JOIN dbo.dist_dtPublicaciones p ON sc.IdPublicacion = p.Id_Publicacion
WHERE f.Periodo = @Periodo
ORDER BY s.NombreCompleto;
";
var sqlBuilder = new StringBuilder(@"
WITH FacturaConEmpresa AS (
-- Esta subconsulta obtiene el IdEmpresa para cada factura basándose en la primera suscripción que encuentra en sus detalles.
-- Esto es seguro porque nuestra lógica de negocio asegura que todos los detalles de una factura pertenecen a la misma empresa.
SELECT
f.IdFactura,
(SELECT TOP 1 p.Id_Empresa
FROM dbo.susc_FacturaDetalles fd
JOIN dbo.susc_Suscripciones s ON fd.IdSuscripcion = s.IdSuscripcion
JOIN dbo.dist_dtPublicaciones p ON s.IdPublicacion = p.Id_Publicacion
WHERE fd.IdFactura = f.IdFactura) AS IdEmpresa
FROM dbo.susc_Facturas f
WHERE f.Periodo = @Periodo
)
SELECT
f.*,
s.NombreCompleto AS NombreSuscriptor,
fce.IdEmpresa,
(SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos pg WHERE pg.IdFactura = f.IdFactura AND pg.Estado = 'Aprobado') AS TotalPagado
FROM dbo.susc_Facturas f
JOIN dbo.susc_Suscriptores s ON f.IdSuscriptor = s.IdSuscriptor
JOIN FacturaConEmpresa fce ON f.IdFactura = fce.IdFactura
WHERE f.Periodo = @Periodo");
var parameters = new DynamicParameters();
parameters.Add("Periodo", periodo);
if (!string.IsNullOrWhiteSpace(nombreSuscriptor))
{
sqlBuilder.Append(" AND s.NombreCompleto LIKE @NombreSuscriptor");
parameters.Add("NombreSuscriptor", $"%{nombreSuscriptor}%");
}
if (!string.IsNullOrWhiteSpace(estadoPago))
{
sqlBuilder.Append(" AND f.EstadoPago = @EstadoPago");
parameters.Add("EstadoPago", estadoPago);
}
if (!string.IsNullOrWhiteSpace(estadoFacturacion))
{
sqlBuilder.Append(" AND f.EstadoFacturacion = @EstadoFacturacion");
parameters.Add("EstadoFacturacion", estadoFacturacion);
}
sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;");
try
{
using var connection = _connectionFactory.CreateConnection();
var result = await connection.QueryAsync<Factura, string, string, (Factura, string, string)>(
sql,
(factura, suscriptor, publicacion) => (factura, suscriptor, publicacion),
new { Periodo = periodo },
splitOn: "NombreSuscriptor,NombrePublicacion"
var result = await connection.QueryAsync<Factura, string, int, decimal, (Factura, string, int, decimal)>(
sqlBuilder.ToString(),
(factura, suscriptor, idEmpresa, totalPagado) => (factura, suscriptor, idEmpresa, totalPagado),
parameters,
splitOn: "NombreSuscriptor,IdEmpresa,TotalPagado"
);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener facturas enriquecidas para el período {Periodo}", periodo);
return Enumerable.Empty<(Factura, string, string)>();
return Enumerable.Empty<(Factura, string, int, decimal)>();
}
}
public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction)
public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction)
{
if (transaction == null || transaction.Connection == null)
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = @"
UPDATE dbo.susc_Facturas SET
Estado = @NuevoEstado,
EstadoPago = @NuevoEstadoPago,
MotivoRechazo = @MotivoRechazo
WHERE IdFactura = @IdFactura;";
var rowsAffected = await transaction.Connection.ExecuteAsync(
sql,
new { NuevoEstado = nuevoEstado, MotivoRechazo = motivoRechazo, IdFactura = idFactura },
transaction
);
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, MotivoRechazo = motivoRechazo, idFactura }, transaction);
return rowsAffected == 1;
}
public async Task<string?> GetUltimoPeriodoFacturadoAsync()
{
const string sql = "SELECT TOP 1 Periodo FROM dbo.susc_Facturas ORDER BY Periodo DESC;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<string>(sql);
}
public async Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo)
{
// Consulta simplificada pero robusta.
const string sql = @"
SELECT * FROM dbo.susc_Facturas
WHERE Periodo = @Periodo
AND EstadoPago = 'Pagada'
AND EstadoFacturacion = 'Pendiente de Facturar';";
try
{
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Factura>(sql, new { Periodo = periodo });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener facturas pagadas pendientes de facturar para el período {Periodo}", periodo);
return Enumerable.Empty<Factura>();
}
}
}
}

View File

@@ -1,15 +1,22 @@
// Archivo: GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs
using GestionIntegral.Api.Models.Suscripciones;
using System;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public interface IAjusteRepository
{
Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction);
Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor);
Task<IEnumerable<Ajuste>> GetAjustesPendientesPorSuscriptorAsync(int idSuscriptor, IDbTransaction transaction);
Task<Ajuste?> GetByIdAsync(int idAjuste);
Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction);
Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction);
Task<bool> UpdateAsync(Ajuste ajuste, IDbTransaction transaction);
Task<bool> AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction);
Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta);
Task<IEnumerable<Ajuste>> GetAjustesPendientesHastaFechaAsync(int idSuscriptor, DateTime fechaHasta, IDbTransaction transaction);
Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction);
Task<IEnumerable<Ajuste>> GetAjustesPorIdFacturaAsync(int idFactura);
}
}

View File

@@ -0,0 +1,22 @@
using System.Data;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public interface IFacturaDetalleRepository
{
/// <summary>
/// Crea un nuevo registro de detalle de factura.
/// </summary>
Task<FacturaDetalle?> CreateAsync(FacturaDetalle nuevoDetalle, IDbTransaction transaction);
/// <summary>
/// Obtiene todos los detalles de una factura específica.
/// </summary>
Task<IEnumerable<FacturaDetalle>> GetDetallesPorFacturaIdAsync(int idFactura);
/// <summary>
/// Obtiene de forma eficiente todos los detalles de todas las facturas de un período específico.
/// </summary>
Task<IEnumerable<FacturaDetalle>> GetDetallesPorPeriodoAsync(string periodo);
}
}

View File

@@ -7,12 +7,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
Task<Factura?> GetByIdAsync(int idFactura);
Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo);
Task<Factura?> GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction);
Task<Factura?> GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction);
Task<IEnumerable<Factura>> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo);
Task<Factura?> CreateAsync(Factura nuevaFactura, IDbTransaction transaction);
Task<bool> UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction);
Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction);
Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction);
Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction);
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo);
Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction);
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion);
Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);
Task<string?> GetUltimoPeriodoFacturadoAsync();
Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo);
}
}

View File

@@ -7,5 +7,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
Task<IEnumerable<Pago>> GetByFacturaIdAsync(int idFactura);
Task<Pago?> CreateAsync(Pago nuevoPago, IDbTransaction transaction);
Task<decimal> GetTotalPagadoAprobadoAsync(int idFactura, IDbTransaction transaction);
}
}

View File

@@ -10,5 +10,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction);
Task<bool> UpdateAsync(Promocion promocion, IDbTransaction transaction);
Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction);
Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo);
}
}

View File

@@ -5,13 +5,13 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public interface ISuscripcionRepository
{
Task<IEnumerable<Suscripcion>> GetBySuscriptorIdAsync(int idSuscriptor);
Task<Suscripcion?> GetByIdAsync(int idSuscripcion);
Task<IEnumerable<Suscripcion>> GetBySuscriptorIdAsync(int idSuscriptor);
Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction);
Task<Suscripcion?> CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction);
Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction);
Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction);
Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion);
Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction);
Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion);
Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction);
Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction);
}
}

View File

@@ -54,5 +54,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return null;
}
}
public async Task<decimal> GetTotalPagadoAprobadoAsync(int idFactura, IDbTransaction transaction)
{
if (transaction == null || transaction.Connection == null)
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = "SELECT ISNULL(SUM(Monto), 0) FROM dbo.susc_Pagos WHERE IdFactura = @IdFactura AND Estado = 'Aprobado';";
return await transaction.Connection.ExecuteScalarAsync<decimal>(sql, new { idFactura }, transaction);
}
}
}

View File

@@ -19,7 +19,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas)
{
var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones");
if(soloActivas)
if (soloActivas)
{
sql.Append(" WHERE Activa = 1");
}
@@ -39,10 +39,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction)
{
const string sql = @"
INSERT INTO dbo.susc_Promociones (Descripcion, TipoPromocion, Valor, FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta)
INSERT INTO dbo.susc_Promociones
(Descripcion, TipoEfecto, ValorEfecto, TipoCondicion, ValorCondicion,
FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta)
OUTPUT INSERTED.*
VALUES (@Descripcion, @TipoPromocion, @Valor, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());";
VALUES (@Descripcion, @TipoEfecto, @ValorEfecto, @TipoCondicion,
@ValorCondicion, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
@@ -74,20 +76,43 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction)
{
// Esta consulta ahora es más compleja para respetar ambas vigencias.
const string sql = @"
SELECT p.* FROM dbo.susc_Promociones p
SELECT p.*
FROM dbo.susc_Promociones p
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion
AND p.Activa = 1
AND p.FechaInicio <= @FechaPeriodo
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo);";
AND p.Activa = 1
-- 1. La promoción general debe estar activa en el período
AND p.FechaInicio <= @FechaPeriodo
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo)
-- 2. La asignación específica al cliente debe estar activa en el período
AND sp.VigenciaDesde <= @FechaPeriodo
AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
return await transaction.Connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion, FechaPeriodo = fechaPeriodo }, transaction);
}
// Versión SIN transacción, para solo lectura
public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo)
{
const string sql = @"
SELECT p.*
FROM dbo.susc_Promociones p
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion
AND p.Activa = 1
-- 1. La promoción general debe estar activa en el período
AND p.FechaInicio <= @FechaPeriodo
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo)
-- 2. La asignación específica al cliente debe estar activa en el período
AND sp.VigenciaDesde <= @FechaPeriodo
AND (sp.VigenciaHasta IS NULL OR sp.VigenciaHasta >= @FechaPeriodo);";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Promocion>(sql, new { idSuscripcion, FechaPeriodo = fechaPeriodo });
}
}
}

View File

@@ -47,7 +47,6 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction)
{
// Lógica para determinar el rango del período (ej. '2023-11')
var year = int.Parse(periodo.Split('-')[0]);
var month = int.Parse(periodo.Split('-')[1]);
var primerDiaMes = new DateTime(year, month, 1);
@@ -112,30 +111,35 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return rowsAffected == 1;
}
public async Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion)
public async Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion)
{
const string sql = @"
SELECT p.* FROM dbo.susc_Promociones p
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion;";
SELECT sp.*, p.*
FROM dbo.susc_SuscripcionPromociones sp
JOIN dbo.susc_Promociones p ON sp.IdPromocion = p.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion });
var result = await connection.QueryAsync<SuscripcionPromocion, Promocion, (SuscripcionPromocion, Promocion)>(
sql,
(asignacion, promocion) => (asignacion, promocion),
new { IdSuscripcion = idSuscripcion },
splitOn: "IdPromocion"
);
return result;
}
public async Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction)
public async Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction)
{
if (transaction == null || transaction.Connection == null)
{
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = @"
INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno)
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuario);";
INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno, VigenciaDesde, VigenciaHasta, FechaAsignacion)
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());";
await transaction.Connection.ExecuteAsync(sql,
new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion, IdUsuario = idUsuario },
transaction);
await transaction.Connection.ExecuteAsync(sql, asignacion, transaction);
}
public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction)
@@ -145,7 +149,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
}
const string sql = "DELETE FROM dbo.susc_SuscripcionPromociones WHERE IdSuscripcion = @IdSuscripcion AND IdPromocion = @IdPromocion;";
var rows = await transaction.Connection.ExecuteAsync(sql, new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion }, transaction);
var rows = await transaction.Connection.ExecuteAsync(sql, new { idSuscripcion, idPromocion }, transaction);
return rows == 1;
}
}

View File

@@ -0,0 +1,14 @@
namespace GestionIntegral.Api.Dtos.Reportes
{
public class FacturasParaReporteDto
{
public int IdFactura { get; set; }
public string Periodo { get; set; } = string.Empty;
public string NombreSuscriptor { get; set; } = string.Empty;
public string TipoDocumento { get; set; } = string.Empty;
public string NroDocumento { get; set; } = string.Empty;
public decimal ImporteFinal { get; set; }
public int IdEmpresa { get; set; }
public string NombreEmpresa { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,18 @@
namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
{
// Esta clase anidada representará los datos de una empresa
public class DatosEmpresaViewModel
{
public string NombreEmpresa { get; set; } = string.Empty;
public IEnumerable<FacturasParaReporteDto> Facturas { get; set; } = new List<FacturasParaReporteDto>();
public decimal TotalEmpresa => Facturas.Sum(f => f.ImporteFinal);
}
public class FacturasPublicidadViewModel
{
public IEnumerable<DatosEmpresaViewModel> DatosPorEmpresa { get; set; } = new List<DatosEmpresaViewModel>();
public string Periodo { get; set; } = string.Empty;
public string FechaGeneracion { get; set; } = string.Empty;
public decimal TotalGeneral => DatosPorEmpresa.Sum(e => e.TotalEmpresa);
}
}

View File

@@ -4,6 +4,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
{
public int IdAjuste { get; set; }
public int IdSuscriptor { get; set; }
public string FechaAjuste { get; set; } = string.Empty;
public string TipoAjuste { get; set; } = string.Empty;
public decimal Monto { get; set; }
public string Motivo { get; set; } = string.Empty;

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class AsignarPromocionDto
{
[Required]
public int IdPromocion { get; set; }
[Required]
public DateTime VigenciaDesde { get; set; }
public DateTime? VigenciaHasta { get; set; }
}
}

View File

@@ -7,6 +7,9 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
[Required]
public int IdSuscriptor { get; set; }
[Required]
public DateTime FechaAjuste { get; set; }
[Required]
[RegularExpression("^(Credito|Debito)$", ErrorMessage = "El tipo de ajuste debe ser 'Credito' o 'Debito'.")]
public string TipoAjuste { get; set; } = string.Empty;

View File

@@ -7,22 +7,25 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class CreatePromocionDto
{
[Required(ErrorMessage = "La descripción es obligatoria.")]
[Required]
[StringLength(200)]
public string Descripcion { get; set; } = string.Empty;
[Required(ErrorMessage = "El tipo de promoción es obligatorio.")]
public string TipoPromocion { get; set; } = string.Empty;
[Required]
public string TipoEfecto { get; set; } = string.Empty; // Corregido
[Required(ErrorMessage = "El valor es obligatorio.")]
[Range(0.01, 99999999.99, ErrorMessage = "El valor debe ser positivo.")]
public decimal Valor { get; set; }
[Required]
[Range(0, 99999999.99)] // Se permite 0 para bonificaciones
public decimal ValorEfecto { get; set; } // Corregido
[Required(ErrorMessage = "La fecha de inicio es obligatoria.")]
[Required]
public string TipoCondicion { get; set; } = string.Empty;
public int? ValorCondicion { get; set; }
[Required]
public DateTime FechaInicio { get; set; }
public DateTime? FechaFin { get; set; }
public bool Activa { get; set; } = true;
}
}

View File

@@ -0,0 +1,13 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class FacturaConsolidadaDto
{
public int IdFactura { get; set; }
public string NombreEmpresa { get; set; } = string.Empty;
public decimal ImporteFinal { get; set; }
public string EstadoPago { get; set; } = string.Empty;
public string EstadoFacturacion { get; set; } = string.Empty;
public string? NumeroFactura { get; set; }
public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
}
}

View File

@@ -1,22 +1,25 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
/// <summary>
/// DTO para enviar la información de una factura generada al frontend.
/// Incluye datos enriquecidos como nombres para facilitar su visualización en la UI.
/// </summary>
public class FacturaDetalleDto
{
public string Descripcion { get; set; } = string.Empty;
public decimal ImporteNeto { get; set; }
}
public class FacturaDto
{
public int IdFactura { get; set; }
public int IdSuscripcion { get; set; }
public string Periodo { get; set; } = string.Empty; // Formato "YYYY-MM"
public string FechaEmision { get; set; } = string.Empty; // Formato "yyyy-MM-dd"
public string FechaVencimiento { get; set; } = string.Empty; // Formato "yyyy-MM-dd"
public int IdSuscriptor { get; set; }
public string Periodo { get; set; } = string.Empty;
public string FechaEmision { get; set; } = string.Empty;
public string FechaVencimiento { get; set; } = string.Empty;
public decimal ImporteFinal { get; set; }
public string Estado { get; set; } = string.Empty;
public decimal TotalPagado { get; set; }
public decimal SaldoPendiente { get; set; }
public string EstadoPago { get; set; } = string.Empty;
public string EstadoFacturacion { get; set; } = string.Empty;
public string? NumeroFactura { get; set; }
// Datos enriquecidos para la UI, poblados por el servicio
public string NombreSuscriptor { get; set; } = string.Empty;
public string NombrePublicacion { get; set; } = string.Empty;
public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
}
}

View File

@@ -0,0 +1,8 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class PromocionAsignadaDto : PromocionDto
{
public string VigenciaDesdeAsignacion { get; set; } = string.Empty;
public string? VigenciaHastaAsignacion { get; set; }
}
}

View File

@@ -4,9 +4,11 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
{
public int IdPromocion { get; set; }
public string Descripcion { get; set; } = string.Empty;
public string TipoPromocion { get; set; } = string.Empty;
public decimal Valor { get; set; }
public string FechaInicio { get; set; } = string.Empty; // yyyy-MM-dd
public string TipoEfecto { get; set; } = string.Empty;
public decimal ValorEfecto { get; set; }
public string TipoCondicion { get; set; } = string.Empty;
public int? ValorCondicion { get; set; }
public string FechaInicio { get; set; } = string.Empty;
public string? FechaFin { get; set; }
public bool Activa { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class ResumenCuentaSuscriptorDto
{
public int IdSuscriptor { get; set; }
public string NombreSuscriptor { get; set; } = string.Empty;
public decimal SaldoPendienteTotal { get; set; }
public decimal ImporteTotal { get; set; }
public List<FacturaConsolidadaDto> Facturas { get; set; } = new List<FacturaConsolidadaDto>();
}
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones;
public class UpdateAjusteDto
{
[Required]
public DateTime FechaAjuste { get; set; }
[Required]
[RegularExpression("^(Credito|Debito)$")]
public string TipoAjuste { get; set; } = string.Empty;
[Required]
[Range(0.01, 999999.99)]
public decimal Monto { get; set; }
[Required]
[StringLength(250)]
public string Motivo { get; set; } = string.Empty;
}

View File

@@ -4,6 +4,7 @@ namespace GestionIntegral.Api.Models.Suscripciones
{
public int IdAjuste { get; set; }
public int IdSuscriptor { get; set; }
public DateTime FechaAjuste { get; set; }
public string TipoAjuste { get; set; } = string.Empty;
public decimal Monto { get; set; }
public string Motivo { get; set; } = string.Empty;

View File

@@ -3,14 +3,15 @@ namespace GestionIntegral.Api.Models.Suscripciones
public class Factura
{
public int IdFactura { get; set; }
public int IdSuscripcion { get; set; }
public int IdSuscriptor { get; set; }
public string Periodo { get; set; } = string.Empty;
public DateTime FechaEmision { get; set; }
public DateTime FechaVencimiento { get; set; }
public decimal ImporteBruto { get; set; }
public decimal DescuentoAplicado { get; set; }
public decimal ImporteFinal { get; set; }
public string Estado { get; set; } = string.Empty;
public string EstadoPago { get; set; } = string.Empty;
public string EstadoFacturacion { get; set; } = string.Empty;
public string? NumeroFactura { get; set; }
public int? IdLoteDebito { get; set; }
public string? MotivoRechazo { get; set; }

View File

@@ -0,0 +1,9 @@
public class FacturaDetalle {
public int IdFacturaDetalle { get; set; }
public int IdFactura { get; set; }
public int IdSuscripcion { get; set; }
public string Descripcion { get; set; } = string.Empty;
public decimal ImporteBruto { get; set; }
public decimal DescuentoAplicado { get; set; }
public decimal ImporteNeto { get; set; }
}

View File

@@ -4,8 +4,10 @@ namespace GestionIntegral.Api.Models.Suscripciones
{
public int IdPromocion { get; set; }
public string Descripcion { get; set; } = string.Empty;
public string TipoPromocion { get; set; } = string.Empty;
public decimal Valor { get; set; }
public string TipoEfecto { get; set; } = string.Empty; // Nuevo nombre
public decimal ValorEfecto { get; set; } // Nuevo nombre
public string TipoCondicion { get; set; } = string.Empty; // Nueva propiedad
public int? ValorCondicion { get; set; } // Nueva propiedad (nullable)
public DateTime FechaInicio { get; set; }
public DateTime? FechaFin { get; set; }
public bool Activa { get; set; }

View File

@@ -6,5 +6,7 @@ namespace GestionIntegral.Api.Models.Suscripciones
public int IdPromocion { get; set; }
public DateTime FechaAsignacion { get; set; }
public int IdUsuarioAsigno { get; set; }
public DateTime VigenciaDesde { get; set; }
public DateTime? VigenciaHasta { get; set; }
}
}

View File

@@ -113,6 +113,7 @@ builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>();
builder.Services.AddScoped<IPagoRepository, PagoRepository>();
builder.Services.AddScoped<IPromocionRepository, PromocionRepository>();
builder.Services.AddScoped<IAjusteRepository, AjusteRepository>();
builder.Services.AddScoped<IFacturaDetalleRepository, FacturaDetalleRepository>();
builder.Services.AddScoped<IFormaPagoService, FormaPagoService>();
builder.Services.AddScoped<ISuscriptorService, SuscriptorService>();

View File

@@ -17,7 +17,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
_logger = logger;
}
public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml)
public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, byte[]? attachment = null, string? attachmentName = null)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
@@ -26,6 +26,10 @@ namespace GestionIntegral.Api.Services.Comunicaciones
email.Subject = asunto;
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
if (attachment != null && !string.IsNullOrEmpty(attachmentName))
{
builder.Attachments.Add(attachmentName, attachment, ContentType.Parse("application/pdf"));
}
email.Body = builder.ToMessageBody();
using var smtp = new SmtpClient();
@@ -46,5 +50,44 @@ namespace GestionIntegral.Api.Services.Comunicaciones
await smtp.DisconnectAsync(true);
}
}
public async Task EnviarEmailConsolidadoAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, List<(byte[] content, string name)> adjuntos)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
email.From.Add(email.Sender);
email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail));
email.Subject = asunto;
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
if (adjuntos != null)
{
foreach (var adjunto in adjuntos)
{
builder.Attachments.Add(adjunto.name, adjunto.content, ContentType.Parse("application/pdf"));
}
}
email.Body = builder.ToMessageBody();
using var smtp = new SmtpClient();
try
{
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
await smtp.SendAsync(email);
_logger.LogInformation("Email consolidado enviado exitosamente a {Destinatario}", destinatarioEmail);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al enviar email consolidado a {Destinatario}", destinatarioEmail);
throw;
}
finally
{
await smtp.DisconnectAsync(true);
}
}
}
}

View File

@@ -2,6 +2,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
{
public interface IEmailService
{
Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml);
Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, byte[]? attachment = null, string? attachmentName = null);
Task EnviarEmailConsolidadoAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, List<(byte[] content, string name)> adjuntos);
}
}

View File

@@ -72,5 +72,6 @@ namespace GestionIntegral.Api.Services.Reportes
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);
Task<(IEnumerable<FacturasParaReporteDto> Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes);
}
}

View File

@@ -1,21 +1,31 @@
using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Data.Repositories.Reportes;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Reportes;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Reportes
{
public class ReportesService : IReportesService
{
private readonly IReportesRepository _reportesRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
private readonly IPublicacionRepository _publicacionRepository;
private readonly IEmpresaRepository _empresaRepository;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly ILogger<ReportesService> _logger;
public ReportesService(IReportesRepository reportesRepository, ILogger<ReportesService> logger)
public ReportesService(IReportesRepository reportesRepository, IFacturaRepository facturaRepository, IFacturaDetalleRepository facturaDetalleRepository, IPublicacionRepository publicacionRepository, IEmpresaRepository empresaRepository
, ISuscriptorRepository suscriptorRepository, ISuscripcionRepository suscripcionRepository, ILogger<ReportesService> logger)
{
_reportesRepository = reportesRepository;
_facturaRepository = facturaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_publicacionRepository = publicacionRepository;
_empresaRepository = empresaRepository;
_suscriptorRepository = suscriptorRepository;
_suscripcionRepository = suscripcionRepository;
_logger = logger;
}
@@ -520,5 +530,25 @@ namespace GestionIntegral.Api.Services.Reportes
return (Enumerable.Empty<ListadoDistCanMensualPubDto>(), "Error al obtener datos del reporte (por publicación).");
}
}
public async Task<(IEnumerable<FacturasParaReporteDto> Data, string? Error)> ObtenerFacturasParaReportePublicidad(int anio, int mes)
{
if (anio < 2020 || mes < 1 || mes > 12)
{
return (Enumerable.Empty<FacturasParaReporteDto>(), "Período no válido.");
}
var periodo = $"{anio}-{mes:D2}";
try
{
// Llamada directa al nuevo método del repositorio
var data = await _reportesRepository.GetDatosReportePublicidadAsync(periodo);
return (data, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error en servicio al obtener datos para reporte de publicidad para el período {Periodo}", periodo);
return (new List<FacturasParaReporteDto>(), "Error interno al generar el reporte.");
}
}
}
}

View File

@@ -37,6 +37,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
IdAjuste = ajuste.IdAjuste,
IdSuscriptor = ajuste.IdSuscriptor,
FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"),
TipoAjuste = ajuste.TipoAjuste,
Monto = ajuste.Monto,
Motivo = ajuste.Motivo,
@@ -47,9 +48,9 @@ namespace GestionIntegral.Api.Services.Suscripciones
};
}
public async Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor)
public async Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta)
{
var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor);
var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor, fechaDesde, fechaHasta);
var dtosTasks = ajustes.Select(a => MapToDto(a));
var dtos = await Task.WhenAll(dtosTasks);
return dtos.Where(dto => dto != null)!;
@@ -66,6 +67,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
var nuevoAjuste = new Ajuste
{
IdSuscriptor = createDto.IdSuscriptor,
FechaAjuste = createDto.FechaAjuste.Date,
TipoAjuste = createDto.TipoAjuste,
Monto = createDto.Monto,
Motivo = createDto.Motivo,
@@ -119,5 +121,36 @@ namespace GestionIntegral.Api.Services.Suscripciones
return (false, "Error interno al anular el ajuste.");
}
}
public async Task<(bool Exito, string? Error)> ActualizarAjuste(int idAjuste, UpdateAjusteDto updateDto)
{
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste);
if (ajuste == null) return (false, "Ajuste no encontrado.");
if (ajuste.Estado != "Pendiente") return (false, $"No se puede modificar un ajuste en estado '{ajuste.Estado}'.");
ajuste.FechaAjuste = updateDto.FechaAjuste;
ajuste.TipoAjuste = updateDto.TipoAjuste;
ajuste.Monto = updateDto.Monto;
ajuste.Motivo = updateDto.Motivo;
var actualizado = await _ajusteRepository.UpdateAsync(ajuste, transaction);
if (!actualizado) throw new DataException("La actualización falló o el ajuste ya no estaba pendiente.");
transaction.Commit();
_logger.LogInformation("Ajuste ID {IdAjuste} actualizado.", idAjuste);
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al actualizar ajuste ID {IdAjuste}", idAjuste);
return (false, "Error interno al actualizar el ajuste.");
}
}
}
}

View File

@@ -1,17 +1,16 @@
// Archivo: GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
using System.Text;
using GestionIntegral.Api.Dtos.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Data;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Collections.Generic;
using GestionIntegral.Api.Dtos.Suscripciones;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using System.IO;
namespace GestionIntegral.Api.Services.Suscripciones
{
@@ -19,21 +18,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
private readonly IFacturaRepository _facturaRepository;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly ILoteDebitoRepository _loteDebitoRepository;
private readonly IFormaPagoRepository _formaPagoRepository;
private readonly IPagoRepository _pagoRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<DebitoAutomaticoService> _logger;
// --- CONSTANTES DEL BANCO (Mover a appsettings.json si es necesario) ---
private const string NRO_PRESTACION = "123456"; // Nro. de prestación asignado por el banco
private const string ORIGEN_EMPRESA = "ELDIA"; // Nombre de la empresa (7 chars)
public DebitoAutomaticoService(
IFacturaRepository facturaRepository,
ISuscriptorRepository suscriptorRepository,
ISuscripcionRepository suscripcionRepository,
ILoteDebitoRepository loteDebitoRepository,
IFormaPagoRepository formaPagoRepository,
IPagoRepository pagoRepository,
@@ -42,7 +38,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
_facturaRepository = facturaRepository;
_suscriptorRepository = suscriptorRepository;
_suscripcionRepository = suscripcionRepository;
_loteDebitoRepository = loteDebitoRepository;
_formaPagoRepository = formaPagoRepository;
_pagoRepository = pagoRepository;
@@ -61,9 +56,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
try
{
// Buscamos facturas que están listas para ser enviadas al cobro.
var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction);
if (!facturasParaDebito.Any())
{
return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado.");
@@ -73,7 +66,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
var cantidadRegistros = facturasParaDebito.Count();
var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt";
// 1. Crear el Lote de Débito
var nuevoLote = new LoteDebito
{
Periodo = periodo,
@@ -85,18 +77,14 @@ namespace GestionIntegral.Api.Services.Suscripciones
var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction);
if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito.");
// 2. Generar el contenido del archivo
var sb = new StringBuilder();
sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros));
foreach (var item in facturasParaDebito)
{
sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor));
}
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros));
// 3. Actualizar las facturas con el ID del lote
var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura);
bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction);
if (!actualizadas) throw new DataException("No se pudieron actualizar las facturas con la información del lote.");
@@ -115,17 +103,12 @@ namespace GestionIntegral.Api.Services.Suscripciones
private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction)
{
// Idealmente, esto debería estar en el repositorio para optimizar la consulta.
// Por simplicidad del ejemplo, lo hacemos aquí.
var facturas = await _facturaRepository.GetByPeriodoAsync(periodo);
var facturasDelPeriodo = await _facturaRepository.GetByPeriodoAsync(periodo);
var resultado = new List<(Factura, Suscriptor)>();
foreach (var f in facturas.Where(fa => fa.Estado == "Pendiente de Cobro"))
foreach (var f in facturasDelPeriodo.Where(fa => fa.EstadoPago == "Pendiente"))
{
var suscripcion = await _suscripcionRepository.GetByIdAsync(f.IdSuscripcion);
if (suscripcion == null) continue;
var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor);
var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor);
if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue;
var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida);
@@ -231,28 +214,17 @@ namespace GestionIntegral.Api.Services.Suscripciones
string? linea;
while ((linea = await reader.ReadLineAsync()) != null)
{
// Ignorar header/trailer si los hubiera (basado en el formato real)
if (linea.Length < 20) continue;
respuesta.TotalRegistrosLeidos++;
// =================================================================
// === ESTA ES LA LÓGICA DE PARSEO QUE SE DEBE AJUSTAR ===
// === CON EL FORMATO REAL DEL ARCHIVO DE RESPUESTA ===
// =================================================================
// Asunción: Pos 1-15: Referencia, Pos 16-17: Estado, Pos 18-20: Rechazo
var referencia = linea.Substring(0, 15).Trim();
var estadoProceso = linea.Substring(15, 2).Trim();
var motivoRechazo = linea.Substring(17, 3).Trim();
// Asumimos que podemos extraer el IdFactura de la referencia
if (!int.TryParse(referencia.Replace("SUSC-", ""), out int idFactura))
{
respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'.");
continue;
}
// =================================================================
// === FIN DE LA LÓGICA DE PARSEO ===
// =================================================================
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null)
@@ -264,27 +236,24 @@ namespace GestionIntegral.Api.Services.Suscripciones
var nuevoPago = new Pago
{
IdFactura = idFactura,
FechaPago = DateTime.Now.Date, // O la fecha que venga en el archivo
IdFormaPago = 1, // 1 = Débito Automático
FechaPago = DateTime.Now.Date,
IdFormaPago = 1,
Monto = factura.ImporteFinal,
IdUsuarioRegistro = idUsuario,
Referencia = $"Lote {factura.IdLoteDebito} - Banco"
};
if (estadoProceso == "AP") // "AP" = Aprobado (Asunción)
if (estadoProceso == "AP")
{
nuevoPago.Estado = "Aprobado";
await _pagoRepository.CreateAsync(nuevoPago, transaction);
await _facturaRepository.UpdateEstadoAsync(idFactura, "Pagada", transaction);
await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction);
respuesta.PagosAprobados++;
}
else // Asumimos que cualquier otra cosa es Rechazado
else
{
nuevoPago.Estado = "Rechazado";
await _pagoRepository.CreateAsync(nuevoPago, transaction);
factura.Estado = "Rechazada";
factura.MotivoRechazo = motivoRechazo;
// Necesitamos un método en el repo para actualizar estado y motivo
await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction);
respuesta.PagosRechazados++;
}

View File

@@ -6,6 +6,8 @@ using GestionIntegral.Api.Models.Distribucion;
using GestionIntegral.Api.Models.Suscripciones;
using GestionIntegral.Api.Services.Comunicaciones;
using System.Data;
using System.Globalization;
using System.Text;
namespace GestionIntegral.Api.Services.Suscripciones
{
@@ -13,40 +15,393 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly IEmpresaRepository _empresaRepository;
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
private readonly IPrecioRepository _precioRepository;
private readonly IPromocionRepository _promocionRepository;
private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación
private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor
private readonly DbConnectionFactory _connectionFactory;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly IAjusteRepository _ajusteRepository;
private readonly IEmailService _emailService;
private readonly IPublicacionRepository _publicacionRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<FacturacionService> _logger;
private readonly string _facturasPdfPath;
public FacturacionService(
ISuscripcionRepository suscripcionRepository,
IFacturaRepository facturaRepository,
IEmpresaRepository empresaRepository,
IFacturaDetalleRepository facturaDetalleRepository,
IPrecioRepository precioRepository,
IPromocionRepository promocionRepository,
IRecargoZonaRepository recargoZonaRepository,
ISuscriptorRepository suscriptorRepository,
DbConnectionFactory connectionFactory,
IAjusteRepository ajusteRepository,
IEmailService emailService,
ILogger<FacturacionService> logger)
IPublicacionRepository publicacionRepository,
DbConnectionFactory connectionFactory,
ILogger<FacturacionService> logger,
IConfiguration configuration)
{
_suscripcionRepository = suscripcionRepository;
_facturaRepository = facturaRepository;
_empresaRepository = empresaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_precioRepository = precioRepository;
_promocionRepository = promocionRepository;
_recargoZonaRepository = recargoZonaRepository;
_suscriptorRepository = suscriptorRepository;
_connectionFactory = connectionFactory;
_ajusteRepository = ajusteRepository;
_emailService = emailService;
_publicacionRepository = publicacionRepository;
_connectionFactory = connectionFactory;
_logger = logger;
_facturasPdfPath = configuration.GetValue<string>("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF";
}
public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
{
var periodoActual = new DateTime(anio, mes, 1);
var periodoActualStr = periodoActual.ToString("yyyy-MM");
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodoActualStr, idUsuario);
var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync();
if (ultimoPeriodoFacturadoStr != null)
{
var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture);
if (periodoActual != ultimoPeriodo.AddMonths(1))
{
var periodoEsperado = ultimoPeriodo.AddMonths(1).ToString("MMMM 'de' yyyy", new CultureInfo("es-ES"));
return (false, $"Error: No se puede generar la facturación de {periodoActual:MMMM 'de' yyyy}. El siguiente período a generar es {periodoEsperado}.", 0);
}
}
var facturasCreadas = new List<Factura>();
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction);
if (!suscripcionesActivas.Any())
{
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0);
}
// 1. Enriquecer las suscripciones con el IdEmpresa de su publicación
var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>();
foreach (var s in suscripcionesActivas)
{
var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion);
if (pub != null)
{
suscripcionesConEmpresa.Add((s, pub.IdEmpresa));
}
}
// 2. Agrupar por la combinación (Suscriptor, Empresa)
var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa });
int facturasGeneradas = 0;
foreach (var grupo in gruposParaFacturar)
{
int idSuscriptor = grupo.Key.IdSuscriptor;
int idEmpresa = grupo.Key.IdEmpresa;
// La verificación de existencia ahora debe ser más específica, pero por ahora la omitimos
// para no añadir otro método al repositorio. Asumimos que no se corre dos veces.
decimal importeBrutoTotal = 0;
decimal descuentoPromocionesTotal = 0;
var detallesParaFactura = new List<FacturaDetalle>();
// 3. Calcular el importe para cada suscripción DENTRO del grupo
foreach (var item in grupo)
{
var suscripcion = item.Suscripcion;
decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction);
decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones);
importeBrutoTotal += importeBrutoSusc;
descuentoPromocionesTotal += descuentoSusc;
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion);
detallesParaFactura.Add(new FacturaDetalle
{
IdSuscripcion = suscripcion.IdSuscripcion,
Descripcion = $"Corresponde a {publicacion?.Nombre ?? "N/A"}",
ImporteBruto = importeBrutoSusc,
DescuentoAplicado = descuentoSusc,
ImporteNeto = importeBrutoSusc - descuentoSusc
});
}
// 4. Aplicar ajustes. Se aplican a la PRIMERA factura que se genere para el cliente.
var ultimoDiaDelMes = periodoActual.AddMonths(1).AddDays(-1);
var ajustesPendientes = await _ajusteRepository.GetAjustesPendientesHastaFechaAsync(idSuscriptor, ultimoDiaDelMes, transaction);
decimal totalAjustes = 0;
// Verificamos si este grupo es el "primero" para este cliente para no aplicar ajustes varias veces
bool esPrimerGrupoParaCliente = !facturasCreadas.Any(f => f.IdSuscriptor == idSuscriptor);
if (esPrimerGrupoParaCliente)
{
totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto);
}
var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes;
if (importeFinal < 0) importeFinal = 0;
if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue;
// 5. Crear UNA factura por cada grupo (Suscriptor + Empresa)
var nuevaFactura = new Factura
{
IdSuscriptor = idSuscriptor,
Periodo = periodoActualStr,
FechaEmision = DateTime.Now.Date,
FechaVencimiento = new DateTime(anio, mes, 10),
ImporteBruto = importeBrutoTotal,
DescuentoAplicado = descuentoPromocionesTotal,
ImporteFinal = importeFinal,
EstadoPago = "Pendiente",
EstadoFacturacion = "Pendiente de Facturar"
};
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}");
facturasCreadas.Add(facturaCreada);
foreach (var detalle in detallesParaFactura)
{
detalle.IdFactura = facturaCreada.IdFactura;
await _facturaDetalleRepository.CreateAsync(detalle, transaction);
}
if (esPrimerGrupoParaCliente && ajustesPendientes.Any())
{
await _ajusteRepository.MarcarAjustesComoAplicadosAsync(ajustesPendientes.Select(a => a.IdAjuste), facturaCreada.IdFactura, transaction);
}
facturasGeneradas++;
}
// --- FIN DE LA LÓGICA DE AGRUPACIÓN ---
transaction.Commit();
_logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr);
// --- INICIO DE LA LÓGICA DE ENVÍO CONSOLIDADO AUTOMÁTICO ---
int emailsEnviados = 0;
if (facturasCreadas.Any())
{
// Agrupamos las facturas creadas por suscriptor para enviar un solo email
var suscriptoresAnotificar = facturasCreadas.Select(f => f.IdSuscriptor).Distinct().ToList();
_logger.LogInformation("Iniciando envío automático de avisos para {Count} suscriptores.", suscriptoresAnotificar.Count);
foreach (var idSuscriptor in suscriptoresAnotificar)
{
try
{
var (envioExitoso, _) = await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor);
if (envioExitoso) emailsEnviados++;
}
catch (Exception exEmail)
{
_logger.LogError(exEmail, "Falló el envío automático de email para el suscriptor ID {IdSuscriptor}", idSuscriptor);
}
}
_logger.LogInformation("{EmailsEnviados} avisos de vencimiento enviados automáticamente.", emailsEnviados);
}
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas y se enviaron {emailsEnviados} notificaciones.", facturasGeneradas);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodoActualStr);
return (false, "Error interno del servidor al generar la facturación.", 0);
}
}
public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion)
{
var periodo = $"{anio}-{mes:D2}";
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodo, idUsuario);
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion);
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo
var empresas = await _empresaRepository.GetAllAsync(null, null);
var resumenes = facturasData
.GroupBy(data => data.Factura.IdSuscriptor)
.Select(grupo =>
{
var primerItem = grupo.First();
var facturasConsolidadas = grupo.Select(itemFactura =>
{
var empresa = empresas.FirstOrDefault(e => e.IdEmpresa == itemFactura.IdEmpresa);
return new FacturaConsolidadaDto
{
IdFactura = itemFactura.Factura.IdFactura,
NombreEmpresa = empresa?.Nombre ?? "N/A",
ImporteFinal = itemFactura.Factura.ImporteFinal,
EstadoPago = itemFactura.Factura.EstadoPago,
EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
NumeroFactura = itemFactura.Factura.NumeroFactura,
Detalles = detallesData
.Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
.Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto })
.ToList()
};
}).ToList();
return new ResumenCuentaSuscriptorDto
{
IdSuscriptor = primerItem.Factura.IdSuscriptor,
NombreSuscriptor = primerItem.NombreSuscriptor,
Facturas = facturasConsolidadas,
ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.EstadoPago == "Pagada" ? 0 : f.ImporteFinal)
};
});
return resumenes.ToList();
}
public async Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura)
{
try
{
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null) return (false, "Factura no encontrada.");
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número oficial asignado para generar el PDF.");
if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada.");
var suscriptor = await _suscriptorRepository.GetByIdAsync(factura.IdSuscriptor);
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.");
var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
var primeraSuscripcionId = detalles.FirstOrDefault()?.IdSuscripcion ?? 0;
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(primeraSuscripcionId);
var empresa = await _empresaRepository.GetByIdAsync(publicacion?.IdEmpresa ?? 0);
// --- LÓGICA DE BÚSQUEDA Y ADJUNTO DE PDF ---
byte[]? pdfAttachment = null;
string? pdfFileName = null;
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
if (File.Exists(rutaCompleta))
{
pdfAttachment = await File.ReadAllBytesAsync(rutaCompleta);
pdfFileName = $"Factura_{empresa?.Nombre?.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
_logger.LogInformation("Adjuntando PDF encontrado en: {Ruta}", rutaCompleta);
}
else
{
_logger.LogWarning("Se intentó enviar la factura {NumeroFactura} pero no se encontró el PDF en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
return (false, "No se encontró el archivo PDF correspondiente en el servidor. Verifique que el archivo exista y el nombre coincida con el número de factura.");
}
string asunto = $"Tu Factura Oficial - Diario El Día - Período {factura.Periodo}";
string cuerpoHtml = $"<div style='font-family: Arial, sans-serif;'><h2>Hola {suscriptor.NombreCompleto},</h2><p>Adjuntamos tu factura oficial número <strong>{factura.NumeroFactura}</strong> correspondiente al período <strong>{factura.Periodo}</strong>.</p><p>Gracias por ser parte de nuestra comunidad de lectores.</p><p><em>Diario El Día</em></p></div>";
await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, pdfAttachment, pdfFileName);
_logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor);
return (true, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura);
return (false, "Ocurrió un error al intentar enviar el email con la factura.");
}
}
public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor)
{
var periodo = $"{anio}-{mes:D2}";
try
{
var facturas = await _facturaRepository.GetListBySuscriptorYPeriodoAsync(idSuscriptor, periodo);
if (!facturas.Any()) return (false, "No se encontraron facturas para este suscriptor en el período.");
var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor);
if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.");
var resumenHtml = new StringBuilder();
var adjuntos = new List<(byte[] content, string name)>();
foreach (var factura in facturas.Where(f => f.EstadoPago != "Anulada"))
{
var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
if (!detalles.Any()) continue;
var primeraSuscripcionId = detalles.First().IdSuscripcion;
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(primeraSuscripcionId);
var empresa = await _empresaRepository.GetByIdAsync(publicacion?.IdEmpresa ?? 0);
resumenHtml.Append($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen de Suscripción</h4>");
resumenHtml.Append("<table style='width: 100%; border-collapse: collapse; font-size: 0.9em;'>");
foreach (var detalle in detalles)
{
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee;'>{detalle.Descripcion}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right;'>${detalle.ImporteNeto:N2}</td></tr>");
}
resumenHtml.Append($"<tr style='font-weight: bold;'><td style='padding: 5px;'>Subtotal</td><td style='padding: 5px; text-align: right;'>${factura.ImporteFinal:N2}</td></tr>");
resumenHtml.Append("</table>");
if (!string.IsNullOrEmpty(factura.NumeroFactura))
{
var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
if (File.Exists(rutaCompleta))
{
byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta);
string pdfFileName = $"Factura_{empresa?.Nombre?.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
adjuntos.Add((pdfBytes, pdfFileName));
_logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName);
}
else
{
_logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
}
}
}
var totalGeneral = facturas.Where(f => f.EstadoPago != "Anulada").Sum(f => f.ImporteFinal);
string asunto = $"Resumen de Cuenta - Diario El Día - Período {periodo}";
string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral);
await _emailService.EnviarEmailConsolidadoAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, adjuntos);
_logger.LogInformation("Email consolidado para Suscriptor ID {IdSuscriptor} enviado para el período {Periodo}.", idSuscriptor, periodo);
return (true, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falló el envío de email consolidado para el suscriptor ID {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo);
return (false, "Ocurrió un error al intentar enviar el email consolidado.");
}
}
private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral)
{
return $@"
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: auto; border: 1px solid #ddd; padding: 20px;'>
<h3 style='color: #333;'>Hola {suscriptor.NombreCompleto},</h2>
<p>Le enviamos el resumen de su cuenta para el período <strong>{periodo}</strong>.</p>
{resumenHtml}
<hr style='border: none; border-top: 1px solid #eee; margin: 20px 0;'/>
<table style='width: 100%;'>
<tr>
<td style='font-size: 1.2em; font-weight: bold;'>TOTAL:</td>
<td style='font-size: 1.4em; font-weight: bold; text-align: right; color: #34515e;'>${totalGeneral:N2}</td>
</tr>
</table>
<p style='margin-top: 25px;'>Si su pago es por débito automático, los importes se debitarán de su cuenta. Si utiliza otro medio de pago, por favor, regularice su situación.</p>
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
<p style='font-size: 0.9em; color: #777;'><em>Diario El Día</em></p>
</div>";
}
public async Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario)
{
if (string.IsNullOrWhiteSpace(numeroFactura))
{
return (false, "El número de factura no puede estar vacío.");
}
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
@@ -54,77 +409,31 @@ namespace GestionIntegral.Api.Services.Suscripciones
try
{
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction);
if (!suscripcionesActivas.Any())
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null)
{
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0);
return (false, "La factura especificada no existe.");
}
if (factura.EstadoPago == "Anulada")
{
return (false, "No se puede modificar una factura anulada.");
}
int facturasGeneradas = 0;
foreach (var suscripcion in suscripcionesActivas)
var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction);
if (!actualizado)
{
var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction);
if (facturaExistente != null)
{
_logger.LogWarning("Ya existe una factura (ID: {IdFactura}) para la suscripción ID {IdSuscripcion} en el período {Periodo}. Se omite.", facturaExistente.IdFactura, suscripcion.IdSuscripcion, periodo);
continue;
}
// --- LÓGICA DE PROMOCIONES ---
var primerDiaMes = new DateTime(anio, mes, 1);
var promocionesAplicables = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, primerDiaMes, transaction);
decimal importeBruto = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
decimal descuentoTotal = 0;
// Aplicar promociones de descuento
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "Porcentaje"))
{
descuentoTotal += (importeBruto * promo.Valor) / 100;
}
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "MontoFijo"))
{
descuentoTotal += promo.Valor;
}
// La bonificación de días se aplicaría idealmente dentro de CalcularImporteParaSuscripcion,
// pero por simplicidad, aquí solo manejamos descuentos sobre el total.
if (importeBruto <= 0)
{
_logger.LogInformation("Suscripción ID {IdSuscripcion} no tiene importe a facturar para el período {Periodo}. Se omite.", suscripcion.IdSuscripcion, periodo);
continue;
}
var importeFinal = importeBruto - descuentoTotal;
if (importeFinal < 0) importeFinal = 0; // El importe no puede ser negativo
var nuevaFactura = new Factura
{
IdSuscripcion = suscripcion.IdSuscripcion,
Periodo = periodo,
FechaEmision = DateTime.Now.Date,
FechaVencimiento = new DateTime(anio, mes, 10).AddMonths(1),
ImporteBruto = importeBruto,
DescuentoAplicado = descuentoTotal,
ImporteFinal = importeFinal,
Estado = "Pendiente de Facturar"
};
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
if (facturaCreada == null) throw new DataException($"No se pudo crear el registro de factura para la suscripción ID {suscripcion.IdSuscripcion}");
facturasGeneradas++;
throw new DataException("La actualización del número de factura falló en el repositorio.");
}
transaction.Commit();
_logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas);
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", facturasGeneradas);
_logger.LogInformation("Número de factura para Factura ID {IdFactura} actualizado a {NumeroFactura} por Usuario ID {IdUsuario}", idFactura, numeroFactura, idUsuario);
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodo);
return (false, "Error interno del servidor al generar la facturación.", 0);
_logger.LogError(ex, "Error al actualizar número de factura para Factura ID {IdFactura}", idFactura);
return (false, "Error interno al actualizar el número de factura.");
}
}
@@ -133,25 +442,34 @@ namespace GestionIntegral.Api.Services.Suscripciones
decimal importeTotal = 0;
var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet();
var fechaActual = new DateTime(anio, mes, 1);
var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, fechaActual, transaction);
var promocionesDeBonificacion = promociones.Where(p => p.TipoEfecto == "BonificarEntregaDia").ToList();
while (fechaActual.Month == mes)
{
// La suscripción debe estar activa en este día
if (fechaActual.Date >= suscripcion.FechaInicio.Date &&
(suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date))
if (fechaActual.Date >= suscripcion.FechaInicio.Date && (suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date))
{
var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek);
if (diasDeEntrega.Contains(diaSemanaChar))
{
decimal precioDelDia = 0;
var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction);
if (precioActivo != null)
{
importeTotal += GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek);
precioDelDia = GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek);
}
else
{
_logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date);
}
bool diaBonificado = promocionesDeBonificacion.Any(promo => EvaluarCondicionPromocion(promo, fechaActual));
if (diaBonificado)
{
precioDelDia = 0;
_logger.LogInformation("Día {Fecha} bonificado para suscripción {IdSuscripcion} por promoción.", fechaActual.ToShortDateString(), suscripcion.IdSuscripcion);
}
importeTotal += precioDelDia;
}
}
fechaActual = fechaActual.AddDays(1);
@@ -159,72 +477,30 @@ namespace GestionIntegral.Api.Services.Suscripciones
return importeTotal;
}
public async Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes)
private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha)
{
var periodo = $"{anio}-{mes:D2}";
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo);
return facturasData.Select(data => new FacturaDto
switch (promocion.TipoCondicion)
{
IdFactura = data.Factura.IdFactura,
IdSuscripcion = data.Factura.IdSuscripcion,
Periodo = data.Factura.Periodo,
FechaEmision = data.Factura.FechaEmision.ToString("yyyy-MM-dd"),
FechaVencimiento = data.Factura.FechaVencimiento.ToString("yyyy-MM-dd"),
ImporteFinal = data.Factura.ImporteFinal,
Estado = data.Factura.Estado,
NumeroFactura = data.Factura.NumeroFactura,
NombreSuscriptor = data.NombreSuscriptor,
NombrePublicacion = data.NombrePublicacion
});
}
public async Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura)
{
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null) return (false, "Factura no encontrada.");
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura aún no tiene un número asignado por ARCA.");
var suscripcion = await _suscripcionRepository.GetByIdAsync(factura.IdSuscripcion);
if (suscripcion == null) return (false, "Suscripción asociada no encontrada.");
var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor);
if (suscriptor == null) return (false, "Suscriptor asociado no encontrado.");
if (string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no tiene una dirección de email configurada.");
try
{
var asunto = $"Tu factura del Diario El Día - Período {factura.Periodo}";
var cuerpo = $@"
<h1>Hola {suscriptor.NombreCompleto},</h1>
<p>Te adjuntamos los detalles de tu factura para el período {factura.Periodo}.</p>
<ul>
<li><strong>Número de Factura:</strong> {factura.NumeroFactura}</li>
<li><strong>Importe Total:</strong> ${factura.ImporteFinal:N2}</li>
<li><strong>Fecha de Vencimiento:</strong> {factura.FechaVencimiento:dd/MM/yyyy}</li>
</ul>
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
<p><em>Diario El Día</em></p>";
await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpo);
return (true, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falló el envío de email para la factura ID {IdFactura}", idFactura);
return (false, "Ocurrió un error al intentar enviar el email.");
case "Siempre": return true;
case "DiaDeSemana":
int diaSemanaActual = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek;
return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActual;
case "PrimerDiaSemanaDelMes":
int diaSemanaActualMes = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek;
return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActualMes && fecha.Day <= 7;
default: return false;
}
}
private string GetCharDiaSemana(DayOfWeek dia) => dia switch
{
DayOfWeek.Sunday => "D",
DayOfWeek.Monday => "L",
DayOfWeek.Tuesday => "M",
DayOfWeek.Wednesday => "X",
DayOfWeek.Thursday => "J",
DayOfWeek.Friday => "V",
DayOfWeek.Saturday => "S",
DayOfWeek.Sunday => "Dom",
DayOfWeek.Monday => "Lun",
DayOfWeek.Tuesday => "Mar",
DayOfWeek.Wednesday => "Mie",
DayOfWeek.Thursday => "Jue",
DayOfWeek.Friday => "Vie",
DayOfWeek.Saturday => "Sab",
_ => ""
};
@@ -239,5 +515,14 @@ namespace GestionIntegral.Api.Services.Suscripciones
DayOfWeek.Saturday => precio.Sabado ?? 0,
_ => 0
};
private decimal CalcularDescuentoPromociones(decimal importeBruto, IEnumerable<Promocion> promociones)
{
return promociones.Where(p => p.TipoEfecto.Contains("Descuento")).Sum(p =>
p.TipoEfecto == "DescuentoPorcentajeTotal"
? (importeBruto * p.ValorEfecto) / 100
: p.ValorEfecto
);
}
}
}

View File

@@ -4,8 +4,9 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IAjusteService
{
Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor);
Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor, DateTime? fechaDesde, DateTime? fechaHasta);
Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> ActualizarAjuste(int idAjuste, UpdateAjusteDto updateDto);
Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario);
}
}

View File

@@ -1,11 +1,15 @@
using GestionIntegral.Api.Dtos.Suscripciones;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IFacturacionService
{
Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes);
Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura);
Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion);
Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor);
Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura);
Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario);
}
}

View File

@@ -4,13 +4,13 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
public interface ISuscripcionService
{
Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor);
Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion);
Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor);
Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario);
Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion);
Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion);
Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion);
Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario);
Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, AsignarPromocionDto dto, int idUsuario);
Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion);
}
}

View File

@@ -66,45 +66,67 @@ namespace GestionIntegral.Api.Services.Suscripciones
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura);
if (factura == null) return (null, "La factura especificada no existe.");
if (factura.Estado == "Pagada") return (null, "La factura ya se encuentra pagada.");
if (factura.Estado == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
// Usar EstadoPago para la validación
if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago);
if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida.");
// Obtenemos la suma de pagos ANTERIORES
var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction);
var nuevoPago = new Pago
{
IdFactura = createDto.IdFactura,
FechaPago = createDto.FechaPago,
IdFormaPago = createDto.IdFormaPago,
Monto = createDto.Monto,
Estado = "Aprobado", // Los pagos manuales se asumen aprobados
Estado = "Aprobado",
Referencia = createDto.Referencia,
Observaciones = createDto.Observaciones,
IdUsuarioRegistro = idUsuario
};
// 1. Crear el registro del pago
// Creamos el nuevo pago
var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction);
if (pagoCreado == null) throw new DataException("No se pudo registrar el pago.");
// 2. Si el monto pagado es igual o mayor al importe de la factura, actualizar la factura
// (Permitimos pago mayor por si hay redondeos, etc.)
if (pagoCreado.Monto >= factura.ImporteFinal)
// Calculamos el nuevo total EN MEMORIA
var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto;
// Comparamos y actualizamos el estado si es necesario
// CORRECCIÓN: Usar EstadoPago y el método correcto del repositorio
if (factura.EstadoPago != "Pagada" && nuevoTotalPagado >= factura.ImporteFinal)
{
bool actualizado = await _facturaRepository.UpdateEstadoAsync(factura.IdFactura, "Pagada", transaction);
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, "Pagada", transaction);
if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'.");
}
transaction.Commit();
_logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario);
var dto = await MapToDto(pagoCreado);
// Construimos el DTO de respuesta SIN volver a consultar la base de datos
var usuario = await _usuarioRepository.GetByIdAsync(idUsuario);
var dto = new PagoDto
{
IdPago = pagoCreado.IdPago,
IdFactura = pagoCreado.IdFactura,
FechaPago = pagoCreado.FechaPago.ToString("yyyy-MM-dd"),
IdFormaPago = pagoCreado.IdFormaPago,
NombreFormaPago = formaPago.Nombre,
Monto = pagoCreado.Monto,
Estado = pagoCreado.Estado,
Referencia = pagoCreado.Referencia,
Observaciones = pagoCreado.Observaciones,
IdUsuarioRegistro = pagoCreado.IdUsuarioRegistro,
NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
};
return (dto, null);
}
catch (Exception ex)

View File

@@ -28,8 +28,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
IdPromocion = promo.IdPromocion,
Descripcion = promo.Descripcion,
TipoPromocion = promo.TipoPromocion,
Valor = promo.Valor,
TipoEfecto = promo.TipoEfecto,
ValorEfecto = promo.ValorEfecto,
TipoCondicion = promo.TipoCondicion,
ValorCondicion = promo.ValorCondicion,
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
Activa = promo.Activa
@@ -58,8 +60,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
var nuevaPromocion = new Promocion
{
Descripcion = createDto.Descripcion,
TipoPromocion = createDto.TipoPromocion,
Valor = createDto.Valor,
TipoEfecto = createDto.TipoEfecto,
ValorEfecto = createDto.ValorEfecto,
TipoCondicion = createDto.TipoCondicion,
ValorCondicion = createDto.ValorCondicion,
FechaInicio = createDto.FechaInicio,
FechaFin = createDto.FechaFin,
Activa = createDto.Activa,
@@ -96,10 +100,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
}
// Mapeo
existente.Descripcion = updateDto.Descripcion;
existente.TipoPromocion = updateDto.TipoPromocion;
existente.Valor = updateDto.Valor;
existente.TipoEfecto = updateDto.TipoEfecto;
existente.ValorEfecto = updateDto.ValorEfecto;
existente.TipoCondicion = updateDto.TipoCondicion;
existente.ValorCondicion = updateDto.ValorCondicion;
existente.FechaInicio = updateDto.FechaInicio;
existente.FechaFin = updateDto.FechaFin;
existente.Activa = updateDto.Activa;

View File

@@ -3,7 +3,12 @@ using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones
{
@@ -36,8 +41,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
IdPromocion = promo.IdPromocion,
Descripcion = promo.Descripcion,
TipoPromocion = promo.TipoPromocion,
Valor = promo.Valor,
TipoEfecto = promo.TipoEfecto,
ValorEfecto = promo.ValorEfecto,
TipoCondicion = promo.TipoCondicion,
ValorCondicion = promo.ValorCondicion,
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
Activa = promo.Activa
@@ -154,47 +161,67 @@ namespace GestionIntegral.Api.Services.Suscripciones
}
}
public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion)
public async Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion)
{
var promociones = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion);
return promociones.Select(MapPromocionToDto);
var asignaciones = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion);
return asignaciones.Select(a => new PromocionAsignadaDto
{
IdPromocion = a.Promocion.IdPromocion,
Descripcion = a.Promocion.Descripcion,
TipoEfecto = a.Promocion.TipoEfecto,
ValorEfecto = a.Promocion.ValorEfecto,
TipoCondicion = a.Promocion.TipoCondicion,
ValorCondicion = a.Promocion.ValorCondicion,
FechaInicio = a.Promocion.FechaInicio.ToString("yyyy-MM-dd"),
FechaFin = a.Promocion.FechaFin?.ToString("yyyy-MM-dd"),
Activa = a.Promocion.Activa,
VigenciaDesdeAsignacion = a.Asignacion.VigenciaDesde.ToString("yyyy-MM-dd"),
VigenciaHastaAsignacion = a.Asignacion.VigenciaHasta?.ToString("yyyy-MM-dd")
});
}
public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion)
{
var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true);
var promosAsignadas = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion);
var idsAsignadas = promosAsignadas.Select(p => p.IdPromocion).ToHashSet();
var promosAsignadasData = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion);
var idsAsignadas = promosAsignadasData.Select(p => p.Promocion.IdPromocion).ToHashSet();
return todasLasPromosActivas
.Where(p => !idsAsignadas.Contains(p.IdPromocion))
.Select(MapPromocionToDto);
.Select(MapPromocionToDto); // Usa el helper que ya creamos
}
public async Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario)
public async Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, AsignarPromocionDto dto, int idUsuario)
{
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
// Validaciones
if (await _suscripcionRepository.GetByIdAsync(idSuscripcion) == null) return (false, "Suscripción no encontrada.");
if (await _promocionRepository.GetByIdAsync(idPromocion) == null) return (false, "Promoción no encontrada.");
if (await _promocionRepository.GetByIdAsync(dto.IdPromocion) == null) return (false, "Promoción no encontrada.");
await _suscripcionRepository.AsignarPromocionAsync(idSuscripcion, idPromocion, idUsuario, transaction);
var nuevaAsignacion = new SuscripcionPromocion
{
IdSuscripcion = idSuscripcion,
IdPromocion = dto.IdPromocion,
IdUsuarioAsigno = idUsuario,
VigenciaDesde = dto.VigenciaDesde,
VigenciaHasta = dto.VigenciaHasta
};
await _suscripcionRepository.AsignarPromocionAsync(nuevaAsignacion, transaction);
transaction.Commit();
return (true, null);
}
catch (Exception ex)
{
// Capturar error de Primary Key duplicada
if (ex.Message.Contains("PRIMARY KEY constraint"))
{
return (false, "Esta promoción ya está asignada a la suscripción.");
}
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al asignar promoción {IdPromocion} a suscripción {IdSuscripcion}", idPromocion, idSuscripcion);
_logger.LogError(ex, "Error al asignar promoción {IdPromocion} a suscripción {IdSuscripcion}", dto.IdPromocion, idSuscripcion);
return (false, "Error interno al asignar la promoción.");
}
}

View File

@@ -1,15 +1,8 @@
// Archivo: GestionIntegral.Api/Services/Suscripciones/SuscriptorService.cs
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones
{

View File

@@ -5,6 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AppSettings": {
"FacturasPdfPath": "C:\\Ruta\\A\\Tus\\FacturasPDF"
},
"Jwt": {
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
"Issuer": "GestionIntegralApi",
@@ -13,11 +16,11 @@
},
"AllowedHosts": "*",
"MailSettings": {
"SmtpHost": "smtp.yourprovider.com",
"SmtpHost": "mail.eldia.com",
"SmtpPort": 587,
"SenderName": "Diario El Día - Suscripciones",
"SenderEmail": "suscripciones@eldia.com",
"SmtpUser": "your-smtp-username",
"SmtpPass": "your-smtp-password"
"SenderName": "Club - Diario El Día",
"SenderEmail": "alertas@eldia.com",
"SmtpUser": "alertas@eldia.com",
"SmtpPass": "@Alertas713550@"
}
}

View File

@@ -1,6 +1,10 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { CreateAjusteDto } from '../../../models/dtos/Suscripciones/CreateAjusteDto';
import type { UpdateAjusteDto } from '../../../models/dtos/Suscripciones/UpdateAjusteDto';
import type { AjusteDto } from '../../../models/dtos/Suscripciones/AjusteDto';
const modalStyle = {
position: 'absolute' as 'absolute',
@@ -12,34 +16,47 @@ const modalStyle = {
boxShadow: 24, p: 4,
};
// --- TIPO UNIFICADO PARA EL ESTADO DEL FORMULARIO ---
type AjusteFormData = Partial<CreateAjusteDto & UpdateAjusteDto>;
interface AjusteFormModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreateAjusteDto) => Promise<void>;
onSubmit: (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => Promise<void>;
initialData?: AjusteDto | null;
idSuscriptor: number;
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage }) => {
const [formData, setFormData] = useState<Partial<CreateAjusteDto>>({});
const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage, initialData }) => {
const [formData, setFormData] = useState<AjusteFormData>({});
const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData);
useEffect(() => {
if (open) {
// Formatear fecha correctamente: el DTO de Ajuste tiene FechaAlta con hora, pero el input necesita "yyyy-MM-dd"
const fechaParaFormulario = initialData?.fechaAjuste
? initialData.fechaAjuste.split(' ')[0] // Tomar solo la parte de la fecha
: new Date().toISOString().split('T')[0];
setFormData({
idSuscriptor: idSuscriptor,
tipoAjuste: 'Credito', // Por defecto es un crédito (descuento)
monto: 0,
motivo: ''
idSuscriptor: initialData?.idSuscriptor || idSuscriptor,
fechaAjuste: fechaParaFormulario,
tipoAjuste: initialData?.tipoAjuste || 'Credito',
monto: initialData?.monto || undefined, // undefined para que el placeholder se muestre
motivo: initialData?.motivo || ''
});
setLocalErrors({});
}
}, [open, idSuscriptor]);
}, [open, initialData, idSuscriptor]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.fechaAjuste) errors.fechaAjuste = "La fecha es obligatoria.";
if (!formData.tipoAjuste) errors.tipoAjuste = "Seleccione un tipo.";
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
if (!formData.motivo?.trim()) errors.motivo = "El motivo es obligatorio.";
@@ -47,16 +64,20 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
return Object.keys(errors).length === 0;
};
// --- HANDLERS CON TIPADO EXPLÍCITO ---
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: name === 'monto' ? parseFloat(value) : value }));
setFormData((prev: AjusteFormData) => ({
...prev,
[name]: name === 'monto' && value !== '' ? parseFloat(value) : value
}));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const handleSelectChange = (e: SelectChangeEvent<string>) => { // Tipado como string
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setFormData((prev: AjusteFormData) => ({ ...prev, [name]: value }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
@@ -68,7 +89,11 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
setLoading(true);
let success = false;
try {
await onSubmit(formData as CreateAjusteDto);
if (isEditing && initialData) {
await onSubmit(formData as UpdateAjusteDto, initialData.idAjuste);
} else {
await onSubmit(formData as CreateAjusteDto);
}
success = true;
} catch (error) {
success = false;
@@ -81,20 +106,22 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">Registrar Ajuste Manual</Typography>
<Typography variant="h6">{isEditing ? 'Editar Ajuste Manual' : 'Registrar Ajuste Manual'}</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="fechaAjuste" label="Fecha del Ajuste" type="date" value={formData.fechaAjuste || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaAjuste} helperText={localErrors.fechaAjuste} />
<FormControl fullWidth margin="dense" error={!!localErrors.tipoAjuste}>
<InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel>
<Select name="tipoAjuste" labelId="tipo-ajuste-label" value={formData.tipoAjuste || ''} onChange={handleSelectChange} label="Tipo de Ajuste">
<MenuItem value="Credito">Crédito (Descuento a favor del cliente)</MenuItem>
<MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem>
</Select>
<InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel>
<Select name="tipoAjuste" labelId="tipo-ajuste-label" value={formData.tipoAjuste || ''} onChange={handleSelectChange} label="Tipo de Ajuste">
<MenuItem value="Credito">Crédito (Descuento a favor del cliente)</MenuItem>
<MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem>
</Select>
</FormControl>
<TextField name="monto" label="Monto" type="number" value={formData.monto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.monto} helperText={localErrors.monto} InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} inputProps={{ step: "0.01" }} />
<TextField name="motivo" label="Motivo" value={formData.motivo || ''} onChange={handleInputChange} required fullWidth margin="dense" multiline rows={3} error={!!localErrors.motivo} helperText={localErrors.motivo} />
<Alert severity="info" sx={{ mt: 2 }}>
Nota: Este ajuste se aplicará en la facturación del período correspondiente a la "Fecha del Ajuste".
</Alert>
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{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}>

View File

@@ -1,12 +1,26 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material';
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider, type SelectChangeEvent, TextField } from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteIcon from '@mui/icons-material/Delete';
import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto';
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
import type { PromocionAsignadaDto } from '../../../models/dtos/Suscripciones/PromocionAsignadaDto';
import type { AsignarPromocionDto } from '../../../models/dtos/Suscripciones/AsignarPromocionDto';
import suscripcionService from '../../../services/Suscripciones/suscripcionService';
const modalStyle = { /* ... */ };
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: '80%', md: '600px' },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflowY: 'auto'
};
interface GestionarPromocionesSuscripcionModalProps {
open: boolean;
@@ -15,12 +29,15 @@ interface GestionarPromocionesSuscripcionModalProps {
}
const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => {
const [asignadas, setAsignadas] = useState<PromocionDto[]>([]);
const [asignadas, setAsignadas] = useState<PromocionAsignadaDto[]>([]);
const [disponibles, setDisponibles] = useState<PromocionDto[]>([]);
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
const [vigenciaDesde, setVigenciaDesde] = useState('');
const [vigenciaHasta, setVigenciaHasta] = useState('');
const cargarDatos = useCallback(async () => {
if (!suscripcion) return;
setLoading(true);
@@ -40,16 +57,30 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
}, [suscripcion]);
useEffect(() => {
if (open) {
if (open && suscripcion) {
cargarDatos();
setSelectedPromo('');
setVigenciaDesde(suscripcion.fechaInicio);
setVigenciaHasta('');
}
}, [open, cargarDatos]);
}, [open, suscripcion]);
const handleAsignar = async () => {
if (!suscripcion || !selectedPromo) return;
if (!suscripcion || !selectedPromo || !vigenciaDesde) {
setError("Debe seleccionar una promoción y una fecha de inicio.");
return;
}
setError(null);
try {
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, Number(selectedPromo));
const dto: AsignarPromocionDto = {
idPromocion: Number(selectedPromo),
vigenciaDesde: vigenciaDesde,
vigenciaHasta: vigenciaHasta || null
};
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, dto);
setSelectedPromo('');
setVigenciaDesde(suscripcion.fechaInicio);
setVigenciaHasta('');
cargarDatos();
} catch (err: any) {
setError(err.response?.data?.message || "Error al asignar la promoción.");
@@ -58,14 +89,34 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
const handleQuitar = async (idPromocion: number) => {
if (!suscripcion) return;
try {
await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion);
cargarDatos();
} catch (err: any) {
setError(err.response?.data?.message || "Error al quitar la promoción.");
setError(null);
if (window.confirm("¿Está seguro de que desea quitar esta promoción de la suscripción?")) {
try {
await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion);
cargarDatos();
} catch (err: any) {
setError(err.response?.data?.message || "Error al quitar la promoción.");
}
}
};
const formatDate = (dateString?: string | null) => {
if (!dateString) return 'Indefinida';
const parts = dateString.split('-');
return `${parts[2]}/${parts[1]}/${parts[0]}`;
};
const formatSecondaryText = (promo: PromocionAsignadaDto): string => {
let text = '';
switch (promo.tipoEfecto) {
case 'DescuentoPorcentajeTotal': text = `Descuento Total: ${promo.valorEfecto}%`; break;
case 'DescuentoMontoFijoTotal': text = `Descuento Total: $${promo.valorEfecto.toFixed(2)}`; break;
case 'BonificarEntregaDia': text = 'Bonificación de Día'; break;
default: text = 'Tipo desconocido';
}
return text;
};
if (!suscripcion) return null;
return (
@@ -73,30 +124,39 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
<Box sx={modalStyle}>
<Typography variant="h6">Gestionar Promociones</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Suscripción a: {suscripcion.nombrePublicacion}
Suscripción a: <strong>{suscripcion.nombrePublicacion}</strong>
</Typography>
{error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{loading ? <CircularProgress /> : (
{loading ? <CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> : (
<>
<Typography sx={{ mt: 2 }}>Promociones Asignadas</Typography>
<Typography sx={{ mt: 2, fontWeight: 'medium' }}>Promociones Asignadas</Typography>
<List dense>
{asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>}
{asignadas.map(p => (
<ListItem key={p.idPromocion} secondaryAction={<IconButton edge="end" onClick={() => handleQuitar(p.idPromocion)}><DeleteIcon /></IconButton>}>
<ListItemText primary={p.descripcion} secondary={`Tipo: ${p.tipoPromocion}, Valor: ${p.valor}`} />
<ListItemText
primary={p.descripcion}
secondary={`Vigente del ${formatDate(p.vigenciaDesdeAsignacion)} al ${formatDate(p.vigenciaHastaAsignacion)} - ${formatSecondaryText(p)}`}
/>
</ListItem>
))}
</List>
<Divider sx={{ my: 2 }} />
<Typography>Asignar Nueva Promoción</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}>
<FormControl fullWidth size="small">
<Box sx={{ mt: 1 }}>
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel>Promociones Disponibles</InputLabel>
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e) => setSelectedPromo(e.target.value)}>
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e: SelectChangeEvent<number | string>) => setSelectedPromo(e.target.value)}>
{disponibles.map(p => <MenuItem key={p.idPromocion} value={p.idPromocion}>{p.descripcion}</MenuItem>)}
</Select>
</FormControl>
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo}><AddCircleOutlineIcon /></Button>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField label="Vigencia Desde" type="date" value={vigenciaDesde} onChange={(e) => setVigenciaDesde(e.target.value)} required fullWidth size="small" InputLabelProps={{ shrink: true }} />
<TextField label="Vigencia Hasta (Opcional)" type="date" value={vigenciaHasta} onChange={(e) => setVigenciaHasta(e.target.value)} fullWidth size="small" InputLabelProps={{ shrink: true }} />
</Box>
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo} sx={{ mt: 2 }} startIcon={<AddCircleOutlineIcon />}>
Asignar
</Button>
</Box>
</>
)}

View File

@@ -1,5 +1,3 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto';
@@ -54,7 +52,7 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
fetchFormasDePago();
setFormData({
idFactura: factura.idFactura,
monto: factura.importeFinal,
monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto
fechaPago: new Date().toISOString().split('T')[0]
});
setLocalErrors({});
@@ -64,8 +62,18 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago.";
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria.";
const monto = formData.monto ?? 0;
const saldo = factura?.saldoPendiente ?? 0;
if (monto <= 0) {
errors.monto = "El monto debe ser mayor a cero.";
} else if (monto > saldo) {
// Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje
errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`;
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
@@ -109,8 +117,8 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">Registrar Pago Manual</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Factura #{factura.idFactura} para {factura.nombreSuscriptor}
<Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}>
Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} />

View File

@@ -1,28 +1,33 @@
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox,
type SelectChangeEvent, InputAdornment } from '@mui/material';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: '80%', md: '600px' },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflowY: 'auto'
boxShadow: 24, p: 4,
maxHeight: '90vh', overflowY: 'auto'
};
const tiposPromocion = [
{ value: 'Porcentaje', label: 'Descuento Porcentual (%)' },
{ value: 'MontoFijo', label: 'Descuento de Monto Fijo ($)' },
// { value: 'BonificacionDias', label: 'Bonificación de Días' }, // Descomentar para futuras implementaciones
const tiposEfecto = [
{ value: 'DescuentoPorcentajeTotal', label: 'Descuento en Porcentaje (%) sobre el total' },
{ value: 'DescuentoMontoFijoTotal', label: 'Descuento en Monto Fijo ($) sobre el total' },
{ value: 'BonificarEntregaDia', label: 'Bonificar / Día Gratis (Precio del día = $0)' },
];
const tiposCondicion = [
{ value: 'Siempre', label: 'Siempre (en todos los días de entrega)' },
{ value: 'DiaDeSemana', label: 'Un día de la semana específico' },
{ value: 'PrimerDiaSemanaDelMes', label: 'El primer día de la semana del mes' },
];
const diasSemana = [
{ value: 1, label: 'Lunes' }, { value: 2, label: 'Martes' }, { value: 3, label: 'Miércoles' },
{ value: 4, label: 'Jueves' }, { value: 5, label: 'Viernes' }, { value: 6, label: 'Sábado' },
{ value: 7, label: 'Domingo' }
];
interface PromocionFormModalProps {
@@ -38,18 +43,22 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({});
const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData);
const necesitaValorCondicion = formData.tipoCondicion === 'DiaDeSemana' || formData.tipoCondicion === 'PrimerDiaSemanaDelMes';
useEffect(() => {
if (open) {
setFormData(initialData || {
const defaults = {
descripcion: '',
tipoPromocion: 'Porcentaje',
valor: 0,
tipoEfecto: 'DescuentoPorcentajeTotal' as const,
valorEfecto: 0,
tipoCondicion: 'Siempre' as const,
valorCondicion: null,
fechaInicio: new Date().toISOString().split('T')[0],
activa: true
});
};
setFormData(initialData ? { ...initialData } : defaults);
setLocalErrors({});
}
}, [open, initialData]);
@@ -57,10 +66,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.';
if (!formData.tipoPromocion) errors.tipoPromocion = 'El tipo de promoción es obligatorio.';
if (!formData.valor || formData.valor <= 0) errors.valor = 'El valor debe ser mayor a cero.';
if (formData.tipoPromocion === 'Porcentaje' && (formData.valor ?? 0) > 100) {
errors.valor = 'El valor para porcentaje no puede ser mayor a 100.';
if (!formData.tipoEfecto) errors.tipoEfecto = 'El tipo de efecto es obligatorio.';
if (formData.tipoEfecto !== 'BonificarEntregaDia' && (!formData.valorEfecto || formData.valorEfecto <= 0)) {
errors.valorEfecto = 'El valor debe ser mayor a cero.';
}
if (formData.tipoEfecto === 'DescuentoPorcentajeTotal' && formData.valorEfecto && formData.valorEfecto > 100) {
errors.valorEfecto = 'El valor para porcentaje no puede ser mayor a 100.';
}
if (!formData.tipoCondicion) errors.tipoCondicion = 'La condición es obligatoria.';
if (necesitaValorCondicion && !formData.valorCondicion) {
errors.valorCondicion = "Debe seleccionar un día para esta condición.";
}
if (!formData.fechaInicio) errors.fechaInicio = 'La fecha de inicio es obligatoria.';
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
@@ -72,7 +87,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
const finalValue = type === 'checkbox' ? checked : (type === 'number' ? parseFloat(value) : value);
const finalValue = type === 'checkbox' ? checked : (name === 'valorEfecto' && value !== '' ? parseFloat(value) : value);
setFormData(prev => ({ ...prev, [name]: finalValue }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
@@ -80,7 +95,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
const newFormData = { ...formData, [name]: value };
if (name === 'tipoCondicion' && value === 'Siempre') {
newFormData.valorCondicion = null;
}
if (name === 'tipoEfecto' && value === 'BonificarEntregaDia') {
newFormData.valorEfecto = 0; // Bonificar no necesita valor
}
setFormData(newFormData);
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
@@ -93,11 +117,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
setLoading(true);
let success = false;
try {
const dataToSubmit = {
...formData,
fechaFin: formData.fechaFin || null
} as CreatePromocionDto | UpdatePromocionDto;
const dataToSubmit = { ...formData, fechaFin: formData.fechaFin || null } as CreatePromocionDto | UpdatePromocionDto;
await onSubmit(dataToSubmit, initialData?.idPromocion);
success = true;
} catch (error) {
@@ -111,28 +131,39 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
<Typography variant="h6">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="descripcion" label="Descripción" value={formData.descripcion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.descripcion} helperText={localErrors.descripcion} disabled={loading} autoFocus />
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControl fullWidth margin="dense" sx={{flex: 2}} error={!!localErrors.tipoPromocion}>
<InputLabel id="tipo-promo-label" required>Tipo</InputLabel>
<Select name="tipoPromocion" labelId="tipo-promo-label" value={formData.tipoPromocion || ''} onChange={handleSelectChange} label="Tipo" disabled={loading}>
{tiposPromocion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</Select>
</FormControl>
<TextField name="valor" label="Valor" type="number" value={formData.valor || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{flex: 1}} error={!!localErrors.valor} helperText={localErrors.valor} disabled={loading}
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoPromocion === 'Porcentaje' ? '%' : '$'}</InputAdornment> }}
<FormControl fullWidth margin="dense" error={!!localErrors.tipoEfecto}>
<InputLabel>Efecto de la Promoción</InputLabel>
<Select name="tipoEfecto" value={formData.tipoEfecto || ''} onChange={handleSelectChange} label="Efecto de la Promoción" disabled={loading}>
{tiposEfecto.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</Select>
</FormControl>
{formData.tipoEfecto !== 'BonificarEntregaDia' && (
<TextField name="valorEfecto" label="Valor" type="number" value={formData.valorEfecto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.valorEfecto} helperText={localErrors.valorEfecto} disabled={loading}
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoEfecto === 'DescuentoPorcentajeTotal' ? '%' : '$'}</InputAdornment> }}
inputProps={{ step: "0.01" }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
)}
<FormControl fullWidth margin="dense" error={!!localErrors.tipoCondicion}>
<InputLabel>Condición de Aplicación</InputLabel>
<Select name="tipoCondicion" value={formData.tipoCondicion || ''} onChange={handleSelectChange} label="Condición de Aplicación" disabled={loading}>
{tiposCondicion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</Select>
</FormControl>
{necesitaValorCondicion && (
<FormControl fullWidth margin="dense" error={!!localErrors.valorCondicion}>
<InputLabel>Día de la Semana</InputLabel>
<Select name="valorCondicion" value={formData.valorCondicion || ''} onChange={handleSelectChange} label="Día de la Semana" disabled={loading}>
{diasSemana.map(d => <MenuItem key={d.value} value={d.value}>{d.label}</MenuItem>)}
</Select>
</FormControl>
)}
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
<TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} />
<TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} />
</Box>
<FormControlLabel control={<Checkbox name="activa" checked={formData.activa ?? true} onChange={handleInputChange} disabled={loading}/>} label="Promoción Activa" sx={{mt: 1}} />
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}

View File

@@ -25,10 +25,10 @@ const modalStyle = {
};
const dias = [
{ label: 'Lunes', value: 'L' }, { label: 'Martes', value: 'M' },
{ label: 'Miércoles', value: 'X' }, { label: 'Jueves', value: 'J' },
{ label: 'Viernes', value: 'V' }, { label: 'Sábado', value: 'S' },
{ label: 'Domingo', value: 'D' }
{ label: 'Lunes', value: 'Lun' }, { label: 'Martes', value: 'Mar' },
{ label: 'Miércoles', value: 'Mie' }, { label: 'Jueves', value: 'Jue' },
{ label: 'Viernes', value: 'Vie' }, { label: 'Sábado', value: 'Sab' },
{ label: 'Domingo', value: 'Dom' }
];
interface SuscripcionFormModalProps {

View File

@@ -1,7 +1,5 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/SuscriptorFormModal.tsx
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material'; // 1. Importar SelectChangeEvent
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material';
import type { SuscriptorDto } from '../../../models/dtos/Suscripciones/SuscriptorDto';
import type { CreateSuscriptorDto } from '../../../models/dtos/Suscripciones/CreateSuscriptorDto';
import type { UpdateSuscriptorDto } from '../../../models/dtos/Suscripciones/UpdateSuscriptorDto';
@@ -31,9 +29,7 @@ interface SuscriptorFormModalProps {
clearErrorMessage: () => void;
}
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage
}) => {
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage }) => {
const [formData, setFormData] = useState<Partial<CreateSuscriptorDto>>({});
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
const [loading, setLoading] = useState(false);
@@ -59,9 +55,18 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
if (open) {
fetchFormasDePago();
setFormData(initialData || {
nombreCompleto: '', tipoDocumento: 'DNI', nroDocumento: '', cbu: ''
});
const dataParaFormulario: Partial<CreateSuscriptorDto> = {
nombreCompleto: initialData?.nombreCompleto || '',
email: initialData?.email || '',
telefono: initialData?.telefono || '',
direccion: initialData?.direccion || '',
tipoDocumento: initialData?.tipoDocumento || 'DNI',
nroDocumento: initialData?.nroDocumento || '',
cbu: initialData?.cbu || '',
idFormaPagoPreferida: initialData?.idFormaPagoPreferida,
observaciones: initialData?.observaciones || ''
};
setFormData(dataParaFormulario);
setLocalErrors({});
}
}, [open, initialData]);
@@ -73,9 +78,15 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
if (!formData.tipoDocumento) errors.tipoDocumento = 'El tipo de documento es obligatorio.';
if (!formData.nroDocumento?.trim()) errors.nroDocumento = 'El número de documento es obligatorio.';
if (!formData.idFormaPagoPreferida) errors.idFormaPagoPreferida = 'La forma de pago es obligatoria.';
if (CBURequerido && (!formData.cbu || formData.cbu.trim().length !== 22)) {
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.';
if (CBURequerido) {
if (!formData.cbu || formData.cbu.trim().length !== 22) {
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.';
}
} else if (formData.cbu && formData.cbu.trim().length > 0 && formData.cbu.trim().length !== 22) {
errors.cbu = 'El CBU debe tener 22 dígitos o estar vacío.';
}
if (formData.email && !/^\S+@\S+\.\S+$/.test(formData.email)) {
errors.email = 'El formato del email no es válido.';
}
@@ -86,23 +97,25 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (localErrors[name]) {
setLocalErrors(prev => ({ ...prev, [name]: null }));
}
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
// 2. Crear un handler específico para los Select
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (localErrors[name]) {
setLocalErrors(prev => ({ ...prev, [name]: null }));
const newFormData = { ...formData, [name]: value };
if (name === 'idFormaPagoPreferida') {
const formaDePagoSeleccionada = formasDePago.find(fp => fp.idFormaPago === value);
if (formaDePagoSeleccionada && !formaDePagoSeleccionada.requiereCBU) {
newFormData.cbu = '';
}
}
setFormData(newFormData);
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
clearErrorMessage();
@@ -111,7 +124,12 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
setLoading(true);
let success = false;
try {
const dataToSubmit = formData as CreateSuscriptorDto | UpdateSuscriptorDto;
const dataToSubmit = {
...formData,
idFormaPagoPreferida: Number(formData.idFormaPagoPreferida),
cbu: formData.cbu?.trim() || null
} as CreateSuscriptorDto | UpdateSuscriptorDto;
await onSubmit(dataToSubmit, initialData?.idSuscriptor);
success = true;
} catch (error) {
@@ -140,7 +158,6 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControl margin="dense" sx={{ minWidth: 120 }}>
<InputLabel id="tipo-doc-label">Tipo</InputLabel>
{/* 3. Aplicar el nuevo handler a los Selects */}
<Select labelId="tipo-doc-label" name="tipoDocumento" value={formData.tipoDocumento || 'DNI'} onChange={handleSelectChange} label="Tipo" disabled={loading}>
<MenuItem value="DNI">DNI</MenuItem>
<MenuItem value="CUIT">CUIT</MenuItem>
@@ -151,15 +168,37 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
</Box>
<FormControl fullWidth margin="dense" error={!!localErrors.idFormaPagoPreferida}>
<InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel>
{/* 3. Aplicar el nuevo handler a los Selects */}
<Select labelId="forma-pago-label" name="idFormaPagoPreferida" value={formData.idFormaPagoPreferida || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loading || loadingFormasPago}>
<Select
labelId="forma-pago-label"
name="idFormaPagoPreferida"
value={loadingFormasPago ? '' : formData.idFormaPagoPreferida || ''}
onChange={handleSelectChange}
label="Forma de Pago"
disabled={loading || loadingFormasPago}
>
{loadingFormasPago && <MenuItem value=""><em>Cargando...</em></MenuItem>}
{formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)}
</Select>
{localErrors.idFormaPagoPreferida && <Typography color="error" variant="caption">{localErrors.idFormaPagoPreferida}</Typography>}
</FormControl>
{CBURequerido && (
<TextField name="cbu" label="CBU" value={formData.cbu || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.cbu} helperText={localErrors.cbu} disabled={loading} inputProps={{ maxLength: 22 }} />
<TextField
name="cbu"
label="CBU"
value={formData.cbu || ''}
onChange={handleInputChange}
required
fullWidth
margin="dense"
error={!!localErrors.cbu}
helperText={localErrors.cbu}
disabled={loading}
inputProps={{ maxLength: 22 }}
/>
)}
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} />
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>}

View File

@@ -1,5 +1,6 @@
export interface AjusteDto {
idAjuste: number;
fechaAjuste: string;
idSuscriptor: number;
tipoAjuste: 'Credito' | 'Debito';
monto: number;

View File

@@ -0,0 +1,5 @@
export interface AsignarPromocionDto {
idPromocion: number;
vigenciaDesde: string; // "yyyy-MM-dd"
vigenciaHasta?: string | null;
}

View File

@@ -1,4 +1,5 @@
export interface CreateAjusteDto {
fechaAjuste: string;
idSuscriptor: number;
tipoAjuste: 'Credito' | 'Debito';
monto: number;

View File

@@ -1,7 +1,9 @@
export interface CreatePromocionDto {
descripcion: string;
tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias';
valor: number;
tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia';
valorEfecto: number;
tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes';
valorCondicion?: number | null;
fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null;
activa: boolean;

View File

@@ -4,6 +4,6 @@ export interface CreateSuscripcionDto {
fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null;
estado: 'Activa' | 'Pausada' | 'Cancelada';
diasEntrega: string[]; // ["L", "M", "X"]
diasEntrega: string[]; // ["Lun", "Mar", "Mie"]
observaciones?: string | null;
}

View File

@@ -1,14 +1,20 @@
export interface FacturaDetalleDto {
descripcion: string;
importeNeto: number;
}
export interface FacturaDto {
idFactura: number;
idSuscripcion: number;
periodo: string; // "YYYY-MM"
fechaEmision: string; // "yyyy-MM-dd"
fechaVencimiento: string; // "yyyy-MM-dd"
idSuscriptor: number;
periodo: string;
fechaEmision: string;
fechaVencimiento: string;
importeFinal: number;
estado: string;
totalPagado: number;
saldoPendiente: number;
estadoPago: string;
estadoFacturacion: string;
numeroFactura?: string | null;
// Datos enriquecidos para la UI
nombreSuscriptor: string;
nombrePublicacion: string;
detalles: FacturaDetalleDto[]; // <-- AÑADIR ESTA LÍNEA
}

View File

@@ -0,0 +1,6 @@
import { type PromocionDto } from "./PromocionDto";
export interface PromocionAsignadaDto extends PromocionDto {
vigenciaDesdeAsignacion: string; // "yyyy-MM-dd"
vigenciaHastaAsignacion?: string | null;
}

View File

@@ -1,8 +1,10 @@
export interface PromocionDto {
idPromocion: number;
descripcion: string;
tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias';
valor: number;
tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia';
valorEfecto: number;
tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes';
valorCondicion?: number | null;
fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null;
activa: boolean;

View File

@@ -0,0 +1,27 @@
// DTO para el detalle de cada línea dentro de una factura (cada suscripción)
export interface FacturaDetalleDto {
descripcion: string;
importeNeto: number;
}
// DTO para cada factura individual (por empresa) dentro del resumen consolidado
export interface FacturaConsolidadaDto {
idFactura: number;
nombreEmpresa: string;
importeFinal: number;
estadoPago: string;
estadoFacturacion: string;
numeroFactura?: string | null;
detalles: FacturaDetalleDto[];
// Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers
idSuscriptor: number;
}
// DTO principal que agrupa todo por suscriptor para la vista de consulta
export interface ResumenCuentaSuscriptorDto {
idSuscriptor: number;
nombreSuscriptor: string;
saldoPendienteTotal: number;
importeTotal: number;
facturas: FacturaConsolidadaDto[];
}

View File

@@ -6,6 +6,6 @@ export interface SuscripcionDto {
fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null;
estado: 'Activa' | 'Pausada' | 'Cancelada';
diasEntrega: string; // "L,M,X,J,V,S,D"
diasEntrega: string; // "Lun,Mar,Mie,Jue,Vie,Sab,Dom"
observaciones?: string | null;
}

View File

@@ -0,0 +1,6 @@
export interface UpdateAjusteDto {
fechaAjuste: string; // "yyyy-MM-dd"
tipoAjuste: 'Credito' | 'Debito';
monto: number;
motivo: string;
}

View File

@@ -0,0 +1,70 @@
// Archivo: Frontend/src/pages/Reportes/ReporteFacturasPublicidadPage.tsx
import React, { useState } from 'react';
import { Box, Alert, Paper } from '@mui/material';
import reporteService from '../../services/Reportes/reportesService';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import SeleccionaReporteFacturasPublicidad from './SeleccionaReporteFacturasPublicidad';
const ReporteFacturasPublicidadPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR010");
const handleGenerateReport = async (params: { anio: number; mes: number; }) => {
setLoading(true);
setApiError(null);
try {
const { fileContent, fileName } = await reporteService.getReporteFacturasPublicidadPdf(params.anio, params.mes);
const url = window.URL.createObjectURL(new Blob([fileContent], { type: 'application/pdf' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err: any) {
let message = 'Ocurrió un error al generar el reporte.';
if (axios.isAxiosError(err) && err.response) {
if (err.response.status === 404) {
message = "No se encontraron datos para los parámetros seleccionados.";
} else if (err.response.data instanceof Blob && err.response.data.type === "application/json") {
const errorText = await err.response.data.text();
try {
const errorJson = JSON.parse(errorText);
message = errorJson.message || message;
} catch {
message = errorText || message;
}
}
}
setApiError(message);
} finally {
setLoading(false);
}
};
if (!puedeVerReporte) {
return <Alert severity="error">No tiene permiso para ver este reporte.</Alert>;
}
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', pt: 4 }}>
<Paper elevation={3} sx={{ borderRadius: '8px' }}>
<SeleccionaReporteFacturasPublicidad
onGenerarReporte={handleGenerateReport}
isLoading={loading}
apiErrorMessage={apiError}
/>
</Paper>
</Box>
);
};
export default ReporteFacturasPublicidadPage;

View File

@@ -23,6 +23,7 @@ const allReportModules: { category: string; label: string; path: string }[] = [
{ 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' },
{ category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' },
];
const predefinedCategoryOrder = [
@@ -30,6 +31,7 @@ const predefinedCategoryOrder = [
'Listados Distribución',
'Ctrl. Devoluciones',
'Novedades de Canillitas',
'Suscripciones',
'Existencia Papel',
'Movimientos Bobinas',
'Consumos Bobinas',

View File

@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent
} from '@mui/material';
// --- Constantes para los selectores de fecha ---
const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
const meses = [
{ value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' },
{ value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' },
{ value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' },
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
];
interface SeleccionaReporteFacturasPublicidadProps {
onGenerarReporte: (params: { anio: number; mes: number; }) => Promise<void>;
isLoading?: boolean;
apiErrorMessage?: string | null;
}
const SeleccionaReporteFacturasPublicidad: React.FC<SeleccionaReporteFacturasPublicidadProps> = ({
onGenerarReporte,
isLoading,
apiErrorMessage
}) => {
const [anio, setAnio] = useState<number>(new Date().getFullYear());
const [mes, setMes] = useState<number>(new Date().getMonth() + 1);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!anio) errors.anio = 'Debe seleccionar un año.';
if (!mes) errors.mes = 'Debe seleccionar un mes.';
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleGenerar = () => {
if (!validate()) return;
onGenerarReporte({ anio, mes });
};
return (
<Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 300 }}>
<Typography variant="h6" gutterBottom>
Parámetros: Reporte de Facturas para Publicidad
</Typography>
<Typography variant="body2" color="text.secondary" sx={{mb: 2}}>
Seleccione el período para generar el reporte.
<br />
Se incluirán todas las suscripciones pagadas que aún están pendientes de facturar.
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
<FormControl fullWidth margin="normal" size="small" error={!!localErrors.mes} disabled={isLoading}>
<InputLabel>Mes</InputLabel>
<Select value={mes} label="Mes" onChange={(e: SelectChangeEvent<number>) => setMes(e.target.value as number)}>
{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}
</Select>
</FormControl>
<FormControl fullWidth margin="normal" size="small" error={!!localErrors.anio} disabled={isLoading}>
<InputLabel>Año</InputLabel>
<Select value={anio} label="Año" onChange={(e: SelectChangeEvent<number>) => setAnio(e.target.value as number)}>
{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}
</Select>
</FormControl>
</Box>
{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 SeleccionaReporteFacturasPublicidad;

View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Typography, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText, Collapse, TextField } from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import PaymentIcon from '@mui/icons-material/Payment';
import EmailIcon from '@mui/icons-material/Email';
import EditNoteIcon from '@mui/icons-material/EditNote';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import facturacionService from '../../services/Suscripciones/facturacionService';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import type { ResumenCuentaSuscriptorDto, FacturaConsolidadaDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal';
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
const meses = [
{ value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' },
{ value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' },
{ value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' },
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
];
const estadosPago = ['Pendiente', 'Pagada', 'Rechazada', 'Anulada'];
const estadosFacturacion = ['Pendiente de Facturar', 'Facturado'];
const SuscriptorRow: React.FC<{
resumen: ResumenCuentaSuscriptorDto;
handleMenuOpen: (event: React.MouseEvent<HTMLElement>, factura: FacturaConsolidadaDto) => void;
}> = ({ resumen, handleMenuOpen }) => {
const [open, setOpen] = useState(false);
return (
<React.Fragment>
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover>
<TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
<TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell>
<TableCell align="right">
<Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>${resumen.saldoPendienteTotal.toFixed(2)}</Typography>
<Typography variant="caption" color="text.secondary">de ${resumen.importeTotal.toFixed(2)}</Typography>
</TableCell>
{/* La cabecera principal ya no tiene acciones */}
<TableCell colSpan={5}></TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}>
<Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Empresa</TableCell><TableCell align="right">Importe</TableCell>
<TableCell>Estado Pago</TableCell><TableCell>Estado Facturación</TableCell>
<TableCell>Nro. Factura</TableCell><TableCell align="right">Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{resumen.facturas.map((factura) => (
<TableRow key={factura.idFactura}>
<TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell>
<TableCell align="right">${factura.importeFinal.toFixed(2)}</TableCell>
<TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default')} /></TableCell>
<TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell>
<TableCell>{factura.numeroFactura || '-'}</TableCell>
<TableCell align="right">
{/* El menú de acciones vuelve a estar aquí, por factura */}
<IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}>
<MoreVertIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
};
const ConsultaFacturasPage: React.FC = () => {
const [selectedAnio, setSelectedAnio] = useState<number>(new Date().getFullYear());
const [selectedMes, setSelectedMes] = useState<number>(new Date().getMonth() + 1);
const [loading, setLoading] = useState(true);
const [apiMessage, setApiMessage] = useState<string | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [resumenes, setResumenes] = useState<ResumenCuentaSuscriptorDto[]>([]);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeConsultar = isSuperAdmin || tienePermiso("SU006");
const puedeGestionarFactura = isSuperAdmin || tienePermiso("SU006");
const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008");
const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009");
const [pagoModalOpen, setPagoModalOpen] = useState(false);
const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [filtroNombre, setFiltroNombre] = useState('');
const [filtroEstadoPago, setFiltroEstadoPago] = useState('');
const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState('');
const cargarResumenesDelPeriodo = useCallback(async () => {
if (!puedeConsultar) return;
setLoading(true);
setApiError(null);
try {
const data = await facturacionService.getResumenesDeCuentaPorPeriodo(
selectedAnio,
selectedMes,
filtroNombre || undefined,
filtroEstadoPago || undefined,
filtroEstadoFacturacion || undefined
);
setResumenes(data);
} catch (err) {
setResumenes([]);
setApiError("Error al cargar los resúmenes de cuenta del período.");
} finally {
setLoading(false);
}
}, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]);
useEffect(() => {
// Ejecutar la búsqueda cuando los filtros cambian
const timer = setTimeout(() => {
cargarResumenesDelPeriodo();
}, 500); // Debounce para no buscar en cada tecla
return () => clearTimeout(timer);
}, [cargarResumenesDelPeriodo]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, factura: FacturaConsolidadaDto) => {
setAnchorEl(event.currentTarget);
setSelectedFactura(factura);
};
const handleMenuClose = () => { setAnchorEl(null); };
const handleOpenPagoModal = () => { setPagoModalOpen(true); handleMenuClose(); };
const handleClosePagoModal = () => { setPagoModalOpen(false); setSelectedFactura(null); };
const handleSubmitPagoModal = async (data: CreatePagoDto) => {
setApiError(null);
try {
await facturacionService.registrarPagoManual(data);
setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`);
cargarResumenesDelPeriodo();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.';
setApiError(message);
throw err;
}
};
const handleUpdateNumeroFactura = async (factura: FacturaConsolidadaDto) => {
const nuevoNumero = prompt("Ingrese el número de factura (ARCA):", factura.numeroFactura || "");
handleMenuClose();
if (nuevoNumero !== null && nuevoNumero.trim() !== "") {
setApiError(null);
try {
await facturacionService.actualizarNumeroFactura(factura.idFactura, nuevoNumero.trim());
setApiMessage(`Número de factura #${factura.idFactura} actualizado.`);
cargarResumenesDelPeriodo();
} catch (err: any) {
setApiError(err.response?.data?.message || 'Error al actualizar el número de factura.');
}
}
};
const handleSendEmail = async (idFactura: number) => {
if (!window.confirm(`¿Está seguro de enviar la factura #${idFactura} por email? Se adjuntará el PDF si se encuentra.`)) return;
setApiMessage(null);
setApiError(null);
try {
await facturacionService.enviarFacturaPdfPorEmail(idFactura);
setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`);
} catch (err: any) {
setApiError(err.response?.data?.message || 'Error al intentar enviar el email.');
} finally {
handleMenuClose();
}
};
if (!puedeConsultar) return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Consulta de Facturas de Suscripciones</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6">Filtros</Typography>
<Box sx={{ display: 'flex', gap: 2, my: 2, alignItems: 'center' }}>
<FormControl sx={{ minWidth: 150 }} size="small"><InputLabel>Mes</InputLabel><Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select></FormControl>
<FormControl sx={{ minWidth: 120 }} size="small"><InputLabel>Año</InputLabel><Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select></FormControl>
<TextField
label="Buscar por Suscriptor"
size="small"
value={filtroNombre}
onChange={(e) => setFiltroNombre(e.target.value)}
sx={{flexGrow: 1, minWidth: '200px'}}
/>
<FormControl sx={{ minWidth: 200 }} size="small">
<InputLabel>Estado de Pago</InputLabel>
<Select value={filtroEstadoPago} label="Estado de Pago" onChange={(e) => setFiltroEstadoPago(e.target.value)}>
<MenuItem value=""><em>Todos</em></MenuItem>
{estadosPago.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}
</Select>
</FormControl>
<FormControl sx={{ minWidth: 200 }} size="small">
<InputLabel>Estado de Facturación</InputLabel>
<Select value={filtroEstadoFacturacion} label="Estado de Facturación" onChange={(e) => setFiltroEstadoFacturacion(e.target.value)}>
<MenuItem value=""><em>Todos</em></MenuItem>
{estadosFacturacion.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}
</Select>
</FormControl>
</Box>
</Paper>
{apiError && <Alert severity="error" sx={{ my: 2 }}>{apiError}</Alert>}
{apiMessage && <Alert severity="success" sx={{ my: 2 }}>{apiMessage}</Alert>}
<TableContainer component={Paper}>
<Table aria-label="collapsible table">
<TableHead>
<TableRow>
<TableCell />
<TableCell>Suscriptor</TableCell>
<TableCell align="right">Saldo Total / Importe Total</TableCell>
<TableCell colSpan={5}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>)
: resumenes.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>)
: (resumenes.map(resumen => (<SuscriptorRow key={resumen.idSuscriptor} resumen={resumen} handleMenuOpen={handleMenuOpen} />)))}
</TableBody>
</Table>
</TableContainer>
{/* El menú de acciones ahora opera sobre la 'selectedFactura' */}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{selectedFactura && puedeRegistrarPago && (<MenuItem onClick={handleOpenPagoModal} disabled={selectedFactura.estadoPago === 'Pagada' || selectedFactura.estadoPago === 'Anulada'}><ListItemIcon><PaymentIcon fontSize="small" /></ListItemIcon><ListItemText>Registrar Pago Manual</ListItemText></MenuItem>)}
{selectedFactura && puedeGestionarFactura && (<MenuItem onClick={() => handleUpdateNumeroFactura(selectedFactura)} disabled={selectedFactura.estadoPago === 'Anulada'}><ListItemIcon><EditNoteIcon fontSize="small" /></ListItemIcon><ListItemText>Cargar/Modificar Nro. Factura</ListItemText></MenuItem>)}
{selectedFactura && puedeEnviarEmail && (
<MenuItem
onClick={() => handleSendEmail(selectedFactura.idFactura)}
disabled={!selectedFactura.numeroFactura || selectedFactura.estadoPago === 'Anulada'}>
<ListItemIcon><EmailIcon fontSize="small" /></ListItemIcon>
<ListItemText>Enviar Factura (PDF)</ListItemText>
</MenuItem>
)}
</Menu>
<PagoManualModal
open={pagoModalOpen}
onClose={handleClosePagoModal}
onSubmit={handleSubmitPagoModal}
factura={
selectedFactura ? {
idFactura: selectedFactura.idFactura,
nombreSuscriptor: resumenes.find(r => r.idSuscriptor === selectedFactura.idSuscriptor)?.nombreSuscriptor || '',
importeFinal: selectedFactura.importeFinal,
// Calculamos el saldo pendiente aquí
saldoPendiente: selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal, // Simplificación
// Rellenamos los campos restantes que el modal podría necesitar, aunque no los use.
idSuscriptor: selectedFactura.idSuscriptor, // Corregido para coincidir con FacturaDto
periodo: '',
fechaEmision: '',
fechaVencimiento: '',
totalPagado: selectedFactura.importeFinal - (selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal),
estadoPago: selectedFactura.estadoPago,
estadoFacturacion: selectedFactura.estadoFacturacion,
numeroFactura: selectedFactura.numeroFactura,
detalles: selectedFactura.detalles,
} : null
}
errorMessage={apiError}
clearErrorMessage={() => setApiError(null)} />
</Box>
);
};
export default ConsultaFacturasPage;

View File

@@ -0,0 +1,241 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, Tooltip, IconButton, TextField } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import EditIcon from '@mui/icons-material/Edit';
import CancelIcon from '@mui/icons-material/Cancel';
import ajusteService from '../../services/Suscripciones/ajusteService';
import suscriptorService from '../../services/Suscripciones/suscriptorService';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto';
import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto';
import type { UpdateAjusteDto } from '../../models/dtos/Suscripciones/UpdateAjusteDto';
import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto';
import AjusteFormModal from '../../components/Modals/Suscripciones/AjusteFormModal';
const getInitialDateRange = () => {
const today = new Date();
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const formatDate = (date: Date) => date.toISOString().split('T')[0];
return {
fechaDesde: formatDate(firstDay),
fechaHasta: formatDate(lastDay)
};
};
const CuentaCorrienteSuscriptorPage: React.FC = () => {
const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>();
const navigate = useNavigate();
const idSuscriptor = Number(idSuscriptorStr);
const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null);
const [ajustes, setAjustes] = useState<AjusteDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingAjuste, setEditingAjuste] = useState<AjusteDto | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(getInitialDateRange().fechaDesde);
const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(getInitialDateRange().fechaHasta);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGestionar = isSuperAdmin || tienePermiso("SU011");
const cargarDatos = useCallback(async () => {
if (isNaN(idSuscriptor)) {
setError("ID de Suscriptor inválido."); setLoading(false); return;
}
setLoading(true); setApiErrorMessage(null); setError(null);
try {
const suscriptorData = await suscriptorService.getSuscriptorById(idSuscriptor);
setSuscriptor(suscriptorData);
const ajustesData = await ajusteService.getAjustesPorSuscriptor(idSuscriptor, filtroFechaDesde || undefined, filtroFechaHasta || undefined);
setAjustes(ajustesData);
} catch (err) {
setError("Error al cargar los datos.");
} finally {
setLoading(false);
}
}, [idSuscriptor, puedeGestionar, filtroFechaDesde, filtroFechaHasta]);
useEffect(() => { cargarDatos(); }, [cargarDatos]);
// --- INICIO DE LA LÓGICA DE SINCRONIZACIÓN DE FECHAS ---
const handleFechaDesdeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const nuevaFechaDesde = e.target.value;
setFiltroFechaDesde(nuevaFechaDesde);
// Si la nueva fecha "desde" es posterior a la fecha "hasta", ajusta la fecha "hasta"
if (nuevaFechaDesde && filtroFechaHasta && new Date(nuevaFechaDesde) > new Date(filtroFechaHasta)) {
setFiltroFechaHasta(nuevaFechaDesde);
}
};
const handleFechaHastaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const nuevaFechaHasta = e.target.value;
setFiltroFechaHasta(nuevaFechaHasta);
// Si la nueva fecha "hasta" es anterior a la fecha "desde", ajusta la fecha "desde"
if (nuevaFechaHasta && filtroFechaDesde && new Date(nuevaFechaHasta) < new Date(filtroFechaDesde)) {
setFiltroFechaDesde(nuevaFechaHasta);
}
};
const handleOpenModal = (ajuste?: AjusteDto) => {
setEditingAjuste(ajuste || null);
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
setEditingAjuste(null);
};
const handleSubmitModal = async (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => {
setApiErrorMessage(null);
try {
if (id && editingAjuste) {
await ajusteService.updateAjuste(id, data as UpdateAjusteDto);
} else {
await ajusteService.createAjusteManual(data as CreateAjusteDto);
}
cargarDatos();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el ajuste.';
setApiErrorMessage(message);
throw err;
}
};
const handleAnularAjuste = async (idAjuste: number) => {
if (window.confirm("¿Está seguro de que desea anular este ajuste? Esta acción no se puede deshacer.")) {
setApiErrorMessage(null);
try {
await ajusteService.anularAjuste(idAjuste);
cargarDatos();
} catch (err: any) {
setApiErrorMessage(err.response?.data?.message || "Error al anular el ajuste.");
}
}
};
const formatDisplayDate = (dateString: string): string => {
if (!dateString) return '';
const datePart = dateString.split(' ')[0];
const parts = datePart.split('-');
if (parts.length === 3) {
return `${parts[2]}/${parts[1]}/${parts[0]}`;
}
return dateString;
};
if (loading && !suscriptor) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
if (error) return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>;
return (
<Box>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}>
Volver a Suscriptores
</Button>
<Typography variant="h5" gutterBottom>Cuenta Corriente de:</Typography>
<Typography variant="h4" color="primary" gutterBottom>{suscriptor?.nombreCompleto || ''}</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
label="Fecha Desde"
type="date"
size="small"
value={filtroFechaDesde}
onChange={handleFechaDesdeChange} // <-- USAR NUEVO HANDLER
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Fecha Hasta"
type="date"
size="small"
value={filtroFechaHasta}
onChange={handleFechaHastaChange} // <-- USAR NUEVO HANDLER
InputLabelProps={{ shrink: true }}
/>
</Box>
{puedeGestionar && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mt: { xs: 2, sm: 0 } }}>
Nuevo Ajuste
</Button>
)}
</Box>
</Paper>
{apiErrorMessage && <Alert severity="error" sx={{ mb: 2 }}>{apiErrorMessage}</Alert>}
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fecha Ajuste</TableCell>
<TableCell>Tipo</TableCell>
<TableCell>Motivo</TableCell>
<TableCell align="right">Monto</TableCell>
<TableCell>Estado</TableCell>
<TableCell>Usuario Carga</TableCell>
<TableCell align="right">Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={7} align="center"><CircularProgress size={24} /></TableCell></TableRow>
) : ajustes.length === 0 ? (
<TableRow><TableCell colSpan={7} align="center">No se encontraron ajustes para los filtros seleccionados.</TableCell></TableRow>
) : (
ajustes.map(a => (
<TableRow key={a.idAjuste} sx={{ '& .MuiTableCell-root': { color: a.estado === 'Anulado' ? 'text.disabled' : 'inherit' }, textDecoration: a.estado === 'Anulado' ? 'line-through' : 'none' }}>
<TableCell>{formatDisplayDate(a.fechaAjuste)}</TableCell>
<TableCell>
<Chip label={a.tipoAjuste} size="small" color={a.tipoAjuste === 'Credito' ? 'success' : 'error'} />
</TableCell>
<TableCell>{a.motivo}</TableCell>
<TableCell align="right">${a.monto.toFixed(2)}</TableCell>
<TableCell>{a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''}</TableCell>
<TableCell>{a.nombreUsuarioAlta}</TableCell>
<TableCell align="right">
{a.estado === 'Pendiente' && puedeGestionar && (
<>
<Tooltip title="Editar Ajuste">
<IconButton onClick={() => handleOpenModal(a)} size="small">
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Anular Ajuste">
<IconButton onClick={() => handleAnularAjuste(a.idAjuste)} size="small">
<CancelIcon color="error" />
</IconButton>
</Tooltip>
</>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<AjusteFormModal
open={modalOpen}
onClose={handleCloseModal}
onSubmit={handleSubmitModal}
idSuscriptor={idSuscriptor}
initialData={editingAjuste}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
</Box>
);
};
export default CuentaCorrienteSuscriptorPage;

View File

@@ -1,125 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Tooltip } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import ajusteService from '../../services/Suscripciones/ajusteService';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto';
import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto';
import AjusteFormModal from '../../components/Modals/Suscripciones/AjusteFormModal';
import CancelIcon from '@mui/icons-material/Cancel';
interface CuentaCorrienteSuscriptorTabProps {
idSuscriptor: number;
}
const CuentaCorrienteSuscriptorTab: React.FC<CuentaCorrienteSuscriptorTabProps> = ({ idSuscriptor }) => {
const [ajustes, setAjustes] = useState<AjusteDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGestionar = isSuperAdmin || tienePermiso("SU011");
const cargarDatos = useCallback(async () => {
if (!puedeGestionar) {
setError("No tiene permiso para ver la cuenta corriente."); setLoading(false); return;
}
setLoading(true); setApiErrorMessage(null);
try {
const data = await ajusteService.getAjustesPorSuscriptor(idSuscriptor);
setAjustes(data);
} catch (err) {
setError("Error al cargar los ajustes del suscriptor.");
} finally {
setLoading(false);
}
}, [idSuscriptor, puedeGestionar]);
useEffect(() => {
cargarDatos();
}, [cargarDatos]);
const handleSubmitModal = async (data: CreateAjusteDto) => {
setApiErrorMessage(null);
try {
await ajusteService.createAjusteManual(data);
cargarDatos();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el ajuste.';
setApiErrorMessage(message);
throw err;
}
};
const handleAnularAjuste = async (idAjuste: number) => {
if (window.confirm("¿Está seguro de que desea anular este ajuste? Esta acción no se puede deshacer.")) {
setApiErrorMessage(null);
try {
await ajusteService.anularAjuste(idAjuste);
cargarDatos(); // Recargar para ver el cambio de estado
} catch (err: any) {
setApiErrorMessage(err.response?.data?.message || "Error al anular el ajuste.");
}
}
};
if (loading) return <CircularProgress />;
if (error) return <Alert severity="error">{error}</Alert>;
return (
<Box>
<Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">Historial de Ajustes</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setModalOpen(true)} disabled={!puedeGestionar}>
Nuevo Ajuste
</Button>
</Paper>
{apiErrorMessage && <Alert severity="error" sx={{ mb: 2 }}>{apiErrorMessage}</Alert>}
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell>Fecha</TableCell><TableCell>Tipo</TableCell><TableCell>Motivo</TableCell>
<TableCell align="right">Monto</TableCell><TableCell>Estado</TableCell><TableCell>Usuario</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell>
</TableRow></TableHead>
<TableBody>
{ajustes.map(a => (
<TableRow key={a.idAjuste} sx={{ '& .MuiTableCell-root': { color: a.estado === 'Anulado' ? 'text.disabled' : 'inherit' }, textDecoration: a.estado === 'Anulado' ? 'line-through' : 'none' }}>
<TableCell>{a.fechaAlta}</TableCell>
<TableCell>
<Chip label={a.tipoAjuste} size="small" color={a.tipoAjuste === 'Credito' ? 'success' : 'error'} />
</TableCell>
<TableCell>{a.motivo}</TableCell>
<TableCell align="right">${a.monto.toFixed(2)}</TableCell>
<TableCell>{a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''}</TableCell>
<TableCell>{a.nombreUsuarioAlta}</TableCell>
<TableCell align="right">
{a.estado === 'Pendiente' && puedeGestionar && (
<Tooltip title="Anular Ajuste">
<IconButton onClick={() => handleAnularAjuste(a.idAjuste)} size="small">
<CancelIcon color="error" />
</IconButton>
</Tooltip>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<AjusteFormModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSubmit={handleSubmitModal}
idSuscriptor={idSuscriptor}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
</Box>
);
};
export default CuentaCorrienteSuscriptorTab;

View File

@@ -1,18 +1,12 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText } from '@mui/material';
import React, { useState } from 'react';
import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import DownloadIcon from '@mui/icons-material/Download';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import PaymentIcon from '@mui/icons-material/Payment';
import EmailIcon from '@mui/icons-material/Email';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { styled } from '@mui/material/styles';
import facturacionService from '../../services/Suscripciones/facturacionService';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto';
import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal';
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
const meses = [
@@ -35,37 +29,14 @@ const FacturacionPage: React.FC = () => {
const [loadingProceso, setLoadingProceso] = useState(false);
const [apiMessage, setApiMessage] = useState<string | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [facturas, setFacturas] = useState<FacturaDto[]>([]);
const [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006");
const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007");
const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008");
const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009");
const [pagoModalOpen, setPagoModalOpen] = useState(false);
const [selectedFactura, setSelectedFactura] = useState<FacturaDto | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null);
const cargarFacturasDelPeriodo = useCallback(async () => {
if (!puedeGenerarFacturacion) return;
setLoading(true);
try {
const data = await facturacionService.getFacturasPorPeriodo(selectedAnio, selectedMes);
setFacturas(data);
} catch (err) {
setFacturas([]);
console.error(err);
} finally {
setLoading(false);
}
}, [selectedAnio, selectedMes, puedeGenerarFacturacion]);
useEffect(() => {
cargarFacturasDelPeriodo();
}, [cargarFacturasDelPeriodo]);
const handleGenerarFacturacion = async () => {
if (!window.confirm(`¿Está seguro de que desea generar la facturación para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Este proceso creará registros de cobro para todas las suscripciones activas.`)) {
if (!window.confirm(`¿Está seguro de generar el cierre para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Se aplicarán los ajustes pendientes del mes anterior y se generarán los nuevos importes a cobrar.`)) {
return;
}
setLoading(true);
@@ -74,7 +45,6 @@ const FacturacionPage: React.FC = () => {
try {
const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes);
setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`);
await cargarFacturasDelPeriodo();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
@@ -103,7 +73,6 @@ const FacturacionPage: React.FC = () => {
link.parentNode?.removeChild(link);
window.URL.revokeObjectURL(url);
setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`);
cargarFacturasDelPeriodo();
} catch (err: any) {
let message = 'Ocurrió un error al generar el archivo.';
if (axios.isAxiosError(err) && err.response) {
@@ -119,52 +88,6 @@ const FacturacionPage: React.FC = () => {
}
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, factura: FacturaDto) => {
setAnchorEl(event.currentTarget);
setSelectedFactura(factura);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedFactura(null);
};
const handleOpenPagoModal = () => {
setPagoModalOpen(true);
handleMenuClose();
};
const handleClosePagoModal = () => {
setPagoModalOpen(false);
};
const handleSubmitPagoModal = async (data: CreatePagoDto) => {
setApiError(null);
try {
await facturacionService.registrarPagoManual(data);
setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`);
cargarFacturasDelPeriodo();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.';
setApiError(message);
throw err;
}
};
const handleSendEmail = async (idFactura: number) => {
if (!window.confirm(`¿Está seguro de enviar la notificación de la factura #${idFactura} por email?`)) return;
setApiMessage(null);
setApiError(null);
try {
await facturacionService.enviarFacturaPorEmail(idFactura);
setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`);
} catch (err: any) {
setApiError(err.response?.data?.message || 'Error al intentar enviar el email.');
} finally {
handleMenuClose();
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setArchivoSeleccionado(event.target.files[0]);
@@ -187,7 +110,6 @@ const FacturacionPage: React.FC = () => {
if (response.errores?.length > 0) {
setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`);
}
cargarFacturasDelPeriodo(); // Recargar para ver los estados finales
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen
? err.response.data.mensajeResumen
@@ -203,15 +125,20 @@ const FacturacionPage: React.FC = () => {
return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
}
if (!puedeGenerarFacturacion) {
return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Facturación y Débito Automático</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6">1. Generación de Facturación</Typography>
<Typography variant="h5" gutterBottom>Procesos Mensuales de Suscripciones</Typography>
<Paper sx={{ p: 1, mb: 1 }}>
<Typography variant="h6">1. Generación de Cierre Mensual</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Este proceso calcula los importes a cobrar para todas las suscripciones activas en el período seleccionado.
Este proceso calcula los importes a cobrar y envía automáticamente una notificación de "Aviso de Vencimiento" a cada suscriptor.
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1, mb: 1, alignItems: 'center' }}>
<FormControl sx={{ minWidth: 120 }} size="small">
<InputLabel>Mes</InputLabel>
<Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select>
@@ -221,20 +148,23 @@ const FacturacionPage: React.FC = () => {
<Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select>
</FormControl>
</Box>
<Button variant="contained" color="primary" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} onClick={handleGenerarFacturacion} disabled={loading || loadingArchivo}>Generar Facturación del Período</Button>
<Button variant="contained" color="primary" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} onClick={handleGenerarFacturacion} disabled={loading || loadingArchivo || loadingProceso}>
Generar Cierre del Período
</Button>
</Paper>
<Paper sx={{ p: 2, mb: 2 }}>
<Paper sx={{ p: 1, mb: 1 }}>
<Typography variant="h6">2. Generación de Archivo para Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro.</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro.</Typography>
<Button variant="contained" color="secondary" startIcon={loadingArchivo ? <CircularProgress size={20} color="inherit" /> : <DownloadIcon />} onClick={handleGenerarArchivo} disabled={loading || loadingArchivo || !puedeGenerarArchivo}>Generar Archivo de Débito</Button>
</Paper>
<Paper sx={{ p: 2, mb: 2 }}>
<Paper sx={{ p: 1, mb: 1 }}>
<Typography variant="h6">3. Procesar Respuesta del Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada".
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button
component="label"
role={undefined}
@@ -262,58 +192,8 @@ const FacturacionPage: React.FC = () => {
)}
</Paper>
{apiError && <Alert severity="error" sx={{ my: 2 }}>{apiError}</Alert>}
{apiMessage && <Alert severity="success" sx={{ my: 2 }}>{apiMessage}</Alert>}
<Typography variant="h6" sx={{ mt: 4 }}>Facturas del Período</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell><TableCell>Suscriptor</TableCell><TableCell>Publicación</TableCell>
<TableCell align="right">Importe</TableCell><TableCell>Estado</TableCell><TableCell>Nro. Factura</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (<TableRow><TableCell colSpan={7} align="center"><CircularProgress /></TableCell></TableRow>)
: facturas.length === 0 ? (<TableRow><TableCell colSpan={7} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>)
: (facturas.map(f => (
<TableRow key={f.idFactura} hover>
<TableCell>{f.idFactura}</TableCell>
<TableCell>{f.nombreSuscriptor}</TableCell>
<TableCell>{f.nombrePublicacion}</TableCell>
<TableCell align="right">${f.importeFinal.toFixed(2)}</TableCell>
<TableCell><Chip label={f.estado} size="small" color={f.estado === 'Pagada' ? 'success' : (f.estado === 'Rechazada' ? 'error' : 'default')} /></TableCell>
<TableCell>{f.numeroFactura || '-'}</TableCell>
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, f)} disabled={f.estado === 'Pagada' || f.estado === 'Anulada'}>
<MoreVertIcon />
</IconButton>
</TableCell>
</TableRow>
)))}
</TableBody>
</Table>
</TableContainer>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{selectedFactura && puedeRegistrarPago && (
<MenuItem onClick={handleOpenPagoModal}>
<ListItemIcon><PaymentIcon fontSize="small" /></ListItemIcon>
<ListItemText>Registrar Pago Manual</ListItemText>
</MenuItem>
)}
{selectedFactura && puedeEnviarEmail && (
<MenuItem
onClick={() => handleSendEmail(selectedFactura.idFactura)}
disabled={!selectedFactura.numeroFactura}
>
<ListItemIcon><EmailIcon fontSize="small" /></ListItemIcon>
<ListItemText>Enviar Email</ListItemText>
</MenuItem>
)}
</Menu>
<PagoManualModal open={pagoModalOpen} onClose={handleClosePagoModal} onSubmit={handleSubmitPagoModal} factura={selectedFactura} errorMessage={apiError} clearErrorMessage={() => setApiError(null)} />
{apiError && <Alert severity="error" sx={{ my: 1 }}>{apiError}</Alert>}
{apiMessage && <Alert severity="success" sx={{ my: 1 }}>{apiMessage}</Alert>}
</Box>
);
};

View File

@@ -79,10 +79,13 @@ const GestionarPromocionesPage: React.FC = () => {
return `${parts[2]}/${parts[1]}/${parts[0]}`;
};
const formatTipo = (tipo: string) => {
if (tipo === 'MontoFijo') return 'Monto Fijo';
if (tipo === 'Porcentaje') return 'Porcentaje';
return tipo;
const formatTipo = (tipo: PromocionDto['tipoEfecto']) => {
switch(tipo) {
case 'DescuentoMontoFijoTotal': return 'Monto Fijo';
case 'DescuentoPorcentajeTotal': return 'Porcentaje';
case 'BonificarEntregaDia': return 'Día Bonificado';
default: return tipo;
}
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -106,7 +109,7 @@ const GestionarPromocionesPage: React.FC = () => {
<TableHead>
<TableRow>
<TableCell sx={{fontWeight: 'bold'}}>Descripción</TableCell>
<TableCell sx={{fontWeight: 'bold'}}>Tipo</TableCell>
<TableCell sx={{fontWeight: 'bold'}}>Efecto</TableCell>
<TableCell align="right" sx={{fontWeight: 'bold'}}>Valor</TableCell>
<TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell>
<TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell>
@@ -121,8 +124,12 @@ const GestionarPromocionesPage: React.FC = () => {
promociones.map(p => (
<TableRow key={p.idPromocion} hover>
<TableCell>{p.descripcion}</TableCell>
<TableCell>{formatTipo(p.tipoPromocion)}</TableCell>
<TableCell align="right">{p.tipoPromocion === 'Porcentaje' ? `${p.valor}%` : `$${p.valor.toFixed(2)}`}</TableCell>
<TableCell>{formatTipo(p.tipoEfecto)}</TableCell>
<TableCell align="right">
{p.tipoEfecto === 'DescuentoPorcentajeTotal' ? `${p.valorEfecto}%` :
p.tipoEfecto === 'DescuentoMontoFijoTotal' ? `$${p.valorEfecto.toFixed(2)}` :
'-'}
</TableCell>
<TableCell>{formatDate(p.fechaInicio)}</TableCell>
<TableCell>{formatDate(p.fechaFin)}</TableCell>
<TableCell align="center">
@@ -130,9 +137,11 @@ const GestionarPromocionesPage: React.FC = () => {
</TableCell>
<TableCell align="right">
<Tooltip title="Editar Promoción">
<IconButton onClick={() => handleOpenModal(p)}>
<EditIcon />
</IconButton>
<span>
<IconButton onClick={() => handleOpenModal(p)} disabled={!puedeGestionar}>
<EditIcon />
</IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>

View File

@@ -1,12 +1,16 @@
// Archivo: Frontend/src/pages/Suscripciones/SuscripcionesTab.tsx
// Archivo: Frontend/src/pages/Suscripciones/GestionarSuscripcionesDeClientePage.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Tooltip } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import LoyaltyIcon from '@mui/icons-material/Loyalty';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import suscripcionService from '../../services/Suscripciones/suscripcionService';
import suscriptorService from '../../services/Suscripciones/suscriptorService';
import type { SuscripcionDto } from '../../models/dtos/Suscripciones/SuscripcionDto';
import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto';
import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
import SuscripcionFormModal from '../../components/Modals/Suscripciones/SuscripcionFormModal';
@@ -14,11 +18,12 @@ import GestionarPromocionesSuscripcionModal from '../../components/Modals/Suscri
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
interface SuscripcionesTabProps {
idSuscriptor: number;
}
const GestionarSuscripcionesDeClientePage: React.FC = () => {
const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>();
const navigate = useNavigate();
const idSuscriptor = Number(idSuscriptorStr);
const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) => {
const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null);
const [suscripciones, setSuscripciones] = useState<SuscripcionDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -32,21 +37,25 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) =>
const puedeGestionar = isSuperAdmin || tienePermiso("SU005");
const cargarDatos = useCallback(async () => {
setLoading(true);
setApiErrorMessage(null);
if (isNaN(idSuscriptor)) {
setError("ID de Suscriptor inválido."); setLoading(false); return;
}
setLoading(true); setApiErrorMessage(null); setError(null);
try {
const data = await suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor);
setSuscripciones(data);
const [suscriptorData, suscripcionesData] = await Promise.all([
suscriptorService.getSuscriptorById(idSuscriptor),
suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor)
]);
setSuscriptor(suscriptorData);
setSuscripciones(suscripcionesData);
} catch (err) {
setError('Error al cargar las suscripciones del cliente.');
setError('Error al cargar los datos.');
} finally {
setLoading(false);
}
}, [idSuscriptor]);
useEffect(() => {
cargarDatos();
}, [cargarDatos]);
useEffect(() => { cargarDatos(); }, [cargarDatos]);
const handleOpenModal = (suscripcion?: SuscripcionDto) => {
setEditingSuscripcion(suscripcion || null);
@@ -86,13 +95,18 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) =>
return `${parts[2]}/${parts[1]}/${parts[0]}`;
};
if (loading) return <CircularProgress />;
if (error) return <Alert severity="error">{error}</Alert>;
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>;
return (
<Box>
<Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">Suscripciones Contratadas</Typography>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}>
Volver a Suscriptores
</Button>
<Typography variant="h5" gutterBottom>Gestionar Suscripciones de:</Typography>
<Typography variant="h4" color="primary" gutterBottom>{suscriptor?.nombreCompleto || ''}</Typography>
<Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
{puedeGestionar && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>
Nueva Suscripción
@@ -169,4 +183,4 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) =>
);
};
export default SuscripcionesTab;
export default GestionarSuscripcionesDeClientePage;

View File

@@ -1,83 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Box, Typography, Button, CircularProgress, Alert, Tabs, Tab } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import suscriptorService from '../../services/Suscripciones/suscriptorService';
import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto';
import { usePermissions } from '../../hooks/usePermissions';
import SuscripcionesTab from './SuscripcionesTab';
import CuentaCorrienteSuscriptorTab from './CuentaCorrienteSuscriptorTab';
const GestionarSuscripcionesSuscriptorPage: React.FC = () => {
const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>();
const navigate = useNavigate();
const idSuscriptor = Number(idSuscriptorStr);
const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tabValue, setTabValue] = useState(0);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVer = isSuperAdmin || tienePermiso("SU001");
const cargarSuscriptor = useCallback(async () => {
if (isNaN(idSuscriptor)) {
setError("ID de Suscriptor inválido."); setLoading(false); return;
}
if (!puedeVer) {
setError("No tiene permiso para ver esta sección."); setLoading(false); return;
}
setLoading(true);
try {
const suscriptorData = await suscriptorService.getSuscriptorById(idSuscriptor);
setSuscriptor(suscriptorData);
} catch (err) {
setError('Error al cargar los datos del suscriptor.');
} finally {
setLoading(false);
}
}, [idSuscriptor, puedeVer]);
useEffect(() => {
cargarSuscriptor();
}, [cargarSuscriptor]);
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>;
if (!puedeVer) return <Alert severity="error" sx={{m: 2}}>Acceso Denegado.</Alert>;
return (
<Box sx={{ p: 2 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}>
Volver a Suscriptores
</Button>
<Typography variant="h4" gutterBottom>{suscriptor?.nombreCompleto || 'Cargando...'}</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
Documento: {suscriptor?.tipoDocumento} {suscriptor?.nroDocumento} | Dirección: {suscriptor?.direccion}
</Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 3 }}>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab label="Suscripciones" />
<Tab label="Cuenta Corriente / Ajustes" />
</Tabs>
</Box>
<Box sx={{ pt: 2 }}>
{tabValue === 0 && (
<SuscripcionesTab idSuscriptor={idSuscriptor} />
)}
{tabValue === 1 && (
<CuentaCorrienteSuscriptorTab idSuscriptor={idSuscriptor} />
)}
</Box>
</Box>
);
};
export default GestionarSuscripcionesSuscriptorPage;

View File

@@ -1,5 +1,3 @@
// Archivo: Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx
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, ListItemIcon, ListItemText, FormControlLabel } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
@@ -16,188 +14,220 @@ import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import ArticleIcon from '@mui/icons-material/Article';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
const GestionarSuscriptoresPage: React.FC = () => {
const [suscriptores, setSuscriptores] = useState<SuscriptorDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filtroNombre, setFiltroNombre] = useState('');
const [filtroNroDoc, setFiltroNroDoc] = useState('');
const [filtroSoloActivos, setFiltroSoloActivos] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingSuscriptor, setEditingSuscriptor] = useState<SuscriptorDto | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(15);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedRow, setSelectedRow] = useState<SuscriptorDto | null>(null);
const [suscriptores, setSuscriptores] = useState<SuscriptorDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filtroNombre, setFiltroNombre] = useState('');
const [filtroNroDoc, setFiltroNroDoc] = useState('');
const [filtroSoloActivos, setFiltroSoloActivos] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editingSuscriptor, setEditingSuscriptor] = useState<SuscriptorDto | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(15);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedRow, setSelectedRow] = useState<SuscriptorDto | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const { tienePermiso, isSuperAdmin } = usePermissions();
const navigate = useNavigate();
const puedeVer = isSuperAdmin || tienePermiso("SU001");
const puedeCrear = isSuperAdmin || tienePermiso("SU002");
const puedeModificar = isSuperAdmin || tienePermiso("SU003");
const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004");
const puedeVer = isSuperAdmin || tienePermiso("SU001");
const puedeCrear = isSuperAdmin || tienePermiso("SU002");
const puedeModificar = isSuperAdmin || tienePermiso("SU003");
const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004");
const puedeVerSuscripciones = isSuperAdmin || tienePermiso("SU005");
const puedeVerCuentaCorriente = isSuperAdmin || tienePermiso("SU011");
const navigate = useNavigate();
const cargarSuscriptores = useCallback(async () => {
if (!puedeVer) {
setError("No tiene permiso para ver esta sección.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
setApiErrorMessage(null);
try {
const data = await suscriptorService.getAllSuscriptores(filtroNombre, filtroNroDoc, filtroSoloActivos);
setSuscriptores(data);
} catch (err) {
console.error(err);
setError('Error al cargar los suscriptores.');
} finally {
setLoading(false);
}
}, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]);
useEffect(() => {
cargarSuscriptores();
}, [cargarSuscriptores]);
const handleOpenModal = (suscriptor?: SuscriptorDto) => {
setEditingSuscriptor(suscriptor || null);
setApiErrorMessage(null);
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
setEditingSuscriptor(null);
};
const handleSubmitModal = async (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => {
setApiErrorMessage(null);
try {
if (id && editingSuscriptor) {
await suscriptorService.updateSuscriptor(id, data as UpdateSuscriptorDto);
} else {
await suscriptorService.createSuscriptor(data as CreateSuscriptorDto);
}
cargarSuscriptores();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Error al guardar el suscriptor.';
setApiErrorMessage(message);
throw err; // Re-lanzar para que el modal sepa que falló
}
};
const handleToggleActivo = async (suscriptor: SuscriptorDto) => {
const action = suscriptor.activo ? 'desactivar' : 'activar';
if (window.confirm(`¿Está seguro de que desea ${action} a ${suscriptor.nombreCompleto}?`)) {
setApiErrorMessage(null);
try {
if (suscriptor.activo) {
await suscriptorService.deactivateSuscriptor(suscriptor.idSuscriptor);
} else {
await suscriptorService.activateSuscriptor(suscriptor.idSuscriptor);
const cargarSuscriptores = useCallback(async () => {
if (!puedeVer) {
setError("No tiene permiso para ver esta sección.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
setApiErrorMessage(null);
try {
const data = await suscriptorService.getAllSuscriptores(filtroNombre, filtroNroDoc, filtroSoloActivos);
setSuscriptores(data);
} catch (err) {
console.error(err);
setError('Error al cargar los suscriptores.');
} finally {
setLoading(false);
}
}, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]);
useEffect(() => {
cargarSuscriptores();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`;
setApiErrorMessage(message);
}
}, [cargarSuscriptores]);
const handleOpenModal = (suscriptor?: SuscriptorDto) => {
setEditingSuscriptor(suscriptor || null);
setApiErrorMessage(null);
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
setEditingSuscriptor(null);
};
const handleSubmitModal = async (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => {
setApiErrorMessage(null);
try {
if (id && editingSuscriptor) {
await suscriptorService.updateSuscriptor(id, data as UpdateSuscriptorDto);
} else {
await suscriptorService.createSuscriptor(data as CreateSuscriptorDto);
}
cargarSuscriptores();
} catch (err: any) {
let message = 'Error al guardar el suscriptor.';
if (axios.isAxiosError(err) && err.response?.data?.errors) {
const validationErrors = err.response.data.errors;
const errorMessages = Object.values(validationErrors).flat();
message = errorMessages.join(' ');
} else if (axios.isAxiosError(err) && err.response?.data?.message) {
message = err.response.data.message;
}
setApiErrorMessage(message);
throw err;
}
};
const handleToggleActivo = async (suscriptor: SuscriptorDto) => {
const action = suscriptor.activo ? 'desactivar' : 'activar';
if (window.confirm(`¿Está seguro de que desea ${action} a ${suscriptor.nombreCompleto}?`)) {
setApiErrorMessage(null);
try {
if (suscriptor.activo) {
await suscriptorService.deactivateSuscriptor(suscriptor.idSuscriptor);
} else {
await suscriptorService.activateSuscriptor(suscriptor.idSuscriptor);
}
cargarSuscriptores();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`;
setApiErrorMessage(message);
}
}
handleMenuClose();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, suscriptor: SuscriptorDto) => {
setAnchorEl(event.currentTarget);
setSelectedRow(suscriptor);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedRow(null);
};
// --- INICIO DE LA CORRECCIÓN CLAVE ---
const handleNavigateToSuscripciones = (idSuscriptor: number) => {
// La ruta debe ser la ruta completa y final que renderiza el componente
navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`);
handleMenuClose();
};
// --- FIN DE LA CORRECCIÓN CLAVE ---
const handleNavigateToCuentaCorriente = (idSuscriptor: number) => {
navigate(`/suscripciones/suscriptor/${idSuscriptor}/cuenta-corriente`);
handleMenuClose();
};
const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage);
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const displayData = suscriptores.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
if (!puedeVer) {
return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso para ver esta sección."}</Alert></Box>;
}
handleMenuClose();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, suscriptor: SuscriptorDto) => {
setAnchorEl(event.currentTarget);
setSelectedRow(suscriptor);
};
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Gestionar Suscriptores</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<TextField label="Filtrar por Nombre" variant="outlined" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1 }} />
<TextField label="Filtrar por Nro. Doc" variant="outlined" size="small" value={filtroNroDoc} onChange={(e) => setFiltroNroDoc(e.target.value)} sx={{ flexGrow: 1 }} />
<FormControlLabel control={<Switch checked={filtroSoloActivos} onChange={(e) => setFiltroSoloActivos(e.target.checked)} />} label="Solo Activos" />
</Box>
{puedeCrear && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Nuevo Suscriptor</Button>}
</Paper>
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedRow(null);
};
{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>}
const handleNavigateToSuscripciones = (idSuscriptor: number) => {
navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`);
handleMenuClose();
};
{!loading && !error && (
<>
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell>Nombre</TableCell><TableCell>Documento</TableCell><TableCell>Dirección</TableCell>
<TableCell>Forma de Pago</TableCell><TableCell>Mail</TableCell><TableCell>Estado</TableCell><TableCell align="right">Acciones</TableCell>
</TableRow></TableHead>
<TableBody>
{displayData.map((s) => (
<TableRow key={s.idSuscriptor} hover>
<TableCell>{s.nombreCompleto}</TableCell>
<TableCell>{s.tipoDocumento} {s.nroDocumento}</TableCell>
<TableCell>{s.direccion}</TableCell>
<TableCell>{s.nombreFormaPagoPreferida}</TableCell>
<TableCell>{s.email}</TableCell>
<TableCell><Chip label={s.activo ? 'Activo' : 'Inactivo'} color={s.activo ? 'success' : 'default'} size="small" /></TableCell>
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, s)}><MoreVertIcon /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination rowsPerPageOptions={[15, 25, 50]} component="div" count={suscriptores.length} rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} />
</>
)}
const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage);
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{selectedRow && puedeModificar && (
<MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>Editar Datos</ListItemText>
</MenuItem>
)}
{selectedRow && puedeVerSuscripciones && (
<MenuItem onClick={() => handleNavigateToSuscripciones(selectedRow.idSuscriptor)}>
<ListItemIcon><ArticleIcon fontSize="small" /></ListItemIcon>
<ListItemText>Ver Suscripciones</ListItemText>
</MenuItem>
)}
{selectedRow && puedeVerCuentaCorriente && (
<MenuItem onClick={() => handleNavigateToCuentaCorriente(selectedRow.idSuscriptor)}>
<ListItemIcon><AccountBalanceWalletIcon fontSize="small" /></ListItemIcon>
<ListItemText>Ver Cuenta Corriente</ListItemText>
</MenuItem>
)}
{selectedRow && puedeActivarDesactivar && (
<MenuItem onClick={() => handleToggleActivo(selectedRow)}>
{selectedRow.activo ? <ListItemIcon><ToggleOffIcon fontSize="small" /></ListItemIcon> : <ListItemIcon><ToggleOnIcon fontSize="small" /></ListItemIcon>}
<ListItemText>{selectedRow.activo ? 'Desactivar' : 'Activar'}</ListItemText>
</MenuItem>
)}
</Menu>
const displayData = suscriptores.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
if (!puedeVer) {
return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso para ver esta sección."}</Alert></Box>;
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Gestionar Suscriptores</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<TextField label="Filtrar por Nombre" variant="outlined" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1 }} />
<TextField label="Filtrar por Nro. Doc" variant="outlined" size="small" value={filtroNroDoc} onChange={(e) => setFiltroNroDoc(e.target.value)} sx={{ flexGrow: 1 }} />
<FormControlLabel control={<Switch checked={filtroSoloActivos} onChange={(e) => setFiltroSoloActivos(e.target.checked)} />} label="Solo Activos" />
<SuscriptorFormModal open={modalOpen} onClose={handleCloseModal} onSubmit={handleSubmitModal} initialData={editingSuscriptor} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} />
</Box>
{puedeCrear && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Nuevo Suscriptor</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>}
{!loading && !error && (
<>
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell>Nombre</TableCell><TableCell>Documento</TableCell><TableCell>Dirección</TableCell>
<TableCell>Forma de Pago</TableCell><TableCell>Estado</TableCell><TableCell align="right">Acciones</TableCell>
</TableRow></TableHead>
<TableBody>
{displayData.map((s) => (
<TableRow key={s.idSuscriptor} hover>
<TableCell>{s.nombreCompleto}</TableCell>
<TableCell>{s.tipoDocumento} {s.nroDocumento}</TableCell>
<TableCell>{s.direccion}</TableCell>
<TableCell>{s.nombreFormaPagoPreferida}</TableCell>
<TableCell><Chip label={s.activo ? 'Activo' : 'Inactivo'} color={s.activo ? 'success' : 'default'} size="small" /></TableCell>
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, s)}><MoreVertIcon /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination rowsPerPageOptions={[15, 25, 50]} component="div" count={suscriptores.length} rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} />
</>
)}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{selectedRow && puedeModificar && <MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><ListItemIcon><EditIcon fontSize="small" /></ListItemIcon><ListItemText>Editar</ListItemText></MenuItem>}
{selectedRow && <MenuItem onClick={() => handleNavigateToSuscripciones(selectedRow.idSuscriptor)}><ListItemIcon><ArticleIcon fontSize="small" /></ListItemIcon><ListItemText>Ver Suscripciones</ListItemText></MenuItem>}
{selectedRow && puedeActivarDesactivar && (
<MenuItem onClick={() => handleToggleActivo(selectedRow)}>
{selectedRow.activo ? <ListItemIcon><ToggleOffIcon fontSize="small" /></ListItemIcon> : <ListItemIcon><ToggleOnIcon fontSize="small" /></ListItemIcon>}
<ListItemText>{selectedRow.activo ? 'Desactivar' : 'Activar'}</ListItemText>
</MenuItem>
)}
</Menu>
<SuscriptorFormModal open={modalOpen} onClose={handleCloseModal} onSubmit={handleSubmitModal} initialData={editingSuscriptor} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} />
</Box>
);
);
};
export default GestionarSuscriptoresPage;

View File

@@ -3,80 +3,76 @@ import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { usePermissions } from '../../hooks/usePermissions';
// Define las pestañas del módulo. Ajusta los permisos según sea necesario.
const suscripcionesSubModules = [
{ label: 'Suscriptores', path: 'suscriptores', requiredPermission: 'SU001' },
{ label: 'Facturación', path: 'facturacion', requiredPermission: 'SU006' },
{ label: 'Consulta Pagos y Facturas', path: 'consulta-facturas', requiredPermission: 'SU006' },
{ label: 'Cierre y Procesos', path: 'procesos', requiredPermission: 'SU006' },
{ label: 'Promociones', path: 'promociones', requiredPermission: 'SU010' },
];
const SuscripcionesIndexPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { tienePermiso, isSuperAdmin } = usePermissions();
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
const navigate = useNavigate();
const location = useLocation();
const { tienePermiso, isSuperAdmin } = usePermissions();
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
// Filtra los sub-módulos a los que el usuario tiene acceso
const accessibleSubModules = suscripcionesSubModules.filter(
(subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission)
);
useEffect(() => {
if (accessibleSubModules.length === 0) {
// Si no tiene acceso a ningún submódulo, no hacemos nada.
// El enrutador principal debería manejar esto.
return;
}
const currentBasePath = '/suscripciones';
const subPath = location.pathname.startsWith(`${currentBasePath}/`)
? location.pathname.substring(currentBasePath.length + 1)
: (location.pathname === currentBasePath ? accessibleSubModules[0]?.path : undefined);
const activeTabIndex = accessibleSubModules.findIndex(
(subModule) => subModule.path === subPath
const accessibleSubModules = suscripcionesSubModules.filter(
(subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission)
);
if (activeTabIndex !== -1) {
setSelectedSubTab(activeTabIndex);
} else if (location.pathname === currentBasePath) {
navigate(accessibleSubModules[0].path, { replace: true });
} else {
setSelectedSubTab(false);
useEffect(() => {
if (accessibleSubModules.length === 0) return;
const currentPath = location.pathname;
const basePath = '/suscripciones';
// Encuentra la pestaña que mejor coincide con la ruta actual
const activeTabIndex = accessibleSubModules.findIndex(subModule =>
currentPath.startsWith(`${basePath}/${subModule.path}`)
);
if (activeTabIndex !== -1) {
setSelectedSubTab(activeTabIndex);
} else if (currentPath === basePath && accessibleSubModules.length > 0) {
// Si estamos en la raíz del módulo, redirigir a la primera pestaña accesible
navigate(accessibleSubModules[0].path, { replace: true });
} else {
setSelectedSubTab(false); // Ninguna pestaña activa
}
}, [location.pathname, navigate, accessibleSubModules]);
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
navigate(accessibleSubModules[newValue].path);
};
if (accessibleSubModules.length === 0) {
return <Typography sx={{ p: 2 }}>No tiene permisos para acceder a este módulo.</Typography>;
}
}, [location.pathname, navigate, accessibleSubModules]);
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
navigate(accessibleSubModules[newValue].path);
};
if (accessibleSubModules.length === 0) {
return <Typography sx={{ p: 2 }}>No tiene permisos para acceder a este módulo.</Typography>;
}
return (
<Box>
<Typography variant="h5" gutterBottom>Módulo de Suscripciones</Typography>
<Paper square elevation={1}>
<Tabs
value={selectedSubTab}
onChange={handleSubTabChange}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
aria-label="sub-módulos de suscripciones"
>
{accessibleSubModules.map((subModule) => (
<Tab key={subModule.path} label={subModule.label} />
))}
</Tabs>
</Paper>
<Box sx={{ pt: 2 }}>
<Outlet />
return (
<Box>
<Typography variant="h5" gutterBottom>Módulo de Suscripciones</Typography>
<Paper square elevation={1}>
<Tabs
value={selectedSubTab}
onChange={handleSubTabChange}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
aria-label="sub-módulos de suscripciones"
>
{accessibleSubModules.map((subModule) => (
<Tab key={subModule.path} label={subModule.label} />
))}
</Tabs>
</Paper>
<Box sx={{ pt: 2 }}>
{/* Aquí se renderizará el componente de la sub-ruta activa */}
<Outlet />
</Box>
</Box>
</Box>
);
);
};
export default SuscripcionesIndexPage;

View File

@@ -75,13 +75,16 @@ import ReporteControlDevolucionesPage from '../pages/Reportes/ReporteControlDevo
import GestionarNovedadesCanillaPage from '../pages/Distribucion/GestionarNovedadesCanillaPage';
import ReporteNovedadesCanillasPage from '../pages/Reportes/ReporteNovedadesCanillasPage';
import ReporteListadoDistMensualPage from '../pages/Reportes/ReporteListadoDistMensualPage';
import ReporteFacturasPublicidadPage from '../pages/Reportes/ReporteFacturasPublicidadPage';
// Suscripciones
import SuscripcionesIndexPage from '../pages/Suscripciones/SuscripcionesIndexPage';
import GestionarSuscriptoresPage from '../pages/Suscripciones/GestionarSuscriptoresPage';
import GestionarSuscripcionesSuscriptorPage from '../pages/Suscripciones/GestionarSuscripcionesSuscriptorPage';
import FacturacionPage from '../pages/Suscripciones/FacturacionPage';
import GestionarPromocionesPage from '../pages/Suscripciones/GestionarPromocionesPage';
import ConsultaFacturasPage from '../pages/Suscripciones/ConsultaFacturasPage';
import FacturacionPage from '../pages/Suscripciones/FacturacionPage';
import GestionarSuscripcionesDeClientePage from '../pages/Suscripciones/GestionarSuscripcionesDeClientePage';
import CuentaCorrienteSuscriptorPage from '../pages/Suscripciones/CuentaCorrienteSuscriptorPage';
// Anonalías
import AlertasPage from '../pages/Anomalia/AlertasPage';
@@ -185,36 +188,44 @@ const AppRoutes = () => {
</Route>
</Route>
{/* --- Módulo de Suscripciones --- */}
{/* Módulo de Suscripciones */}
<Route
path="/suscripciones"
path="suscripciones"
element={
<SectionProtectedRoute requiredPermission="SS007" sectionName="Suscripciones">
<SuscripcionesIndexPage />
{/* Este Outlet es para las sub-rutas anidadas */}
<Outlet />
</SectionProtectedRoute>
}
>
<Route index element={<Navigate to="suscriptores" replace />} />
<Route path="suscriptores" element={
<SectionProtectedRoute requiredPermission="SU001" sectionName="Suscriptores">
<GestionarSuscriptoresPage />
</SectionProtectedRoute>
} />
<Route path="suscriptor/:idSuscriptor" element={
<SectionProtectedRoute requiredPermission="SU001" sectionName="Suscripciones del Cliente">
<GestionarSuscripcionesSuscriptorPage />
</SectionProtectedRoute>
} />
<Route path="facturacion" element={
<SectionProtectedRoute requiredPermission="SU006" sectionName="Facturación de Suscripciones">
<FacturacionPage />
</SectionProtectedRoute>
} />
<Route path="promociones" element={
<SectionProtectedRoute requiredPermission="SU010" sectionName="Promociones">
<GestionarPromocionesPage />
</SectionProtectedRoute>
} />
{/* 1. Ruta para el layout con pestañas */}
<Route
element={<SuscripcionesIndexPage />}
>
<Route index element={<Navigate to="suscriptores" replace />} />
<Route path="suscriptores" element={<GestionarSuscriptoresPage />} />
<Route path="consulta-facturas" element={<ConsultaFacturasPage />} />
<Route path="procesos" element={<FacturacionPage />} />
<Route path="promociones" element={<GestionarPromocionesPage />} />
</Route>
{/* 2. Rutas de detalle que NO usan el layout de pestañas */}
<Route
path="suscriptor/:idSuscriptor/suscripciones"
element={
<SectionProtectedRoute requiredPermission="SU005" sectionName="Gestionar Suscripciones de Cliente">
<GestionarSuscripcionesDeClientePage />
</SectionProtectedRoute>
}
/>
<Route
path="suscriptor/:idSuscriptor/cuenta-corriente"
element={
<SectionProtectedRoute requiredPermission="SU011" sectionName="Cuenta Corriente del Suscriptor">
<CuentaCorrienteSuscriptorPage />
</SectionProtectedRoute>
}
/>
</Route>
{/* Módulo Contable (anidado) */}
@@ -273,6 +284,11 @@ const AppRoutes = () => {
<Route path="control-devoluciones" element={<ReporteControlDevolucionesPage />} />
<Route path="novedades-canillas" element={<ReporteNovedadesCanillasPage />} />
<Route path="listado-distribucion-mensual" element={<ReporteListadoDistMensualPage />} />
<Route path="suscripciones-facturas-publicidad" element={
<SectionProtectedRoute requiredPermission="RR010" sectionName="Reporte Facturas a Publicidad">
<ReporteFacturasPublicidadPage />
</SectionProtectedRoute>
}/>
</Route>
{/* Módulo de Radios (anidado) */}

View File

@@ -445,6 +445,25 @@ const getListadoDistMensualPorPublicacionPdf = async (params: GetListadoDistMens
return response.data;
};
const getReporteFacturasPublicidadPdf = async (anio: number, mes: number): Promise<{ fileContent: Blob, fileName: string }> => {
const params = new URLSearchParams({ anio: String(anio), mes: String(mes) });
const url = `/reportes/suscripciones/facturas-para-publicidad/pdf?${params.toString()}`;
const response = await apiClient.get(url, {
responseType: 'blob',
});
const contentDisposition = response.headers['content-disposition'];
let fileName = `ReportePublicidad_Suscripciones_${anio}-${String(mes).padStart(2, '0')}.pdf`; // Fallback
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
if (fileNameMatch && fileNameMatch.length > 1) {
fileName = fileNameMatch[1];
}
}
return { fileContent: response.data, fileName: fileName };
};
const reportesService = {
getExistenciaPapel,
getExistenciaPapelPdf,
@@ -488,6 +507,7 @@ const reportesService = {
getListadoDistMensualDiariosPdf,
getListadoDistMensualPorPublicacion,
getListadoDistMensualPorPublicacionPdf,
getReporteFacturasPublicidadPdf,
};
export default reportesService;

View File

@@ -1,12 +1,26 @@
import apiClient from '../apiClient';
import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto';
import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto';
import type { UpdateAjusteDto } from '../../models/dtos/Suscripciones/UpdateAjusteDto';
const API_URL_BY_SUSCRIPTOR = '/suscriptores';
const API_URL_BASE = '/ajustes';
const getAjustesPorSuscriptor = async (idSuscriptor: number): Promise<AjusteDto[]> => {
const response = await apiClient.get<AjusteDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes`);
const getAjustesPorSuscriptor = async (idSuscriptor: number, fechaDesde?: string, fechaHasta?: string): Promise<AjusteDto[]> => {
// URLSearchParams nos ayuda a construir la query string de forma segura y limpia
const params = new URLSearchParams();
if (fechaDesde) {
params.append('fechaDesde', fechaDesde);
}
if (fechaHasta) {
params.append('fechaHasta', fechaHasta);
}
// Si hay parámetros, los añadimos a la URL. Si no, la URL queda limpia.
const queryString = params.toString();
const url = `${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<AjusteDto[]>(url);
return response.data;
};
@@ -19,8 +33,13 @@ const anularAjuste = async (idAjuste: number): Promise<void> => {
await apiClient.post(`${API_URL_BASE}/${idAjuste}/anular`);
};
const updateAjuste = async (idAjuste: number, data: UpdateAjusteDto): Promise<void> => {
await apiClient.put(`${API_URL_BASE}/${idAjuste}`, data);
};
export default {
getAjustesPorSuscriptor,
createAjusteManual,
anularAjuste
anularAjuste,
updateAjuste,
};

View File

@@ -1,29 +1,33 @@
import apiClient from '../apiClient';
import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto';
import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto';
import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto';
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto';
import type { ResumenCuentaSuscriptorDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
const API_URL = '/facturacion';
const DEBITOS_URL = '/debitos';
const PAGOS_URL = '/pagos';
const FACTURAS_URL = '/facturas';
const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLoteResponseDto> => {
const formData = new FormData();
formData.append('archivo', archivo);
const response = await apiClient.post<ProcesamientoLoteResponseDto>(`${DEBITOS_URL}/procesar-respuesta`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
};
const getFacturasPorPeriodo = async (anio: number, mes: number): Promise<FacturaDto[]> => {
const response = await apiClient.get<FacturaDto[]>(`${API_URL}/${anio}/${mes}`);
const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise<ResumenCuentaSuscriptorDto[]> => {
const params = new URLSearchParams();
if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor);
if (estadoPago) params.append('estadoPago', estadoPago);
if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion);
const queryString = params.toString();
const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<ResumenCuentaSuscriptorDto[]>(url);
return response.data;
};
@@ -36,7 +40,6 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo
const response = await apiClient.post(`${DEBITOS_URL}/${anio}/${mes}/generar-archivo`, {}, {
responseType: 'blob',
});
const contentDisposition = response.headers['content-disposition'];
let fileName = `debito_${anio}_${mes}.txt`;
if (contentDisposition) {
@@ -45,30 +48,35 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo
fileName = fileNameMatch[1];
}
}
return { fileContent: response.data, fileName: fileName };
};
const getPagosPorFactura = async (idFactura: number): Promise<PagoDto[]> => {
const response = await apiClient.get<PagoDto[]>(`${FACTURAS_URL}/${idFactura}/pagos`);
return response.data;
};
const registrarPagoManual = async (data: CreatePagoDto): Promise<PagoDto> => {
const response = await apiClient.post<PagoDto>(PAGOS_URL, data);
return response.data;
};
const enviarFacturaPorEmail = async (idFactura: number): Promise<void> => {
await apiClient.post(`${API_URL}/${idFactura}/enviar-email`);
const actualizarNumeroFactura = async (idFactura: number, numeroFactura: string): Promise<void> => {
await apiClient.put(`${API_URL}/${idFactura}/numero-factura`, `"${numeroFactura}"`, {
headers: { 'Content-Type': 'application/json' }
});
};
const enviarAvisoCuentaPorEmail = async (anio: number, mes: number, idSuscriptor: number): Promise<void> => {
await apiClient.post(`${API_URL}/${anio}/${mes}/suscriptor/${idSuscriptor}/enviar-aviso`);
};
const enviarFacturaPdfPorEmail = async (idFactura: number): Promise<void> => {
await apiClient.post(`${API_URL}/${idFactura}/enviar-factura-pdf`);
};
export default {
procesarArchivoRespuesta,
getFacturasPorPeriodo,
getResumenesDeCuentaPorPeriodo,
generarFacturacionMensual,
generarArchivoDebito,
getPagosPorFactura,
registrarPagoManual,
enviarFacturaPorEmail,
actualizarNumeroFactura,
enviarAvisoCuentaPorEmail,
enviarFacturaPdfPorEmail,
};

View File

@@ -3,12 +3,15 @@ import type { SuscripcionDto } from '../../models/dtos/Suscripciones/Suscripcion
import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto';
import type { PromocionAsignadaDto } from '../../models/dtos/Suscripciones/PromocionAsignadaDto';
import type { AsignarPromocionDto } from '../../models/dtos/Suscripciones/AsignarPromocionDto';
const API_URL_BASE = '/suscripciones';
const API_URL_BY_SUSCRIPTOR = '/suscriptores'; // Para la ruta anidada
const API_URL_SUSCRIPTORES = '/suscriptores';
const getSuscripcionesPorSuscriptor = async (idSuscriptor: number): Promise<SuscripcionDto[]> => {
const response = await apiClient.get<SuscripcionDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/suscripciones`);
// La URL correcta es /suscriptores/{id}/suscripciones, no /suscripciones/suscriptor/...
const response = await apiClient.get<SuscripcionDto[]>(`${API_URL_SUSCRIPTORES}/${idSuscriptor}/suscripciones`);
return response.data;
};
@@ -26,8 +29,8 @@ const updateSuscripcion = async (id: number, data: UpdateSuscripcionDto): Promis
await apiClient.put(`${API_URL_BASE}/${id}`, data);
};
const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionDto[]> => {
const response = await apiClient.get<PromocionDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`);
const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionAsignadaDto[]> => {
const response = await apiClient.get<PromocionAsignadaDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`);
return response.data;
};
@@ -36,8 +39,8 @@ const getPromocionesDisponibles = async (idSuscripcion: number): Promise<Promoci
return response.data;
};
const asignarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => {
await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`);
const asignarPromocion = async (idSuscripcion: number, data: AsignarPromocionDto): Promise<void> => {
await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones`, data);
};
const quitarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => {
@@ -52,5 +55,5 @@ export default {
getPromocionesAsignadas,
getPromocionesDisponibles,
asignarPromocion,
quitarPromocion
quitarPromocion,
};