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;
using GestionIntegral.Api.Dtos.Reportes.ViewModels; using GestionIntegral.Api.Dtos.Reportes.ViewModels;
using QuestPDF.Fluent; using QuestPDF.Fluent;
using QuestPDF.Helpers; using QuestPDF.Helpers;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq;
namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates 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 GestionIntegral.Api.Services.Reportes;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; 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.Dtos.Reportes;
using GestionIntegral.Api.Data.Repositories.Impresion; using GestionIntegral.Api.Data.Repositories.Impresion;
using System.IO;
using System.Linq;
using GestionIntegral.Api.Data.Repositories.Distribucion; using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Services.Distribucion; using GestionIntegral.Api.Services.Distribucion;
using GestionIntegral.Api.Services.Pdf; using GestionIntegral.Api.Services.Pdf;
@@ -45,6 +38,7 @@ namespace GestionIntegral.Api.Controllers
private const string PermisoVerReporteConsumoBobinas = "RR007"; private const string PermisoVerReporteConsumoBobinas = "RR007";
private const string PermisoVerReporteNovedadesCanillas = "RR004"; private const string PermisoVerReporteNovedadesCanillas = "RR004";
private const string PermisoVerReporteListadoDistMensual = "RR009"; private const string PermisoVerReporteListadoDistMensual = "RR009";
private const string PermisoVerReporteFacturasPublicidad = "RR010";
public ReportesController( public ReportesController(
IReportesService reportesService, IReportesService reportesService,
@@ -1676,5 +1670,54 @@ namespace GestionIntegral.Api.Controllers
return StatusCode(500, "Error interno al generar el PDF del reporte."); 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 // GET: api/suscriptores/{idSuscriptor}/ajustes
[HttpGet("~/api/suscriptores/{idSuscriptor:int}/ajustes")] [HttpGet("~/api/suscriptores/{idSuscriptor:int}/ajustes")]
[ProducesResponseType(typeof(IEnumerable<AjusteDto>), StatusCodes.Status200OK)] [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(); if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor); var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor, fechaDesde, fechaHasta);
return Ok(ajustes); return Ok(ajustes);
} }
@@ -74,5 +74,21 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
return Ok(new { message = "Ajuste anulado correctamente." }); 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 IFacturacionService _facturacionService;
private readonly ILogger<FacturacionController> _logger; private readonly ILogger<FacturacionController> _logger;
private const string PermisoGestionarFacturacion = "SU006";
// Permiso para generar facturación (a crear en la BD) private const string PermisoEnviarEmail = "SU009";
private const string PermisoGenerarFacturacion = "SU006";
public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger) public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger)
{ {
@@ -28,67 +27,94 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
private int? GetCurrentUserId() private int? GetCurrentUserId()
{ {
if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; 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; return null;
} }
// POST: api/facturacion/{anio}/{mes} [HttpPut("{idFactura:int}/numero-factura")]
[HttpPost("{anio:int}/{mes:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> GenerarFacturacion(int anio, int mes) public async Task<IActionResult> UpdateNumeroFactura(int idFactura, [FromBody] string numeroFactura)
{ {
if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid(); if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
var userId = GetCurrentUserId(); var userId = GetCurrentUserId();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (anio < 2020 || mes < 1 || mes > 12) var (exito, error) = await _facturacionService.ActualizarNumeroFactura(idFactura, numeroFactura, userId.Value);
{
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) 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 NoContent();
return Ok(new { message = mensaje, facturasGeneradas });
} }
// GET: api/facturacion/{anio}/{mes} // POST: api/facturacion/{idFactura}/enviar-factura-pdf
[HttpGet("{anio:int}/{mes:int}")] [HttpPost("{idFactura:int}/enviar-factura-pdf")]
[ProducesResponseType(typeof(IEnumerable<FacturaDto>), StatusCodes.Status200OK)] public async Task<IActionResult> EnviarFacturaPdf(int idFactura)
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetFacturas(int anio, int mes)
{ {
// 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(); if (!TienePermiso("SU009")) return Forbid();
var (exito, error) = await _facturacionService.EnviarFacturaPorEmail(idFactura); var (exito, error) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura);
if (!exito) if (!exito)
{ {
return BadRequest(new { message = error }); 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); return Ok(promos);
} }
// POST: api/suscripciones/{idSuscripcion}/promociones/{idPromocion} // POST: api/suscripciones/{idSuscripcion}/promociones
[HttpPost("{idSuscripcion:int}/promociones/{idPromocion:int}")] [HttpPost("{idSuscripcion:int}/promociones")]
public async Task<IActionResult> AsignarPromocion(int idSuscripcion, int idPromocion) public async Task<IActionResult> AsignarPromocion(int idSuscripcion, [FromBody] AsignarPromocionDto dto)
{ {
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
var userId = GetCurrentUserId(); var userId = GetCurrentUserId();
if (userId == null) return Unauthorized(); 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 }); if (!exito) return BadRequest(new { message = error });
return Ok(); return Ok();
} }

View File

@@ -45,5 +45,6 @@ namespace GestionIntegral.Api.Data.Repositories.Reportes
Task<IEnumerable<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla); Task<IEnumerable<LiquidacionCanillaGananciaDto>> GetLiquidacionCanillaGananciasAsync(DateTime fecha, int idCanilla);
Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista); Task<IEnumerable<ListadoDistCanMensualDiariosDto>> GetReporteMensualDiariosAsync(DateTime fechaDesde, DateTime fechaHasta, bool esAccionista);
Task<IEnumerable<ListadoDistCanMensualPubDto>> GetReporteMensualPorPublicacionAsync(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 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 Dapper;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using System.Data; using System.Data;
using System.Text;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
@@ -15,36 +16,72 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
_logger = logger; _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) public async Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction)
{ {
const string sql = @" 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.* OUTPUT INSERTED.*
VALUES (@IdSuscriptor, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());"; VALUES (@IdSuscriptor, @FechaAjuste, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());";
if (transaction?.Connection == null) if (transaction?.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); 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); 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(); 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) if (transaction?.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); 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) 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); var rows = await transaction.Connection.ExecuteAsync(sql, new { IdAjuste = idAjuste, IdUsuario = idUsuario }, transaction);
return rows == 1; 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 Dapper;
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data; using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
@@ -19,7 +24,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<Factura?> GetByIdAsync(int idFactura) 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(); using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<Factura>(sql, new { idFactura }); 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 }); 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) if (transaction == null || transaction.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); 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) 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."); throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
} }
const string sqlInsert = @" const string sqlInsert = @"
INSERT INTO dbo.susc_Facturas INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion)
(IdSuscripcion, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,
DescuentoAplicado, ImporteFinal, Estado)
OUTPUT INSERTED.* OUTPUT INSERTED.*
VALUES VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);";
(@IdSuscripcion, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,
@DescuentoAplicado, @ImporteFinal, @Estado);";
return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); 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) if (transaction == null || transaction.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); 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;"; const string sql = "UPDATE dbo.susc_Facturas SET EstadoPago = @NuevoEstadoPago WHERE IdFactura = @IdFactura;";
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstado = nuevoEstado, IdFactura = idFactura }, transaction); var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, idFactura }, transaction);
return rowsAffected == 1; 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."); 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;"; const string sql = @"
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, IdFactura = idFactura }, transaction); 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; 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."); 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); var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdLoteDebito = idLoteDebito, IdsFacturas = idsFacturas }, transaction);
return rowsAffected == idsFacturas.Count(); 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 = @" var sqlBuilder = new StringBuilder(@"
SELECT f.*, s.NombreCompleto AS NombreSuscriptor, p.Nombre AS NombrePublicacion WITH FacturaConEmpresa AS (
FROM dbo.susc_Facturas f -- Esta subconsulta obtiene el IdEmpresa para cada factura basándose en la primera suscripción que encuentra en sus detalles.
JOIN dbo.susc_Suscripciones sc ON f.IdSuscripcion = sc.IdSuscripcion -- Esto es seguro porque nuestra lógica de negocio asegura que todos los detalles de una factura pertenecen a la misma empresa.
JOIN dbo.susc_Suscriptores s ON sc.IdSuscriptor = s.IdSuscriptor SELECT
JOIN dbo.dist_dtPublicaciones p ON sc.IdPublicacion = p.Id_Publicacion f.IdFactura,
WHERE f.Periodo = @Periodo (SELECT TOP 1 p.Id_Empresa
ORDER BY s.NombreCompleto; 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 try
{ {
using var connection = _connectionFactory.CreateConnection(); using var connection = _connectionFactory.CreateConnection();
var result = await connection.QueryAsync<Factura, string, string, (Factura, string, string)>( var result = await connection.QueryAsync<Factura, string, int, decimal, (Factura, string, int, decimal)>(
sql, sqlBuilder.ToString(),
(factura, suscriptor, publicacion) => (factura, suscriptor, publicacion), (factura, suscriptor, idEmpresa, totalPagado) => (factura, suscriptor, idEmpresa, totalPagado),
new { Periodo = periodo }, parameters,
splitOn: "NombreSuscriptor,NombrePublicacion" splitOn: "NombreSuscriptor,IdEmpresa,TotalPagado"
); );
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error al obtener facturas enriquecidas para el período {Periodo}", periodo); _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) if (transaction == null || transaction.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
} }
const string sql = @" const string sql = @"
UPDATE dbo.susc_Facturas SET UPDATE dbo.susc_Facturas SET
Estado = @NuevoEstado, EstadoPago = @NuevoEstadoPago,
MotivoRechazo = @MotivoRechazo MotivoRechazo = @MotivoRechazo
WHERE IdFactura = @IdFactura;"; WHERE IdFactura = @IdFactura;";
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NuevoEstadoPago = nuevoEstadoPago, MotivoRechazo = motivoRechazo, idFactura }, transaction);
var rowsAffected = await transaction.Connection.ExecuteAsync(
sql,
new { NuevoEstado = nuevoEstado, MotivoRechazo = motivoRechazo, IdFactura = idFactura },
transaction
);
return rowsAffected == 1; 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 GestionIntegral.Api.Models.Suscripciones;
using System;
using System.Collections.Generic;
using System.Data; using System.Data;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
public interface IAjusteRepository 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<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<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<Factura?> GetByIdAsync(int idFactura);
Task<IEnumerable<Factura>> GetByPeriodoAsync(string periodo); 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<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> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction);
Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction);
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo); 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 nuevoEstado, string? motivoRechazo, IDbTransaction transaction); 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<IEnumerable<Pago>> GetByFacturaIdAsync(int idFactura);
Task<Pago?> CreateAsync(Pago nuevoPago, IDbTransaction transaction); 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<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction);
Task<bool> UpdateAsync(Promocion promocion, 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, 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 public interface ISuscripcionRepository
{ {
Task<IEnumerable<Suscripcion>> GetBySuscriptorIdAsync(int idSuscriptor);
Task<Suscripcion?> GetByIdAsync(int idSuscripcion); 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<Suscripcion?> CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction);
Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction); Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction);
Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction); Task<IEnumerable<(SuscripcionPromocion Asignacion, Promocion Promocion)>> GetPromocionesAsignadasBySuscripcionIdAsync(int idSuscripcion);
Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion); Task AsignarPromocionAsync(SuscripcionPromocion asignacion, IDbTransaction transaction);
Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction);
Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction); Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction);
} }
} }

View File

@@ -43,7 +43,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
OUTPUT INSERTED.* OUTPUT INSERTED.*
VALUES VALUES
(@IdFactura, @FechaPago, @IdFormaPago, @Monto, @Estado, @Referencia, @Observaciones, @IdUsuarioRegistro);"; (@IdFactura, @FechaPago, @IdFormaPago, @Monto, @Estado, @Referencia, @Observaciones, @IdUsuarioRegistro);";
try try
{ {
return await transaction.Connection.QuerySingleAsync<Pago>(sqlInsert, nuevoPago, transaction); return await transaction.Connection.QuerySingleAsync<Pago>(sqlInsert, nuevoPago, transaction);
@@ -54,5 +54,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return null; 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) public async Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas)
{ {
var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones"); var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones");
if(soloActivas) if (soloActivas)
{ {
sql.Append(" WHERE Activa = 1"); sql.Append(" WHERE Activa = 1");
} }
@@ -39,10 +39,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction) public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction)
{ {
const string sql = @" 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.* 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) if (transaction?.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); 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) 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 = @" 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 JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion WHERE sp.IdSuscripcion = @IdSuscripcion
AND p.Activa = 1 AND p.Activa = 1
AND p.FechaInicio <= @FechaPeriodo -- 1. La promoción general debe estar activa en el período
AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo);"; 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) if (transaction?.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); 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); 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

@@ -44,10 +44,9 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return Enumerable.Empty<Suscripcion>(); return Enumerable.Empty<Suscripcion>();
} }
} }
public async Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction) 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 year = int.Parse(periodo.Split('-')[0]);
var month = int.Parse(periodo.Split('-')[1]); var month = int.Parse(periodo.Split('-')[1]);
var primerDiaMes = new DateTime(year, month, 1); var primerDiaMes = new DateTime(year, month, 1);
@@ -61,7 +60,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
AND su.Activo = 1 AND su.Activo = 1
AND s.FechaInicio <= @UltimoDiaMes AND s.FechaInicio <= @UltimoDiaMes
AND (s.FechaFin IS NULL OR s.FechaFin >= @PrimerDiaMes);"; AND (s.FechaFin IS NULL OR s.FechaFin >= @PrimerDiaMes);";
if (transaction == null || transaction.Connection == null) if (transaction == null || transaction.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
@@ -85,7 +84,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
VALUES VALUES
(@IdSuscriptor, @IdPublicacion, @FechaInicio, @FechaFin, @Estado, @DiasEntrega, (@IdSuscriptor, @IdPublicacion, @FechaInicio, @FechaFin, @Estado, @DiasEntrega,
@Observaciones, @IdUsuarioAlta, GETDATE());"; @Observaciones, @IdUsuarioAlta, GETDATE());";
return await transaction.Connection.QuerySingleAsync<Suscripcion>(sqlInsert, nuevaSuscripcion, transaction); return await transaction.Connection.QuerySingleAsync<Suscripcion>(sqlInsert, nuevaSuscripcion, transaction);
} }
@@ -112,30 +111,35 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return rowsAffected == 1; 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 = @" const string sql = @"
SELECT p.* FROM dbo.susc_Promociones p SELECT sp.*, p.*
JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion FROM dbo.susc_SuscripcionPromociones sp
WHERE sp.IdSuscripcion = @IdSuscripcion;"; JOIN dbo.susc_Promociones p ON sp.IdPromocion = p.IdPromocion
WHERE sp.IdSuscripcion = @IdSuscripcion;";
using var connection = _connectionFactory.CreateConnection(); 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) if (transaction == null || transaction.Connection == null)
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
} }
const string sql = @" const string sql = @"
INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno) INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno, VigenciaDesde, VigenciaHasta, FechaAsignacion)
VALUES (@IdSuscripcion, @IdPromocion, @IdUsuario);"; VALUES (@IdSuscripcion, @IdPromocion, @IdUsuarioAsigno, @VigenciaDesde, @VigenciaHasta, GETDATE());";
await transaction.Connection.ExecuteAsync(sql, await transaction.Connection.ExecuteAsync(sql, asignacion, transaction);
new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion, IdUsuario = idUsuario },
transaction);
} }
public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction 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."); 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;"; 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; 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 IdAjuste { get; set; }
public int IdSuscriptor { get; set; } public int IdSuscriptor { get; set; }
public string FechaAjuste { get; set; } = string.Empty;
public string TipoAjuste { get; set; } = string.Empty; public string TipoAjuste { get; set; } = string.Empty;
public decimal Monto { get; set; } public decimal Monto { get; set; }
public string Motivo { get; set; } = string.Empty; 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] [Required]
public int IdSuscriptor { get; set; } public int IdSuscriptor { get; set; }
[Required]
public DateTime FechaAjuste { get; set; }
[Required] [Required]
[RegularExpression("^(Credito|Debito)$", ErrorMessage = "El tipo de ajuste debe ser 'Credito' o 'Debito'.")] [RegularExpression("^(Credito|Debito)$", ErrorMessage = "El tipo de ajuste debe ser 'Credito' o 'Debito'.")]
public string TipoAjuste { get; set; } = string.Empty; public string TipoAjuste { get; set; } = string.Empty;

View File

@@ -7,22 +7,25 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
{ {
public class CreatePromocionDto public class CreatePromocionDto
{ {
[Required(ErrorMessage = "La descripción es obligatoria.")] [Required]
[StringLength(200)] [StringLength(200)]
public string Descripcion { get; set; } = string.Empty; public string Descripcion { get; set; } = string.Empty;
[Required(ErrorMessage = "El tipo de promoción es obligatorio.")] [Required]
public string TipoPromocion { get; set; } = string.Empty; public string TipoEfecto { get; set; } = string.Empty; // Corregido
[Required(ErrorMessage = "El valor es obligatorio.")] [Required]
[Range(0.01, 99999999.99, ErrorMessage = "El valor debe ser positivo.")] [Range(0, 99999999.99)] // Se permite 0 para bonificaciones
public decimal Valor { get; set; } public decimal ValorEfecto { get; set; } // Corregido
[Required(ErrorMessage = "La fecha de inicio es obligatoria.")] [Required]
public DateTime FechaInicio { get; set; } public string TipoCondicion { get; set; } = string.Empty;
public DateTime? FechaFin { get; set; }
public int? ValorCondicion { get; set; }
[Required]
public DateTime FechaInicio { get; set; }
public DateTime? FechaFin { get; set; }
public bool Activa { get; set; } = true; 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 namespace GestionIntegral.Api.Dtos.Suscripciones
{ {
/// <summary> public class FacturaDetalleDto
/// 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. public string Descripcion { get; set; } = string.Empty;
/// </summary> public decimal ImporteNeto { get; set; }
}
public class FacturaDto public class FacturaDto
{ {
public int IdFactura { get; set; } public int IdFactura { get; set; }
public int IdSuscripcion { get; set; } public int IdSuscriptor { get; set; }
public string Periodo { get; set; } = string.Empty; // Formato "YYYY-MM" public string Periodo { get; set; } = string.Empty;
public string FechaEmision { get; set; } = string.Empty; // Formato "yyyy-MM-dd" public string FechaEmision { get; set; } = string.Empty;
public string FechaVencimiento { get; set; } = string.Empty; // Formato "yyyy-MM-dd" public string FechaVencimiento { get; set; } = string.Empty;
public decimal ImporteFinal { get; set; } 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; } public string? NumeroFactura { get; set; }
// Datos enriquecidos para la UI, poblados por el servicio
public string NombreSuscriptor { get; set; } = string.Empty; 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 int IdPromocion { get; set; }
public string Descripcion { get; set; } = string.Empty; public string Descripcion { get; set; } = string.Empty;
public string TipoPromocion { get; set; } = string.Empty; public string TipoEfecto { get; set; } = string.Empty;
public decimal Valor { get; set; } public decimal ValorEfecto { get; set; }
public string FechaInicio { get; set; } = string.Empty; // yyyy-MM-dd 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 string? FechaFin { get; set; }
public bool Activa { 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 IdAjuste { get; set; }
public int IdSuscriptor { get; set; } public int IdSuscriptor { get; set; }
public DateTime FechaAjuste { get; set; }
public string TipoAjuste { get; set; } = string.Empty; public string TipoAjuste { get; set; } = string.Empty;
public decimal Monto { get; set; } public decimal Monto { get; set; }
public string Motivo { get; set; } = string.Empty; public string Motivo { get; set; } = string.Empty;

View File

@@ -3,14 +3,15 @@ namespace GestionIntegral.Api.Models.Suscripciones
public class Factura public class Factura
{ {
public int IdFactura { get; set; } public int IdFactura { get; set; }
public int IdSuscripcion { get; set; } public int IdSuscriptor { get; set; }
public string Periodo { get; set; } = string.Empty; public string Periodo { get; set; } = string.Empty;
public DateTime FechaEmision { get; set; } public DateTime FechaEmision { get; set; }
public DateTime FechaVencimiento { get; set; } public DateTime FechaVencimiento { get; set; }
public decimal ImporteBruto { get; set; } public decimal ImporteBruto { get; set; }
public decimal DescuentoAplicado { get; set; } public decimal DescuentoAplicado { get; set; }
public decimal ImporteFinal { 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 string? NumeroFactura { get; set; }
public int? IdLoteDebito { get; set; } public int? IdLoteDebito { get; set; }
public string? MotivoRechazo { 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 int IdPromocion { get; set; }
public string Descripcion { get; set; } = string.Empty; public string Descripcion { get; set; } = string.Empty;
public string TipoPromocion { get; set; } = string.Empty; public string TipoEfecto { get; set; } = string.Empty; // Nuevo nombre
public decimal Valor { get; set; } 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 FechaInicio { get; set; }
public DateTime? FechaFin { get; set; } public DateTime? FechaFin { get; set; }
public bool Activa { get; set; } public bool Activa { get; set; }

View File

@@ -6,5 +6,7 @@ namespace GestionIntegral.Api.Models.Suscripciones
public int IdPromocion { get; set; } public int IdPromocion { get; set; }
public DateTime FechaAsignacion { get; set; } public DateTime FechaAsignacion { get; set; }
public int IdUsuarioAsigno { 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<IPagoRepository, PagoRepository>();
builder.Services.AddScoped<IPromocionRepository, PromocionRepository>(); builder.Services.AddScoped<IPromocionRepository, PromocionRepository>();
builder.Services.AddScoped<IAjusteRepository, AjusteRepository>(); builder.Services.AddScoped<IAjusteRepository, AjusteRepository>();
builder.Services.AddScoped<IFacturaDetalleRepository, FacturaDetalleRepository>();
builder.Services.AddScoped<IFormaPagoService, FormaPagoService>(); builder.Services.AddScoped<IFormaPagoService, FormaPagoService>();
builder.Services.AddScoped<ISuscriptorService, SuscriptorService>(); builder.Services.AddScoped<ISuscriptorService, SuscriptorService>();

View File

@@ -17,7 +17,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
_logger = logger; _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(); var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail); email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
@@ -26,6 +26,10 @@ namespace GestionIntegral.Api.Services.Comunicaciones
email.Subject = asunto; email.Subject = asunto;
var builder = new BodyBuilder { HtmlBody = cuerpoHtml }; 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(); email.Body = builder.ToMessageBody();
using var smtp = new SmtpClient(); using var smtp = new SmtpClient();
@@ -46,5 +50,44 @@ namespace GestionIntegral.Api.Services.Comunicaciones
await smtp.DisconnectAsync(true); 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 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<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<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.Reportes;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Reportes; 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 namespace GestionIntegral.Api.Services.Reportes
{ {
public class ReportesService : IReportesService public class ReportesService : IReportesService
{ {
private readonly IReportesRepository _reportesRepository; 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; 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; _reportesRepository = reportesRepository;
_facturaRepository = facturaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_publicacionRepository = publicacionRepository;
_empresaRepository = empresaRepository;
_suscriptorRepository = suscriptorRepository;
_suscripcionRepository = suscripcionRepository;
_logger = logger; _logger = logger;
} }
@@ -520,5 +530,25 @@ namespace GestionIntegral.Api.Services.Reportes
return (Enumerable.Empty<ListadoDistCanMensualPubDto>(), "Error al obtener datos del reporte (por publicación)."); 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, IdAjuste = ajuste.IdAjuste,
IdSuscriptor = ajuste.IdSuscriptor, IdSuscriptor = ajuste.IdSuscriptor,
FechaAjuste = ajuste.FechaAjuste.ToString("yyyy-MM-dd"),
TipoAjuste = ajuste.TipoAjuste, TipoAjuste = ajuste.TipoAjuste,
Monto = ajuste.Monto, Monto = ajuste.Monto,
Motivo = ajuste.Motivo, 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 dtosTasks = ajustes.Select(a => MapToDto(a));
var dtos = await Task.WhenAll(dtosTasks); var dtos = await Task.WhenAll(dtosTasks);
return dtos.Where(dto => dto != null)!; return dtos.Where(dto => dto != null)!;
@@ -62,10 +63,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
return (null, "El suscriptor especificado no existe."); return (null, "El suscriptor especificado no existe.");
} }
var nuevoAjuste = new Ajuste var nuevoAjuste = new Ajuste
{ {
IdSuscriptor = createDto.IdSuscriptor, IdSuscriptor = createDto.IdSuscriptor,
FechaAjuste = createDto.FechaAjuste.Date,
TipoAjuste = createDto.TipoAjuste, TipoAjuste = createDto.TipoAjuste,
Monto = createDto.Monto, Monto = createDto.Monto,
Motivo = createDto.Motivo, Motivo = createDto.Motivo,
@@ -119,5 +121,36 @@ namespace GestionIntegral.Api.Services.Suscripciones
return (false, "Error interno al anular el ajuste."); 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;
using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
using System.Text;
using GestionIntegral.Api.Dtos.Suscripciones;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System; using System;
using System.Data;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Collections.Generic; 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 namespace GestionIntegral.Api.Services.Suscripciones
{ {
@@ -19,21 +18,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
private readonly IFacturaRepository _facturaRepository; private readonly IFacturaRepository _facturaRepository;
private readonly ISuscriptorRepository _suscriptorRepository; private readonly ISuscriptorRepository _suscriptorRepository;
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly ILoteDebitoRepository _loteDebitoRepository; private readonly ILoteDebitoRepository _loteDebitoRepository;
private readonly IFormaPagoRepository _formaPagoRepository; private readonly IFormaPagoRepository _formaPagoRepository;
private readonly IPagoRepository _pagoRepository; private readonly IPagoRepository _pagoRepository;
private readonly DbConnectionFactory _connectionFactory; private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<DebitoAutomaticoService> _logger; 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 NRO_PRESTACION = "123456"; // Nro. de prestación asignado por el banco
private const string ORIGEN_EMPRESA = "ELDIA"; // Nombre de la empresa (7 chars) private const string ORIGEN_EMPRESA = "ELDIA"; // Nombre de la empresa (7 chars)
public DebitoAutomaticoService( public DebitoAutomaticoService(
IFacturaRepository facturaRepository, IFacturaRepository facturaRepository,
ISuscriptorRepository suscriptorRepository, ISuscriptorRepository suscriptorRepository,
ISuscripcionRepository suscripcionRepository,
ILoteDebitoRepository loteDebitoRepository, ILoteDebitoRepository loteDebitoRepository,
IFormaPagoRepository formaPagoRepository, IFormaPagoRepository formaPagoRepository,
IPagoRepository pagoRepository, IPagoRepository pagoRepository,
@@ -42,7 +38,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
_facturaRepository = facturaRepository; _facturaRepository = facturaRepository;
_suscriptorRepository = suscriptorRepository; _suscriptorRepository = suscriptorRepository;
_suscripcionRepository = suscripcionRepository;
_loteDebitoRepository = loteDebitoRepository; _loteDebitoRepository = loteDebitoRepository;
_formaPagoRepository = formaPagoRepository; _formaPagoRepository = formaPagoRepository;
_pagoRepository = pagoRepository; _pagoRepository = pagoRepository;
@@ -61,9 +56,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
try try
{ {
// Buscamos facturas que están listas para ser enviadas al cobro.
var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction); var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction);
if (!facturasParaDebito.Any()) if (!facturasParaDebito.Any())
{ {
return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado."); 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 cantidadRegistros = facturasParaDebito.Count();
var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt"; var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt";
// 1. Crear el Lote de Débito
var nuevoLote = new LoteDebito var nuevoLote = new LoteDebito
{ {
Periodo = periodo, Periodo = periodo,
@@ -85,18 +77,14 @@ namespace GestionIntegral.Api.Services.Suscripciones
var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction); var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction);
if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); 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(); var sb = new StringBuilder();
sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros)); sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros));
foreach (var item in facturasParaDebito) foreach (var item in facturasParaDebito)
{ {
sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor));
} }
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros)); sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros));
// 3. Actualizar las facturas con el ID del lote
var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura);
bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction); 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."); 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) 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. var facturasDelPeriodo = await _facturaRepository.GetByPeriodoAsync(periodo);
// Por simplicidad del ejemplo, lo hacemos aquí.
var facturas = await _facturaRepository.GetByPeriodoAsync(periodo);
var resultado = new List<(Factura, Suscriptor)>(); 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); var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor);
if (suscripcion == null) continue;
var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor);
if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue; if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue;
var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida);
@@ -231,28 +214,17 @@ namespace GestionIntegral.Api.Services.Suscripciones
string? linea; string? linea;
while ((linea = await reader.ReadLineAsync()) != null) while ((linea = await reader.ReadLineAsync()) != null)
{ {
// Ignorar header/trailer si los hubiera (basado en el formato real)
if (linea.Length < 20) continue; if (linea.Length < 20) continue;
respuesta.TotalRegistrosLeidos++; 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 referencia = linea.Substring(0, 15).Trim();
var estadoProceso = linea.Substring(15, 2).Trim(); var estadoProceso = linea.Substring(15, 2).Trim();
var motivoRechazo = linea.Substring(17, 3).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)) 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}'."); respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'.");
continue; continue;
} }
// =================================================================
// === FIN DE LA LÓGICA DE PARSEO ===
// =================================================================
var factura = await _facturaRepository.GetByIdAsync(idFactura); var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null) if (factura == null)
@@ -264,27 +236,24 @@ namespace GestionIntegral.Api.Services.Suscripciones
var nuevoPago = new Pago var nuevoPago = new Pago
{ {
IdFactura = idFactura, IdFactura = idFactura,
FechaPago = DateTime.Now.Date, // O la fecha que venga en el archivo FechaPago = DateTime.Now.Date,
IdFormaPago = 1, // 1 = Débito Automático IdFormaPago = 1,
Monto = factura.ImporteFinal, Monto = factura.ImporteFinal,
IdUsuarioRegistro = idUsuario, IdUsuarioRegistro = idUsuario,
Referencia = $"Lote {factura.IdLoteDebito} - Banco" Referencia = $"Lote {factura.IdLoteDebito} - Banco"
}; };
if (estadoProceso == "AP") // "AP" = Aprobado (Asunción) if (estadoProceso == "AP")
{ {
nuevoPago.Estado = "Aprobado"; nuevoPago.Estado = "Aprobado";
await _pagoRepository.CreateAsync(nuevoPago, transaction); await _pagoRepository.CreateAsync(nuevoPago, transaction);
await _facturaRepository.UpdateEstadoAsync(idFactura, "Pagada", transaction); await _facturaRepository.UpdateEstadoPagoAsync(idFactura, "Pagada", transaction);
respuesta.PagosAprobados++; respuesta.PagosAprobados++;
} }
else // Asumimos que cualquier otra cosa es Rechazado else
{ {
nuevoPago.Estado = "Rechazado"; nuevoPago.Estado = "Rechazado";
await _pagoRepository.CreateAsync(nuevoPago, transaction); 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); await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction);
respuesta.PagosRechazados++; respuesta.PagosRechazados++;
} }

View File

@@ -6,6 +6,8 @@ using GestionIntegral.Api.Models.Distribucion;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using GestionIntegral.Api.Services.Comunicaciones; using GestionIntegral.Api.Services.Comunicaciones;
using System.Data; using System.Data;
using System.Globalization;
using System.Text;
namespace GestionIntegral.Api.Services.Suscripciones namespace GestionIntegral.Api.Services.Suscripciones
{ {
@@ -13,40 +15,393 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
private readonly ISuscripcionRepository _suscripcionRepository; private readonly ISuscripcionRepository _suscripcionRepository;
private readonly IFacturaRepository _facturaRepository; private readonly IFacturaRepository _facturaRepository;
private readonly IEmpresaRepository _empresaRepository;
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
private readonly IPrecioRepository _precioRepository; private readonly IPrecioRepository _precioRepository;
private readonly IPromocionRepository _promocionRepository; private readonly IPromocionRepository _promocionRepository;
private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación private readonly ISuscriptorRepository _suscriptorRepository;
private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor private readonly IAjusteRepository _ajusteRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly IEmailService _emailService; private readonly IEmailService _emailService;
private readonly IPublicacionRepository _publicacionRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<FacturacionService> _logger; private readonly ILogger<FacturacionService> _logger;
private readonly string _facturasPdfPath;
public FacturacionService( public FacturacionService(
ISuscripcionRepository suscripcionRepository, ISuscripcionRepository suscripcionRepository,
IFacturaRepository facturaRepository, IFacturaRepository facturaRepository,
IEmpresaRepository empresaRepository,
IFacturaDetalleRepository facturaDetalleRepository,
IPrecioRepository precioRepository, IPrecioRepository precioRepository,
IPromocionRepository promocionRepository, IPromocionRepository promocionRepository,
IRecargoZonaRepository recargoZonaRepository,
ISuscriptorRepository suscriptorRepository, ISuscriptorRepository suscriptorRepository,
DbConnectionFactory connectionFactory, IAjusteRepository ajusteRepository,
IEmailService emailService, IEmailService emailService,
ILogger<FacturacionService> logger) IPublicacionRepository publicacionRepository,
DbConnectionFactory connectionFactory,
ILogger<FacturacionService> logger,
IConfiguration configuration)
{ {
_suscripcionRepository = suscripcionRepository; _suscripcionRepository = suscripcionRepository;
_facturaRepository = facturaRepository; _facturaRepository = facturaRepository;
_empresaRepository = empresaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_precioRepository = precioRepository; _precioRepository = precioRepository;
_promocionRepository = promocionRepository; _promocionRepository = promocionRepository;
_recargoZonaRepository = recargoZonaRepository;
_suscriptorRepository = suscriptorRepository; _suscriptorRepository = suscriptorRepository;
_connectionFactory = connectionFactory; _ajusteRepository = ajusteRepository;
_emailService = emailService; _emailService = emailService;
_publicacionRepository = publicacionRepository;
_connectionFactory = connectionFactory;
_logger = logger; _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) 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}"; 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(); using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync(); await (connection as System.Data.Common.DbConnection)!.OpenAsync();
@@ -54,77 +409,31 @@ namespace GestionIntegral.Api.Services.Suscripciones
try try
{ {
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction); var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (!suscripcionesActivas.Any()) 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; var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction);
foreach (var suscripcion in suscripcionesActivas) if (!actualizado)
{ {
var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction); throw new DataException("La actualización del número de factura falló en el repositorio.");
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++;
} }
transaction.Commit(); transaction.Commit();
_logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas); _logger.LogInformation("Número de factura para Factura ID {IdFactura} actualizado a {NumeroFactura} por Usuario ID {IdUsuario}", idFactura, numeroFactura, idUsuario);
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", facturasGeneradas); return (true, null);
} }
catch (Exception ex) catch (Exception ex)
{ {
try { transaction.Rollback(); } catch { } try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodo); _logger.LogError(ex, "Error al actualizar número de factura para Factura ID {IdFactura}", idFactura);
return (false, "Error interno del servidor al generar la facturación.", 0); return (false, "Error interno al actualizar el número de factura.");
} }
} }
@@ -133,25 +442,34 @@ namespace GestionIntegral.Api.Services.Suscripciones
decimal importeTotal = 0; decimal importeTotal = 0;
var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet();
var fechaActual = new DateTime(anio, mes, 1); 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) 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); var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek);
if (diasDeEntrega.Contains(diaSemanaChar)) if (diasDeEntrega.Contains(diaSemanaChar))
{ {
decimal precioDelDia = 0;
var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction); var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction);
if (precioActivo != null) if (precioActivo != null)
{ {
importeTotal += GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek); precioDelDia = GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek);
} }
else else
{ {
_logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date); _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); fechaActual = fechaActual.AddDays(1);
@@ -159,72 +477,30 @@ namespace GestionIntegral.Api.Services.Suscripciones
return importeTotal; return importeTotal;
} }
public async Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes) private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha)
{ {
var periodo = $"{anio}-{mes:D2}"; switch (promocion.TipoCondicion)
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo);
return facturasData.Select(data => new FacturaDto
{ {
IdFactura = data.Factura.IdFactura, case "Siempre": return true;
IdSuscripcion = data.Factura.IdSuscripcion, case "DiaDeSemana":
Periodo = data.Factura.Periodo, int diaSemanaActual = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek;
FechaEmision = data.Factura.FechaEmision.ToString("yyyy-MM-dd"), return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActual;
FechaVencimiento = data.Factura.FechaVencimiento.ToString("yyyy-MM-dd"), case "PrimerDiaSemanaDelMes":
ImporteFinal = data.Factura.ImporteFinal, int diaSemanaActualMes = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek;
Estado = data.Factura.Estado, return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActualMes && fecha.Day <= 7;
NumeroFactura = data.Factura.NumeroFactura, default: return false;
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.");
} }
} }
private string GetCharDiaSemana(DayOfWeek dia) => dia switch private string GetCharDiaSemana(DayOfWeek dia) => dia switch
{ {
DayOfWeek.Sunday => "D", DayOfWeek.Sunday => "Dom",
DayOfWeek.Monday => "L", DayOfWeek.Monday => "Lun",
DayOfWeek.Tuesday => "M", DayOfWeek.Tuesday => "Mar",
DayOfWeek.Wednesday => "X", DayOfWeek.Wednesday => "Mie",
DayOfWeek.Thursday => "J", DayOfWeek.Thursday => "Jue",
DayOfWeek.Friday => "V", DayOfWeek.Friday => "Vie",
DayOfWeek.Saturday => "S", DayOfWeek.Saturday => "Sab",
_ => "" _ => ""
}; };
@@ -239,5 +515,14 @@ namespace GestionIntegral.Api.Services.Suscripciones
DayOfWeek.Saturday => precio.Sabado ?? 0, DayOfWeek.Saturday => precio.Sabado ?? 0,
_ => 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 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<(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); Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario);
} }
} }

View File

@@ -1,11 +1,15 @@
using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones namespace GestionIntegral.Api.Services.Suscripciones
{ {
public interface IFacturacionService public interface IFacturacionService
{ {
Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes); Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion);
Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura); 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 public interface ISuscripcionService
{ {
Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor);
Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion); Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion);
Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor);
Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario); Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, 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<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); 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(); using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync(); await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
try try
{ {
var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura); var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura);
if (factura == null) return (null, "La factura especificada no existe."); 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); var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago);
if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida."); 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 var nuevoPago = new Pago
{ {
IdFactura = createDto.IdFactura, IdFactura = createDto.IdFactura,
FechaPago = createDto.FechaPago, FechaPago = createDto.FechaPago,
IdFormaPago = createDto.IdFormaPago, IdFormaPago = createDto.IdFormaPago,
Monto = createDto.Monto, Monto = createDto.Monto,
Estado = "Aprobado", // Los pagos manuales se asumen aprobados Estado = "Aprobado",
Referencia = createDto.Referencia, Referencia = createDto.Referencia,
Observaciones = createDto.Observaciones, Observaciones = createDto.Observaciones,
IdUsuarioRegistro = idUsuario IdUsuarioRegistro = idUsuario
}; };
// 1. Crear el registro del pago // Creamos el nuevo pago
var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction);
if (pagoCreado == null) throw new DataException("No se pudo registrar el pago."); 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 // Calculamos el nuevo total EN MEMORIA
// (Permitimos pago mayor por si hay redondeos, etc.) var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto;
if (pagoCreado.Monto >= factura.ImporteFinal)
// 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'."); if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'.");
} }
transaction.Commit(); transaction.Commit();
_logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario); _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); return (dto, null);
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -28,8 +28,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
IdPromocion = promo.IdPromocion, IdPromocion = promo.IdPromocion,
Descripcion = promo.Descripcion, Descripcion = promo.Descripcion,
TipoPromocion = promo.TipoPromocion, TipoEfecto = promo.TipoEfecto,
Valor = promo.Valor, ValorEfecto = promo.ValorEfecto,
TipoCondicion = promo.TipoCondicion,
ValorCondicion = promo.ValorCondicion,
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"), FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"), FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
Activa = promo.Activa Activa = promo.Activa
@@ -58,8 +60,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
var nuevaPromocion = new Promocion var nuevaPromocion = new Promocion
{ {
Descripcion = createDto.Descripcion, Descripcion = createDto.Descripcion,
TipoPromocion = createDto.TipoPromocion, TipoEfecto = createDto.TipoEfecto,
Valor = createDto.Valor, ValorEfecto = createDto.ValorEfecto,
TipoCondicion = createDto.TipoCondicion,
ValorCondicion = createDto.ValorCondicion,
FechaInicio = createDto.FechaInicio, FechaInicio = createDto.FechaInicio,
FechaFin = createDto.FechaFin, FechaFin = createDto.FechaFin,
Activa = createDto.Activa, 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."); return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
} }
// Mapeo
existente.Descripcion = updateDto.Descripcion; existente.Descripcion = updateDto.Descripcion;
existente.TipoPromocion = updateDto.TipoPromocion; existente.TipoEfecto = updateDto.TipoEfecto;
existente.Valor = updateDto.Valor; existente.ValorEfecto = updateDto.ValorEfecto;
existente.TipoCondicion = updateDto.TipoCondicion;
existente.ValorCondicion = updateDto.ValorCondicion;
existente.FechaInicio = updateDto.FechaInicio; existente.FechaInicio = updateDto.FechaInicio;
existente.FechaFin = updateDto.FechaFin; existente.FechaFin = updateDto.FechaFin;
existente.Activa = updateDto.Activa; 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.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data; using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones namespace GestionIntegral.Api.Services.Suscripciones
{ {
@@ -36,8 +41,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
IdPromocion = promo.IdPromocion, IdPromocion = promo.IdPromocion,
Descripcion = promo.Descripcion, Descripcion = promo.Descripcion,
TipoPromocion = promo.TipoPromocion, TipoEfecto = promo.TipoEfecto,
Valor = promo.Valor, ValorEfecto = promo.ValorEfecto,
TipoCondicion = promo.TipoCondicion,
ValorCondicion = promo.ValorCondicion,
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"), FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"), FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
Activa = promo.Activa 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); var asignaciones = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion);
return promociones.Select(MapPromocionToDto); 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) public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion)
{ {
var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true); var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true);
var promosAsignadas = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion); var promosAsignadasData = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion);
var idsAsignadas = promosAsignadas.Select(p => p.IdPromocion).ToHashSet(); var idsAsignadas = promosAsignadasData.Select(p => p.Promocion.IdPromocion).ToHashSet();
return todasLasPromosActivas return todasLasPromosActivas
.Where(p => !idsAsignadas.Contains(p.IdPromocion)) .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(); using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync(); await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
try try
{ {
// Validaciones
if (await _suscripcionRepository.GetByIdAsync(idSuscripcion) == null) return (false, "Suscripción no encontrada."); 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(); transaction.Commit();
return (true, null); return (true, null);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Capturar error de Primary Key duplicada
if (ex.Message.Contains("PRIMARY KEY constraint")) if (ex.Message.Contains("PRIMARY KEY constraint"))
{ {
return (false, "Esta promoción ya está asignada a la suscripción."); return (false, "Esta promoción ya está asignada a la suscripción.");
} }
try { transaction.Rollback(); } catch { } 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."); 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;
using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data; using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones namespace GestionIntegral.Api.Services.Suscripciones
{ {

View File

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

View File

@@ -1,6 +1,10 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx
import React, { useState, useEffect } from 'react'; 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 { 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 { CreateAjusteDto } from '../../../models/dtos/Suscripciones/CreateAjusteDto';
import type { UpdateAjusteDto } from '../../../models/dtos/Suscripciones/UpdateAjusteDto';
import type { AjusteDto } from '../../../models/dtos/Suscripciones/AjusteDto';
const modalStyle = { const modalStyle = {
position: 'absolute' as 'absolute', position: 'absolute' as 'absolute',
@@ -12,34 +16,47 @@ const modalStyle = {
boxShadow: 24, p: 4, boxShadow: 24, p: 4,
}; };
// --- TIPO UNIFICADO PARA EL ESTADO DEL FORMULARIO ---
type AjusteFormData = Partial<CreateAjusteDto & UpdateAjusteDto>;
interface AjusteFormModalProps { interface AjusteFormModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (data: CreateAjusteDto) => Promise<void>; onSubmit: (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => Promise<void>;
initialData?: AjusteDto | null;
idSuscriptor: number; idSuscriptor: number;
errorMessage?: string | null; errorMessage?: string | null;
clearErrorMessage: () => void; clearErrorMessage: () => void;
} }
const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage }) => { const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage, initialData }) => {
const [formData, setFormData] = useState<Partial<CreateAjusteDto>>({}); const [formData, setFormData] = useState<AjusteFormData>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData);
useEffect(() => { useEffect(() => {
if (open) { 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({ setFormData({
idSuscriptor: idSuscriptor, idSuscriptor: initialData?.idSuscriptor || idSuscriptor,
tipoAjuste: 'Credito', // Por defecto es un crédito (descuento) fechaAjuste: fechaParaFormulario,
monto: 0, tipoAjuste: initialData?.tipoAjuste || 'Credito',
motivo: '' monto: initialData?.monto || undefined, // undefined para que el placeholder se muestre
motivo: initialData?.motivo || ''
}); });
setLocalErrors({}); setLocalErrors({});
} }
}, [open, idSuscriptor]); }, [open, initialData, idSuscriptor]);
const validate = (): boolean => { const validate = (): boolean => {
const errors: { [key: string]: string | null } = {}; 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.tipoAjuste) errors.tipoAjuste = "Seleccione un tipo.";
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero."; 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."; 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; return Object.keys(errors).length === 0;
}; };
// --- HANDLERS CON TIPADO EXPLÍCITO ---
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; 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 (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage(); if (errorMessage) clearErrorMessage();
}; };
const handleSelectChange = (e: SelectChangeEvent<any>) => { const handleSelectChange = (e: SelectChangeEvent<string>) => { // Tipado como string
const { name, value } = e.target; 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 (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage(); if (errorMessage) clearErrorMessage();
}; };
@@ -68,7 +89,11 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
setLoading(true); setLoading(true);
let success = false; let success = false;
try { try {
await onSubmit(formData as CreateAjusteDto); if (isEditing && initialData) {
await onSubmit(formData as UpdateAjusteDto, initialData.idAjuste);
} else {
await onSubmit(formData as CreateAjusteDto);
}
success = true; success = true;
} catch (error) { } catch (error) {
success = false; success = false;
@@ -81,20 +106,22 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
return ( return (
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose}>
<Box sx={modalStyle}> <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 }}> <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}> <FormControl fullWidth margin="dense" error={!!localErrors.tipoAjuste}>
<InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel> <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"> <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="Credito">Crédito (Descuento a favor del cliente)</MenuItem>
<MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem> <MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem>
</Select> </Select>
</FormControl> </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="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} /> <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>} {errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" disabled={loading}> <Button type="submit" variant="contained" disabled={loading}>

View File

@@ -1,12 +1,26 @@
import React, { useState, useEffect, useCallback } from 'react'; 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 AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto'; import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto';
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto'; 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'; 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 { interface GestionarPromocionesSuscripcionModalProps {
open: boolean; open: boolean;
@@ -15,12 +29,15 @@ interface GestionarPromocionesSuscripcionModalProps {
} }
const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => { const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => {
const [asignadas, setAsignadas] = useState<PromocionDto[]>([]); const [asignadas, setAsignadas] = useState<PromocionAsignadaDto[]>([]);
const [disponibles, setDisponibles] = useState<PromocionDto[]>([]); const [disponibles, setDisponibles] = useState<PromocionDto[]>([]);
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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 () => { const cargarDatos = useCallback(async () => {
if (!suscripcion) return; if (!suscripcion) return;
setLoading(true); setLoading(true);
@@ -40,16 +57,30 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
}, [suscripcion]); }, [suscripcion]);
useEffect(() => { useEffect(() => {
if (open) { if (open && suscripcion) {
cargarDatos(); cargarDatos();
setSelectedPromo('');
setVigenciaDesde(suscripcion.fechaInicio);
setVigenciaHasta('');
} }
}, [open, cargarDatos]); }, [open, suscripcion]);
const handleAsignar = async () => { 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 { 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(''); setSelectedPromo('');
setVigenciaDesde(suscripcion.fechaInicio);
setVigenciaHasta('');
cargarDatos(); cargarDatos();
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || "Error al asignar la promoción."); 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) => { const handleQuitar = async (idPromocion: number) => {
if (!suscripcion) return; if (!suscripcion) return;
try { setError(null);
await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion); if (window.confirm("¿Está seguro de que desea quitar esta promoción de la suscripción?")) {
cargarDatos(); try {
} catch (err: any) { await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion);
setError(err.response?.data?.message || "Error al quitar la promoción."); 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; if (!suscripcion) return null;
return ( return (
@@ -73,30 +124,39 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
<Box sx={modalStyle}> <Box sx={modalStyle}>
<Typography variant="h6">Gestionar Promociones</Typography> <Typography variant="h6">Gestionar Promociones</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom> <Typography variant="body2" color="text.secondary" gutterBottom>
Suscripción a: {suscripcion.nombrePublicacion} Suscripción a: <strong>{suscripcion.nombrePublicacion}</strong>
</Typography> </Typography>
{error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} {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> <List dense>
{asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>} {asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>}
{asignadas.map(p => ( {asignadas.map(p => (
<ListItem key={p.idPromocion} secondaryAction={<IconButton edge="end" onClick={() => handleQuitar(p.idPromocion)}><DeleteIcon /></IconButton>}> <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> </ListItem>
))} ))}
</List> </List>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Typography>Asignar Nueva Promoción</Typography> <Typography>Asignar Nueva Promoción</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}> <Box sx={{ mt: 1 }}>
<FormControl fullWidth size="small"> <FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel>Promociones Disponibles</InputLabel> <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>)} {disponibles.map(p => <MenuItem key={p.idPromocion} value={p.idPromocion}>{p.descripcion}</MenuItem>)}
</Select> </Select>
</FormControl> </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> </Box>
</> </>
)} )}

View File

@@ -1,5 +1,3 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx
import React, { useState, useEffect } from 'react'; 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 { 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'; import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto';
@@ -54,7 +52,7 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
fetchFormasDePago(); fetchFormasDePago();
setFormData({ setFormData({
idFactura: factura.idFactura, idFactura: factura.idFactura,
monto: factura.importeFinal, monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto
fechaPago: new Date().toISOString().split('T')[0] fechaPago: new Date().toISOString().split('T')[0]
}); });
setLocalErrors({}); setLocalErrors({});
@@ -64,8 +62,18 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
const validate = (): boolean => { const validate = (): boolean => {
const errors: { [key: string]: string | null } = {}; const errors: { [key: string]: string | null } = {};
if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago."; 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."; 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); setLocalErrors(errors);
return Object.keys(errors).length === 0; return Object.keys(errors).length === 0;
}; };
@@ -109,8 +117,8 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose}>
<Box sx={modalStyle}> <Box sx={modalStyle}>
<Typography variant="h6">Registrar Pago Manual</Typography> <Typography variant="h6">Registrar Pago Manual</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom> <Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}>
Factura #{factura.idFactura} para {factura.nombreSuscriptor} Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)}
</Typography> </Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> <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} /> <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 React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, type SelectChangeEvent, InputAdornment } from '@mui/material';
FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox,
type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto'; import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto'; import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto';
const modalStyle = { const modalStyle = {
position: 'absolute' as 'absolute', position: 'absolute' as 'absolute',
top: '50%', top: '50%', left: '50%',
left: '50%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: '80%', md: '600px' }, width: { xs: '95%', sm: '80%', md: '600px' },
bgcolor: 'background.paper', bgcolor: 'background.paper',
border: '2px solid #000', border: '2px solid #000',
boxShadow: 24, boxShadow: 24, p: 4,
p: 4, maxHeight: '90vh', overflowY: 'auto'
maxHeight: '90vh',
overflowY: 'auto'
}; };
const tiposPromocion = [ const tiposEfecto = [
{ value: 'Porcentaje', label: 'Descuento Porcentual (%)' }, { value: 'DescuentoPorcentajeTotal', label: 'Descuento en Porcentaje (%) sobre el total' },
{ value: 'MontoFijo', label: 'Descuento de Monto Fijo ($)' }, { value: 'DescuentoMontoFijoTotal', label: 'Descuento en Monto Fijo ($) sobre el total' },
// { value: 'BonificacionDias', label: 'Bonificación de Días' }, // Descomentar para futuras implementaciones { 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 { interface PromocionFormModalProps {
@@ -38,18 +43,22 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({}); const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData); const isEditing = Boolean(initialData);
const necesitaValorCondicion = formData.tipoCondicion === 'DiaDeSemana' || formData.tipoCondicion === 'PrimerDiaSemanaDelMes';
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFormData(initialData || { const defaults = {
descripcion: '', descripcion: '',
tipoPromocion: 'Porcentaje', tipoEfecto: 'DescuentoPorcentajeTotal' as const,
valor: 0, valorEfecto: 0,
tipoCondicion: 'Siempre' as const,
valorCondicion: null,
fechaInicio: new Date().toISOString().split('T')[0], fechaInicio: new Date().toISOString().split('T')[0],
activa: true activa: true
}); };
setFormData(initialData ? { ...initialData } : defaults);
setLocalErrors({}); setLocalErrors({});
} }
}, [open, initialData]); }, [open, initialData]);
@@ -57,10 +66,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const validate = (): boolean => { const validate = (): boolean => {
const errors: { [key: string]: string | null } = {}; const errors: { [key: string]: string | null } = {};
if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.'; 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.tipoEfecto) errors.tipoEfecto = 'El tipo de efecto es obligatorio.';
if (!formData.valor || formData.valor <= 0) errors.valor = 'El valor debe ser mayor a cero.'; if (formData.tipoEfecto !== 'BonificarEntregaDia' && (!formData.valorEfecto || formData.valorEfecto <= 0)) {
if (formData.tipoPromocion === 'Porcentaje' && (formData.valor ?? 0) > 100) { errors.valorEfecto = 'El valor debe ser mayor a cero.';
errors.valor = 'El valor para porcentaje no puede ser mayor a 100.'; }
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.fechaInicio) errors.fechaInicio = 'La fecha de inicio es obligatoria.';
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) { 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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target; 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 })); setFormData(prev => ({ ...prev, [name]: finalValue }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage(); if (errorMessage) clearErrorMessage();
@@ -80,7 +95,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const handleSelectChange = (e: SelectChangeEvent<any>) => { const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target; 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 (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage(); if (errorMessage) clearErrorMessage();
}; };
@@ -93,11 +117,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
setLoading(true); setLoading(true);
let success = false; let success = false;
try { try {
const dataToSubmit = { const dataToSubmit = { ...formData, fechaFin: formData.fechaFin || null } as CreatePromocionDto | UpdatePromocionDto;
...formData,
fechaFin: formData.fechaFin || null
} as CreatePromocionDto | UpdatePromocionDto;
await onSubmit(dataToSubmit, initialData?.idPromocion); await onSubmit(dataToSubmit, initialData?.idPromocion);
success = true; success = true;
} catch (error) { } catch (error) {
@@ -111,32 +131,43 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
return ( return (
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose}>
<Box sx={modalStyle}> <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 }}> <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 /> <TextField name="descripcion" label="Descripción" value={formData.descripcion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.descripcion} helperText={localErrors.descripcion} disabled={loading} autoFocus />
<FormControl fullWidth margin="dense" error={!!localErrors.tipoEfecto}>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> <InputLabel>Efecto de la Promoción</InputLabel>
<FormControl fullWidth margin="dense" sx={{flex: 2}} error={!!localErrors.tipoPromocion}> <Select name="tipoEfecto" value={formData.tipoEfecto || ''} onChange={handleSelectChange} label="Efecto de la Promoción" disabled={loading}>
<InputLabel id="tipo-promo-label" required>Tipo</InputLabel> {tiposEfecto.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
<Select name="tipoPromocion" labelId="tipo-promo-label" value={formData.tipoPromocion || ''} onChange={handleSelectChange} label="Tipo" disabled={loading}> </Select>
{tiposPromocion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)} </FormControl>
</Select> {formData.tipoEfecto !== 'BonificarEntregaDia' && (
</FormControl> <TextField name="valorEfecto" label="Valor" type="number" value={formData.valorEfecto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.valorEfecto} helperText={localErrors.valorEfecto} disabled={loading}
<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.tipoEfecto === 'DescuentoPorcentajeTotal' ? '%' : '$'}</InputAdornment> }}
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoPromocion === 'Porcentaje' ? '%' : '$'}</InputAdornment> }}
inputProps={{ step: "0.01" }} inputProps={{ step: "0.01" }}
/> />
</Box> )}
<FormControl fullWidth margin="dense" error={!!localErrors.tipoCondicion}>
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}> <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="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} /> <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> </Box>
<FormControlLabel control={<Checkbox name="activa" checked={formData.activa ?? true} onChange={handleInputChange} disabled={loading}/>} label="Promoción Activa" sx={{mt: 1}} /> <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>} {errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" disabled={loading}> <Button type="submit" variant="contained" disabled={loading}>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
export interface AjusteDto { export interface AjusteDto {
idAjuste: number; idAjuste: number;
fechaAjuste: string;
idSuscriptor: number; idSuscriptor: number;
tipoAjuste: 'Credito' | 'Debito'; tipoAjuste: 'Credito' | 'Debito';
monto: number; 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 { export interface CreateAjusteDto {
fechaAjuste: string;
idSuscriptor: number; idSuscriptor: number;
tipoAjuste: 'Credito' | 'Debito'; tipoAjuste: 'Credito' | 'Debito';
monto: number; monto: number;

View File

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

View File

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

View File

@@ -1,14 +1,20 @@
export interface FacturaDetalleDto {
descripcion: string;
importeNeto: number;
}
export interface FacturaDto { export interface FacturaDto {
idFactura: number; idFactura: number;
idSuscripcion: number; idSuscriptor: number;
periodo: string; // "YYYY-MM" periodo: string;
fechaEmision: string; // "yyyy-MM-dd" fechaEmision: string;
fechaVencimiento: string; // "yyyy-MM-dd" fechaVencimiento: string;
importeFinal: number; importeFinal: number;
estado: string; totalPagado: number;
saldoPendiente: number;
estadoPago: string;
estadoFacturacion: string;
numeroFactura?: string | null; numeroFactura?: string | null;
// Datos enriquecidos para la UI
nombreSuscriptor: string; 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 { export interface PromocionDto {
idPromocion: number; idPromocion: number;
descripcion: string; descripcion: string;
tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias'; tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia';
valor: number; valorEfecto: number;
tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes';
valorCondicion?: number | null;
fechaInicio: string; // "yyyy-MM-dd" fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null; fechaFin?: string | null;
activa: boolean; 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" fechaInicio: string; // "yyyy-MM-dd"
fechaFin?: string | null; fechaFin?: string | null;
estado: 'Activa' | 'Pausada' | 'Cancelada'; 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; 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: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' },
{ category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' }, { 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: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' },
{ category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' },
]; ];
const predefinedCategoryOrder = [ const predefinedCategoryOrder = [
@@ -30,6 +31,7 @@ const predefinedCategoryOrder = [
'Listados Distribución', 'Listados Distribución',
'Ctrl. Devoluciones', 'Ctrl. Devoluciones',
'Novedades de Canillitas', 'Novedades de Canillitas',
'Suscripciones',
'Existencia Papel', 'Existencia Papel',
'Movimientos Bobinas', 'Movimientos Bobinas',
'Consumos 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 React, { useState } 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 { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import DownloadIcon from '@mui/icons-material/Download'; 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 UploadFileIcon from '@mui/icons-material/UploadFile';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import facturacionService from '../../services/Suscripciones/facturacionService'; import facturacionService from '../../services/Suscripciones/facturacionService';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios'; 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 anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
const meses = [ const meses = [
@@ -35,37 +29,14 @@ const FacturacionPage: React.FC = () => {
const [loadingProceso, setLoadingProceso] = useState(false); const [loadingProceso, setLoadingProceso] = useState(false);
const [apiMessage, setApiMessage] = useState<string | null>(null); const [apiMessage, setApiMessage] = useState<string | null>(null);
const [apiError, setApiError] = 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 { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006"); const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006");
const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007"); 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 () => { 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; return;
} }
setLoading(true); setLoading(true);
@@ -74,7 +45,6 @@ const FacturacionPage: React.FC = () => {
try { try {
const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes); const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes);
setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`); setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`);
await cargarFacturasDelPeriodo();
} catch (err: any) { } catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message ? err.response.data.message
@@ -103,7 +73,6 @@ const FacturacionPage: React.FC = () => {
link.parentNode?.removeChild(link); link.parentNode?.removeChild(link);
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`); setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`);
cargarFacturasDelPeriodo();
} catch (err: any) { } catch (err: any) {
let message = 'Ocurrió un error al generar el archivo.'; let message = 'Ocurrió un error al generar el archivo.';
if (axios.isAxiosError(err) && err.response) { 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>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) { if (event.target.files && event.target.files.length > 0) {
setArchivoSeleccionado(event.target.files[0]); setArchivoSeleccionado(event.target.files[0]);
@@ -187,7 +110,6 @@ const FacturacionPage: React.FC = () => {
if (response.errores?.length > 0) { if (response.errores?.length > 0) {
setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`); setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`);
} }
cargarFacturasDelPeriodo(); // Recargar para ver los estados finales
} catch (err: any) { } catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen
? 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>; 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 ( return (
<Box sx={{ p: 1 }}> <Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Facturación y Débito Automático</Typography> <Typography variant="h5" gutterBottom>Procesos Mensuales de Suscripciones</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6">1. Generación de Facturación</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 }}> <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> </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"> <FormControl sx={{ minWidth: 120 }} size="small">
<InputLabel>Mes</InputLabel> <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> <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> <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> </FormControl>
</Box> </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>
<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="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> <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>
<Paper sx={{ p: 2, mb: 2 }}> <Paper sx={{ p: 1, mb: 1 }}>
<Typography variant="h6">3. Procesar Respuesta del Banco</Typography> <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". Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada".
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button <Button
component="label" component="label"
role={undefined} role={undefined}
@@ -262,58 +192,8 @@ const FacturacionPage: React.FC = () => {
)} )}
</Paper> </Paper>
{apiError && <Alert severity="error" sx={{ my: 2 }}>{apiError}</Alert>} {apiError && <Alert severity="error" sx={{ my: 1 }}>{apiError}</Alert>}
{apiMessage && <Alert severity="success" sx={{ my: 2 }}>{apiMessage}</Alert>} {apiMessage && <Alert severity="success" sx={{ my: 1 }}>{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)} />
</Box> </Box>
); );
}; };

View File

@@ -79,10 +79,13 @@ const GestionarPromocionesPage: React.FC = () => {
return `${parts[2]}/${parts[1]}/${parts[0]}`; return `${parts[2]}/${parts[1]}/${parts[0]}`;
}; };
const formatTipo = (tipo: string) => { const formatTipo = (tipo: PromocionDto['tipoEfecto']) => {
if (tipo === 'MontoFijo') return 'Monto Fijo'; switch(tipo) {
if (tipo === 'Porcentaje') return 'Porcentaje'; case 'DescuentoMontoFijoTotal': return 'Monto Fijo';
return tipo; 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>; if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -106,7 +109,7 @@ const GestionarPromocionesPage: React.FC = () => {
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{fontWeight: 'bold'}}>Descripción</TableCell> <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 align="right" sx={{fontWeight: 'bold'}}>Valor</TableCell>
<TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell> <TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell>
<TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell> <TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell>
@@ -121,8 +124,12 @@ const GestionarPromocionesPage: React.FC = () => {
promociones.map(p => ( promociones.map(p => (
<TableRow key={p.idPromocion} hover> <TableRow key={p.idPromocion} hover>
<TableCell>{p.descripcion}</TableCell> <TableCell>{p.descripcion}</TableCell>
<TableCell>{formatTipo(p.tipoPromocion)}</TableCell> <TableCell>{formatTipo(p.tipoEfecto)}</TableCell>
<TableCell align="right">{p.tipoPromocion === 'Porcentaje' ? `${p.valor}%` : `$${p.valor.toFixed(2)}`}</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.fechaInicio)}</TableCell>
<TableCell>{formatDate(p.fechaFin)}</TableCell> <TableCell>{formatDate(p.fechaFin)}</TableCell>
<TableCell align="center"> <TableCell align="center">
@@ -130,9 +137,11 @@ const GestionarPromocionesPage: React.FC = () => {
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
<Tooltip title="Editar Promoción"> <Tooltip title="Editar Promoción">
<IconButton onClick={() => handleOpenModal(p)}> <span>
<EditIcon /> <IconButton onClick={() => handleOpenModal(p)} disabled={!puedeGestionar}>
</IconButton> <EditIcon />
</IconButton>
</span>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
</TableRow> </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 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 { 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 AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import LoyaltyIcon from '@mui/icons-material/Loyalty'; import LoyaltyIcon from '@mui/icons-material/Loyalty';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import suscripcionService from '../../services/Suscripciones/suscripcionService'; import suscripcionService from '../../services/Suscripciones/suscripcionService';
import suscriptorService from '../../services/Suscripciones/suscriptorService';
import type { SuscripcionDto } from '../../models/dtos/Suscripciones/SuscripcionDto'; 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 { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto'; import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
import SuscripcionFormModal from '../../components/Modals/Suscripciones/SuscripcionFormModal'; import SuscripcionFormModal from '../../components/Modals/Suscripciones/SuscripcionFormModal';
@@ -14,11 +18,12 @@ import GestionarPromocionesSuscripcionModal from '../../components/Modals/Suscri
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios'; import axios from 'axios';
interface SuscripcionesTabProps { const GestionarSuscripcionesDeClientePage: React.FC = () => {
idSuscriptor: number; 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 [suscripciones, setSuscripciones] = useState<SuscripcionDto[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -32,28 +37,32 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) =>
const puedeGestionar = isSuperAdmin || tienePermiso("SU005"); const puedeGestionar = isSuperAdmin || tienePermiso("SU005");
const cargarDatos = useCallback(async () => { const cargarDatos = useCallback(async () => {
setLoading(true); if (isNaN(idSuscriptor)) {
setApiErrorMessage(null); setError("ID de Suscriptor inválido."); setLoading(false); return;
}
setLoading(true); setApiErrorMessage(null); setError(null);
try { try {
const data = await suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor); const [suscriptorData, suscripcionesData] = await Promise.all([
setSuscripciones(data); suscriptorService.getSuscriptorById(idSuscriptor),
suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor)
]);
setSuscriptor(suscriptorData);
setSuscripciones(suscripcionesData);
} catch (err) { } catch (err) {
setError('Error al cargar las suscripciones del cliente.'); setError('Error al cargar los datos.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [idSuscriptor]); }, [idSuscriptor]);
useEffect(() => { useEffect(() => { cargarDatos(); }, [cargarDatos]);
cargarDatos();
}, [cargarDatos]);
const handleOpenModal = (suscripcion?: SuscripcionDto) => { const handleOpenModal = (suscripcion?: SuscripcionDto) => {
setEditingSuscripcion(suscripcion || null); setEditingSuscripcion(suscripcion || null);
setApiErrorMessage(null); setApiErrorMessage(null);
setModalOpen(true); setModalOpen(true);
}; };
const handleCloseModal = () => { const handleCloseModal = () => {
setModalOpen(false); setModalOpen(false);
setEditingSuscripcion(null); setEditingSuscripcion(null);
@@ -86,13 +95,18 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) =>
return `${parts[2]}/${parts[1]}/${parts[0]}`; return `${parts[2]}/${parts[1]}/${parts[0]}`;
}; };
if (loading) return <CircularProgress />; if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
if (error) return <Alert severity="error">{error}</Alert>; if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>;
return ( return (
<Box> <Box>
<Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}>
<Typography variant="h6">Suscripciones Contratadas</Typography> 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 && ( {puedeGestionar && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}> <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>
Nueva Suscripción 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 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 { 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'; import AddIcon from '@mui/icons-material/Add';
@@ -16,188 +14,220 @@ import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios'; import axios from 'axios';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ArticleIcon from '@mui/icons-material/Article'; import ArticleIcon from '@mui/icons-material/Article';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
const GestionarSuscriptoresPage: React.FC = () => { const GestionarSuscriptoresPage: React.FC = () => {
const [suscriptores, setSuscriptores] = useState<SuscriptorDto[]>([]); const [suscriptores, setSuscriptores] = useState<SuscriptorDto[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filtroNombre, setFiltroNombre] = useState(''); const [filtroNombre, setFiltroNombre] = useState('');
const [filtroNroDoc, setFiltroNroDoc] = useState(''); const [filtroNroDoc, setFiltroNroDoc] = useState('');
const [filtroSoloActivos, setFiltroSoloActivos] = useState(true); const [filtroSoloActivos, setFiltroSoloActivos] = useState(true);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingSuscriptor, setEditingSuscriptor] = useState<SuscriptorDto | null>(null); const [editingSuscriptor, setEditingSuscriptor] = useState<SuscriptorDto | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(15); const [rowsPerPage, setRowsPerPage] = useState(15);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedRow, setSelectedRow] = useState<SuscriptorDto | null>(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 puedeVer = isSuperAdmin || tienePermiso("SU001");
const puedeCrear = isSuperAdmin || tienePermiso("SU002"); const puedeCrear = isSuperAdmin || tienePermiso("SU002");
const puedeModificar = isSuperAdmin || tienePermiso("SU003"); const puedeModificar = isSuperAdmin || tienePermiso("SU003");
const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004"); const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004");
const puedeVerSuscripciones = isSuperAdmin || tienePermiso("SU005");
const puedeVerCuentaCorriente = isSuperAdmin || tienePermiso("SU011");
const navigate = useNavigate(); const cargarSuscriptores = useCallback(async () => {
if (!puedeVer) {
const cargarSuscriptores = useCallback(async () => { setError("No tiene permiso para ver esta sección.");
if (!puedeVer) { setLoading(false);
setError("No tiene permiso para ver esta sección."); return;
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);
} }
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();
} catch (err: any) { }, [cargarSuscriptores]);
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`;
setApiErrorMessage(message); 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) => { return (
setAnchorEl(event.currentTarget); <Box sx={{ p: 1 }}>
setSelectedRow(suscriptor); <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 = () => { {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
setAnchorEl(null); {error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
setSelectedRow(null); {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
};
const handleNavigateToSuscripciones = (idSuscriptor: number) => { {!loading && !error && (
navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`); <>
handleMenuClose(); <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); <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => { {selectedRow && puedeModificar && (
setRowsPerPage(parseInt(event.target.value, 10)); <MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}>
setPage(0); <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); <SuscriptorFormModal open={modalOpen} onClose={handleCloseModal} onSubmit={handleSubmitModal} initialData={editingSuscriptor} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} />
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" />
</Box> </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; 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 { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
// Define las pestañas del módulo. Ajusta los permisos según sea necesario.
const suscripcionesSubModules = [ const suscripcionesSubModules = [
{ label: 'Suscriptores', path: 'suscriptores', requiredPermission: 'SU001' }, { 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' }, { label: 'Promociones', path: 'promociones', requiredPermission: 'SU010' },
]; ];
const SuscripcionesIndexPage: React.FC = () => { const SuscripcionesIndexPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { tienePermiso, isSuperAdmin } = usePermissions(); const { tienePermiso, isSuperAdmin } = usePermissions();
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false); 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
);
if (activeTabIndex !== -1) {
setSelectedSubTab(activeTabIndex);
} else if (location.pathname === currentBasePath) {
navigate(accessibleSubModules[0].path, { replace: true });
} else {
setSelectedSubTab(false);
}
}, [location.pathname, navigate, accessibleSubModules]);
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => { const accessibleSubModules = suscripcionesSubModules.filter(
navigate(accessibleSubModules[newValue].path); (subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission)
}; );
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 (accessibleSubModules.length === 0) { if (activeTabIndex !== -1) {
return <Typography sx={{ p: 2 }}>No tiene permisos para acceder a este módulo.</Typography>; 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
return ( navigate(accessibleSubModules[0].path, { replace: true });
<Box> } else {
<Typography variant="h5" gutterBottom>Módulo de Suscripciones</Typography> setSelectedSubTab(false); // Ninguna pestaña activa
<Paper square elevation={1}> }
<Tabs }, [location.pathname, navigate, accessibleSubModules]);
value={selectedSubTab}
onChange={handleSubTabChange} const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
indicatorColor="primary" navigate(accessibleSubModules[newValue].path);
textColor="primary" };
variant="scrollable"
scrollButtons="auto" if (accessibleSubModules.length === 0) {
aria-label="sub-módulos de suscripciones" return <Typography sx={{ p: 2 }}>No tiene permisos para acceder a este módulo.</Typography>;
> }
{accessibleSubModules.map((subModule) => (
<Tab key={subModule.path} label={subModule.label} /> return (
))} <Box>
</Tabs> <Typography variant="h5" gutterBottom>Módulo de Suscripciones</Typography>
</Paper> <Paper square elevation={1}>
<Box sx={{ pt: 2 }}> <Tabs
<Outlet /> 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>
</Box> );
);
}; };
export default SuscripcionesIndexPage; export default SuscripcionesIndexPage;

View File

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

View File

@@ -445,6 +445,25 @@ const getListadoDistMensualPorPublicacionPdf = async (params: GetListadoDistMens
return response.data; 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 = { const reportesService = {
getExistenciaPapel, getExistenciaPapel,
getExistenciaPapelPdf, getExistenciaPapelPdf,
@@ -487,7 +506,8 @@ const reportesService = {
getListadoDistMensualDiarios, getListadoDistMensualDiarios,
getListadoDistMensualDiariosPdf, getListadoDistMensualDiariosPdf,
getListadoDistMensualPorPublicacion, getListadoDistMensualPorPublicacion,
getListadoDistMensualPorPublicacionPdf, getListadoDistMensualPorPublicacionPdf,
getReporteFacturasPublicidadPdf,
}; };
export default reportesService; export default reportesService;

View File

@@ -1,12 +1,26 @@
import apiClient from '../apiClient'; import apiClient from '../apiClient';
import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto'; import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto';
import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto'; 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_BY_SUSCRIPTOR = '/suscriptores';
const API_URL_BASE = '/ajustes'; const API_URL_BASE = '/ajustes';
const getAjustesPorSuscriptor = async (idSuscriptor: number): Promise<AjusteDto[]> => { const getAjustesPorSuscriptor = async (idSuscriptor: number, fechaDesde?: string, fechaHasta?: string): Promise<AjusteDto[]> => {
const response = await apiClient.get<AjusteDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes`); // 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; return response.data;
}; };
@@ -19,8 +33,13 @@ const anularAjuste = async (idAjuste: number): Promise<void> => {
await apiClient.post(`${API_URL_BASE}/${idAjuste}/anular`); 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 { export default {
getAjustesPorSuscriptor, getAjustesPorSuscriptor,
createAjusteManual, createAjusteManual,
anularAjuste anularAjuste,
updateAjuste,
}; };

View File

@@ -1,29 +1,33 @@
import apiClient from '../apiClient'; import apiClient from '../apiClient';
import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto';
import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto'; import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto';
import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto'; import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto';
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto'; import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto';
import type { ResumenCuentaSuscriptorDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
const API_URL = '/facturacion'; const API_URL = '/facturacion';
const DEBITOS_URL = '/debitos'; const DEBITOS_URL = '/debitos';
const PAGOS_URL = '/pagos'; const PAGOS_URL = '/pagos';
const FACTURAS_URL = '/facturas';
const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLoteResponseDto> => { const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLoteResponseDto> => {
const formData = new FormData(); const formData = new FormData();
formData.append('archivo', archivo); formData.append('archivo', archivo);
const response = await apiClient.post<ProcesamientoLoteResponseDto>(`${DEBITOS_URL}/procesar-respuesta`, formData, { const response = await apiClient.post<ProcesamientoLoteResponseDto>(`${DEBITOS_URL}/procesar-respuesta`, formData, {
headers: { headers: { 'Content-Type': 'multipart/form-data' },
'Content-Type': 'multipart/form-data',
},
}); });
return response.data; return response.data;
}; };
const getFacturasPorPeriodo = async (anio: number, mes: number): Promise<FacturaDto[]> => { const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise<ResumenCuentaSuscriptorDto[]> => {
const response = await apiClient.get<FacturaDto[]>(`${API_URL}/${anio}/${mes}`); 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; 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`, {}, { const response = await apiClient.post(`${DEBITOS_URL}/${anio}/${mes}/generar-archivo`, {}, {
responseType: 'blob', responseType: 'blob',
}); });
const contentDisposition = response.headers['content-disposition']; const contentDisposition = response.headers['content-disposition'];
let fileName = `debito_${anio}_${mes}.txt`; let fileName = `debito_${anio}_${mes}.txt`;
if (contentDisposition) { if (contentDisposition) {
@@ -45,30 +48,35 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo
fileName = fileNameMatch[1]; fileName = fileNameMatch[1];
} }
} }
return { fileContent: response.data, fileName: fileName }; 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 registrarPagoManual = async (data: CreatePagoDto): Promise<PagoDto> => {
const response = await apiClient.post<PagoDto>(PAGOS_URL, data); const response = await apiClient.post<PagoDto>(PAGOS_URL, data);
return response.data; return response.data;
}; };
const enviarFacturaPorEmail = async (idFactura: number): Promise<void> => { const actualizarNumeroFactura = async (idFactura: number, numeroFactura: string): Promise<void> => {
await apiClient.post(`${API_URL}/${idFactura}/enviar-email`); 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 { export default {
procesarArchivoRespuesta, procesarArchivoRespuesta,
getFacturasPorPeriodo, getResumenesDeCuentaPorPeriodo,
generarFacturacionMensual, generarFacturacionMensual,
generarArchivoDebito, generarArchivoDebito,
getPagosPorFactura,
registrarPagoManual, 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 { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto'; import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto'; 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_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 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; return response.data;
}; };
@@ -26,8 +29,8 @@ const updateSuscripcion = async (id: number, data: UpdateSuscripcionDto): Promis
await apiClient.put(`${API_URL_BASE}/${id}`, data); await apiClient.put(`${API_URL_BASE}/${id}`, data);
}; };
const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionDto[]> => { const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionAsignadaDto[]> => {
const response = await apiClient.get<PromocionDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`); const response = await apiClient.get<PromocionAsignadaDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`);
return response.data; return response.data;
}; };
@@ -36,8 +39,8 @@ const getPromocionesDisponibles = async (idSuscripcion: number): Promise<Promoci
return response.data; return response.data;
}; };
const asignarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => { const asignarPromocion = async (idSuscripcion: number, data: AsignarPromocionDto): Promise<void> => {
await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`); await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones`, data);
}; };
const quitarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => { const quitarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => {
@@ -52,5 +55,5 @@ export default {
getPromocionesAsignadas, getPromocionesAsignadas,
getPromocionesDisponibles, getPromocionesDisponibles,
asignarPromocion, asignarPromocion,
quitarPromocion quitarPromocion,
}; };