diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs index c4df979..6505c35 100644 --- a/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs @@ -1,4 +1,6 @@ +using GestionIntegral.Api.Dtos.Comunicaciones; using GestionIntegral.Api.Dtos.Suscripciones; +using GestionIntegral.Api.Services.Comunicaciones; using GestionIntegral.Api.Services.Suscripciones; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,13 +15,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones { private readonly IFacturacionService _facturacionService; private readonly ILogger _logger; + private readonly IEmailLogService _emailLogService; private const string PermisoGestionarFacturacion = "SU006"; private const string PermisoEnviarEmail = "SU009"; - public FacturacionController(IFacturacionService facturacionService, ILogger logger) + public FacturacionController(IFacturacionService facturacionService, ILogger logger, IEmailLogService emailLogService) { _facturacionService = facturacionService; _logger = logger; + _emailLogService = emailLogService; } private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); @@ -55,14 +59,17 @@ namespace GestionIntegral.Api.Controllers.Suscripciones public async Task EnviarFacturaPdf(int idFactura) { if (!TienePermiso("SU009")) return Forbid(); - - var (exito, error) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura); + var userId = GetCurrentUserId(); + if (userId == null) return Unauthorized(); + var (exito, error, emailDestino) = await _facturacionService.EnviarFacturaPdfPorEmail(idFactura, userId.Value); if (!exito) { return BadRequest(new { message = error }); } - return Ok(new { message = "Email con factura PDF enviado a la cola de procesamiento." }); + + var mensajeExito = $"El email con la factura PDF se ha enviado correctamente a {emailDestino}."; + return Ok(new { message = mensajeExito }); } // POST: api/facturacion/{anio}/{mes}/suscriptor/{idSuscriptor}/enviar-aviso @@ -116,5 +123,13 @@ namespace GestionIntegral.Api.Controllers.Suscripciones if (!exito) return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje }); return Ok(new { message = mensaje, facturasGeneradas }); } + + [HttpGet("{idFactura:int}/historial-envios")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetHistorialEnvios(int idFactura) + { + var historial = await _emailLogService.ObtenerHistorialPorReferencia("Factura-" + idFactura); + return Ok(historial); + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/EmailLogRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/EmailLogRepository.cs new file mode 100644 index 0000000..428be53 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/EmailLogRepository.cs @@ -0,0 +1,46 @@ +using Dapper; +using GestionIntegral.Api.Models.Comunicaciones; + +namespace GestionIntegral.Api.Data.Repositories.Comunicaciones +{ + public class EmailLogRepository : IEmailLogRepository + { + private readonly DbConnectionFactory _connectionFactory; + private readonly ILogger _logger; + public EmailLogRepository(DbConnectionFactory connectionFactory, ILogger logger) + { + _connectionFactory = connectionFactory; + _logger = logger; + } + + public async Task CreateAsync(EmailLog log) + { + const string sql = @" + INSERT INTO dbo.com_EmailLogs + (FechaEnvio, DestinatarioEmail, Asunto, Estado, Error, IdUsuarioDisparo, Origen, ReferenciaId) + VALUES + (@FechaEnvio, @DestinatarioEmail, @Asunto, @Estado, @Error, @IdUsuarioDisparo, @Origen, @ReferenciaId);"; + + using var connection = _connectionFactory.CreateConnection(); + await connection.ExecuteAsync(sql, log); + } + + public async Task> GetByReferenceAsync(string referenciaId) + { + const string sql = @" + SELECT * FROM dbo.com_EmailLogs + WHERE ReferenciaId = @ReferenciaId + ORDER BY FechaEnvio DESC;"; + try + { + using var connection = _connectionFactory.CreateConnection(); + return await connection.QueryAsync(sql, new { ReferenciaId = referenciaId }); + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error al obtener logs de email por ReferenciaId: {ReferenciaId}", referenciaId); + return Enumerable.Empty(); + } + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/IEmailLogRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/IEmailLogRepository.cs new file mode 100644 index 0000000..e137959 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Comunicaciones/IEmailLogRepository.cs @@ -0,0 +1,19 @@ +using GestionIntegral.Api.Models.Comunicaciones; + +namespace GestionIntegral.Api.Data.Repositories.Comunicaciones +{ + public interface IEmailLogRepository + { + /// + /// Guarda un nuevo registro de log de email en la base de datos. + /// + Task CreateAsync(EmailLog log); + + /// + /// Obtiene todos los registros de log de email que coinciden con una referencia específica. + /// + /// El identificador de la entidad (ej. "Factura-59"). + /// Una colección de registros de log de email. + Task> GetByReferenceAsync(string referenciaId); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Comunicaciones/EmailLog.cs b/Backend/GestionIntegral.Api/Models/Comunicaciones/EmailLog.cs new file mode 100644 index 0000000..fd78b8e --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Comunicaciones/EmailLog.cs @@ -0,0 +1,15 @@ +namespace GestionIntegral.Api.Models.Comunicaciones +{ + public class EmailLog + { + public int IdEmailLog { get; set; } + public DateTime FechaEnvio { get; set; } + public string DestinatarioEmail { get; set; } = string.Empty; + public string Asunto { get; set; } = string.Empty; + public string Estado { get; set; } = string.Empty; + public string? Error { get; set; } + public int? IdUsuarioDisparo { get; set; } + public string? Origen { get; set; } + public string? ReferenciaId { get; set; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Comunicaciones/EmailLogDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Comunicaciones/EmailLogDto.cs new file mode 100644 index 0000000..ab4b9ed --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Comunicaciones/EmailLogDto.cs @@ -0,0 +1,20 @@ +namespace GestionIntegral.Api.Dtos.Comunicaciones +{ + /// + /// Representa un registro de historial de envío de correo para ser mostrado en la interfaz de usuario. + /// + public class EmailLogDto + { + public DateTime FechaEnvio { get; set; } + public string Estado { get; set; } = string.Empty; + public string Asunto { get; set; } = string.Empty; + public string DestinatarioEmail { get; set; } = string.Empty; + public string? Error { get; set; } + + /// + /// Nombre del usuario que inició la acción de envío (ej. "Juan Pérez"). + /// Puede ser "Sistema" si el envío fue automático (ej. Cierre Mensual). + /// + public string? NombreUsuarioDisparo { get; set; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Program.cs b/Backend/GestionIntegral.Api/Program.cs index bbbd06b..f58f5c6 100644 --- a/Backend/GestionIntegral.Api/Program.cs +++ b/Backend/GestionIntegral.Api/Program.cs @@ -22,6 +22,7 @@ using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Services.Suscripciones; using GestionIntegral.Api.Models.Comunicaciones; using GestionIntegral.Api.Services.Comunicaciones; +using GestionIntegral.Api.Data.Repositories.Comunicaciones; var builder = WebApplication.CreateBuilder(args); @@ -127,6 +128,8 @@ builder.Services.AddScoped(); // --- Comunicaciones --- builder.Services.Configure(builder.Configuration.GetSection("MailSettings")); builder.Services.AddTransient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // --- SERVICIO DE HEALTH CHECKS --- // Añadimos una comprobación específica para SQL Server. diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailLogService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailLogService.cs new file mode 100644 index 0000000..ada0d36 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailLogService.cs @@ -0,0 +1,53 @@ +using GestionIntegral.Api.Data.Repositories.Comunicaciones; +using GestionIntegral.Api.Data.Repositories.Usuarios; +using GestionIntegral.Api.Dtos.Comunicaciones; + +namespace GestionIntegral.Api.Services.Comunicaciones +{ + public class EmailLogService : IEmailLogService + { + private readonly IEmailLogRepository _emailLogRepository; + private readonly IUsuarioRepository _usuarioRepository; + + public EmailLogService(IEmailLogRepository emailLogRepository, IUsuarioRepository usuarioRepository) + { + _emailLogRepository = emailLogRepository; + _usuarioRepository = usuarioRepository; + } + + public async Task> ObtenerHistorialPorReferencia(string referenciaId) + { + var logs = await _emailLogRepository.GetByReferenceAsync(referenciaId); + if (!logs.Any()) + { + return Enumerable.Empty(); + } + + // Optimización N+1: Obtener todos los usuarios necesarios en una sola consulta + var idsUsuarios = logs + .Where(l => l.IdUsuarioDisparo.HasValue) + .Select(l => l.IdUsuarioDisparo!.Value) + .Distinct(); + + var usuariosDict = new Dictionary(); + if (idsUsuarios.Any()) + { + var usuarios = await _usuarioRepository.GetByIdsAsync(idsUsuarios); + usuariosDict = usuarios.ToDictionary(u => u.Id, u => $"{u.Nombre} {u.Apellido}"); + } + + // Mapear a DTO + return logs.Select(log => new EmailLogDto + { + FechaEnvio = log.FechaEnvio, + Estado = log.Estado, + Asunto = log.Asunto, + DestinatarioEmail = log.DestinatarioEmail, + Error = log.Error, + NombreUsuarioDisparo = log.IdUsuarioDisparo.HasValue + ? usuariosDict.GetValueOrDefault(log.IdUsuarioDisparo.Value, "Usuario Desconocido") + : "Sistema" + }); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs index 47be05b..31bd8ae 100644 --- a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs @@ -1,3 +1,4 @@ +using GestionIntegral.Api.Data.Repositories.Comunicaciones; using GestionIntegral.Api.Models.Comunicaciones; using MailKit.Net.Smtp; using MailKit.Security; @@ -10,14 +11,22 @@ namespace GestionIntegral.Api.Services.Comunicaciones { private readonly MailSettings _mailSettings; private readonly ILogger _logger; + private readonly IEmailLogRepository _emailLogRepository; - public EmailService(IOptions mailSettings, ILogger logger) + public EmailService( + IOptions mailSettings, + ILogger logger, + IEmailLogRepository emailLogRepository) // Inyectar el nuevo repositorio { _mailSettings = mailSettings.Value; _logger = logger; + _emailLogRepository = emailLogRepository; } - public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, byte[]? attachment = null, string? attachmentName = null) + public async Task EnviarEmailAsync( + string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, + byte[]? attachment = null, string? attachmentName = null, + string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null) { var email = new MimeMessage(); email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail); @@ -32,26 +41,14 @@ namespace GestionIntegral.Api.Services.Comunicaciones } 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 enviado exitosamente a {Destinatario}", destinatarioEmail); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error al enviar email a {Destinatario}", destinatarioEmail); - throw; // Relanzar para que el servicio que lo llamó sepa que falló - } - finally - { - await smtp.DisconnectAsync(true); - } + // Llamar al método centralizado de envío y logging + await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo); } - public async Task EnviarEmailConsolidadoAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, List<(byte[] content, string name)> adjuntos) + public async Task EnviarEmailConsolidadoAsync( + string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml, + List<(byte[] content, string name)> adjuntos, + string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null) { var email = new MimeMessage(); email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail); @@ -60,7 +57,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones email.Subject = asunto; var builder = new BodyBuilder { HtmlBody = cuerpoHtml }; - if (adjuntos != null) { foreach (var adjunto in adjuntos) @@ -68,25 +64,80 @@ namespace GestionIntegral.Api.Services.Comunicaciones builder.Attachments.Add(adjunto.name, adjunto.content, ContentType.Parse("application/pdf")); } } - email.Body = builder.ToMessageBody(); + // Llamar al método centralizado de envío y logging + await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo); + } + + /// + /// Método privado que centraliza el envío de correo y el registro de logs. + /// + private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo) + { + var destinatario = emailMessage.To.Mailboxes.FirstOrDefault()?.Address ?? "desconocido"; + + var log = new EmailLog + { + FechaEnvio = DateTime.Now, + DestinatarioEmail = destinatario, + Asunto = emailMessage.Subject, + Origen = origen, + ReferenciaId = referenciaId, + IdUsuarioDisparo = idUsuarioDisparo + }; + 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); + await smtp.SendAsync(emailMessage); + + log.Estado = "Enviado"; + _logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); } - catch (Exception ex) + // Capturamos excepciones específicas de MailKit para obtener errores más detallados. + catch (SmtpCommandException scEx) { - _logger.LogError(ex, "Error al enviar email consolidado a {Destinatario}", destinatarioEmail); + // Este error ocurre cuando el servidor SMTP rechaza un comando. + // Es el caso más común para direcciones de email inválidas que son rechazadas inmediatamente. + _logger.LogError(scEx, "Error de comando SMTP al enviar a {Destinatario}. StatusCode: {StatusCode}", destinatario, scEx.StatusCode); + log.Estado = "Fallido"; + log.Error = $"Error del servidor: ({scEx.StatusCode}) {scEx.Message}"; + throw; + } + catch (AuthenticationException authEx) + { + // Error específico de autenticación. + _logger.LogError(authEx, "Error de autenticación con el servidor SMTP."); + log.Estado = "Fallido"; + log.Error = "Error de autenticación. Revise las credenciales de correo."; + throw; + } + catch (Exception ex) // Captura genérica para cualquier otro problema (conexión, etc.) + { + _logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); + log.Estado = "Fallido"; + log.Error = ex.Message; throw; } finally { - await smtp.DisconnectAsync(true); + if (smtp.IsConnected) + { + await smtp.DisconnectAsync(true); + } + + // Guardar el log en la base de datos, sin importar el resultado del envío + try + { + await _emailLogRepository.CreateAsync(log); + } + catch (Exception logEx) + { + _logger.LogError(logEx, "FALLO CRÍTICO: No se pudo guardar el log del email para {Destinatario}", destinatario); + } } } } diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailLogService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailLogService.cs new file mode 100644 index 0000000..c5bd692 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailLogService.cs @@ -0,0 +1,9 @@ +using GestionIntegral.Api.Dtos.Comunicaciones; + +namespace GestionIntegral.Api.Services.Comunicaciones +{ + public interface IEmailLogService + { + Task> ObtenerHistorialPorReferencia(string referenciaId); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs index 2c43061..03cbd33 100644 --- a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs @@ -2,7 +2,50 @@ namespace GestionIntegral.Api.Services.Comunicaciones { public interface IEmailService { - 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); + /// + /// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar un archivo. + /// Este método también registra automáticamente el resultado del envío en la base de datos. + /// + /// La dirección de correo del destinatario. + /// El nombre del destinatario. + /// El asunto del correo. + /// El contenido del correo en formato HTML. + /// Los bytes del archivo a adjuntar (opcional). + /// El nombre del archivo adjunto (requerido si se provee attachment). + /// Identificador del proceso que dispara el email (ej. "EnvioManualPDF"). Para logging. + /// ID de la entidad relacionada (ej. "Factura-59"). Para logging. + /// ID del usuario que inició la acción (si aplica). Para logging. + Task EnviarEmailAsync( + string destinatarioEmail, + string destinatarioNombre, + string asunto, + string cuerpoHtml, + byte[]? attachment = null, + string? attachmentName = null, + string? origen = null, + string? referenciaId = null, + int? idUsuarioDisparo = null); + + /// + /// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar múltiples archivos. + /// Este método también registra automáticamente el resultado del envío en la base de datos. + /// + /// La dirección de correo del destinatario. + /// El nombre del destinatario. + /// El asunto del correo. + /// El contenido del correo en formato HTML. + /// Una lista de tuplas que contienen los bytes y el nombre de cada archivo a adjuntar. + /// Identificador del proceso que dispara el email (ej. "FacturacionMensual"). Para logging. + /// ID de la entidad relacionada (ej. "Suscriptor-3"). Para logging. + /// ID del usuario que inició la acción (si aplica). Para logging. + Task EnviarEmailConsolidadoAsync( + string destinatarioEmail, + string destinatarioNombre, + string asunto, + string cuerpoHtml, + List<(byte[] content, string name)> adjuntos, + string? origen = null, + string? referenciaId = null, + int? idUsuarioDisparo = null); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs index 66de84b..06d73ec 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs @@ -26,6 +26,7 @@ namespace GestionIntegral.Api.Services.Suscripciones private readonly DbConnectionFactory _connectionFactory; private readonly ILogger _logger; private readonly string _facturasPdfPath; + private const string LogoUrl = "https://www.eldia.com/img/header/eldia.png"; public FacturacionService( ISuscripcionRepository suscripcionRepository, @@ -261,24 +262,18 @@ namespace GestionIntegral.Api.Services.Suscripciones return resumenes.ToList(); } - public async Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura) + public async Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario) { 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."); + if (factura == null) return (false, "Factura no encontrada.", null); + if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número asignado.", null); + if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada.", null); 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."); + if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.", null); - 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"); @@ -286,122 +281,190 @@ namespace GestionIntegral.Api.Services.Suscripciones 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); + pdfFileName = $"Factura_{factura.NumeroFactura}.pdf"; } 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."); + _logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura}", factura.NumeroFactura); + return (false, "No se encontró el archivo PDF correspondiente en el servidor.", null); } - string asunto = $"Tu Factura Oficial - Diario El Día - Período {factura.Periodo}"; - string cuerpoHtml = $"

Hola {suscriptor.NombreCompleto},

Adjuntamos tu factura oficial número {factura.NumeroFactura} correspondiente al período {factura.Periodo}.

Gracias por ser parte de nuestra comunidad de lectores.

Diario El Día

"; + string asunto = $"Factura Electrónica - Período {factura.Periodo}"; + string cuerpoHtml = ConstruirCuerpoEmailFacturaPdf(suscriptor, factura); + + // Pasamos los nuevos parámetros de contexto al EmailService. + await _emailService.EnviarEmailAsync( + destinatarioEmail: suscriptor.Email, + destinatarioNombre: suscriptor.NombreCompleto, + asunto: asunto, + cuerpoHtml: cuerpoHtml, + attachment: pdfAttachment, + attachmentName: pdfFileName, + origen: "EnvioManualPDF", + referenciaId: $"Factura-{idFactura}", + idUsuarioDisparo: idUsuario); - 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); + + return (true, null, suscriptor.Email); } 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."); + // El error ya será logueado por EmailService, pero lo relanzamos para que el controller lo maneje. + // En este caso, simplemente devolvemos la tupla de error. + return (false, "Ocurrió un error al intentar enviar el email con la factura.", null); } } public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor) - { - var periodo = $"{anio}-{mes:D2}"; - try { - var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo); - if (!facturasConEmpresa.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 item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada")) + var periodo = $"{anio}-{mes:D2}"; + try { - var factura = item.Factura; - var nombreEmpresa = item.NombreEmpresa; + var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo); + if (!facturasConEmpresa.Any()) return (false, "No se encontraron facturas para este suscriptor en el período."); - var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); + 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."); - resumenHtml.Append($"

Resumen para {nombreEmpresa}

"); - resumenHtml.Append(""); - - foreach (var detalle in detalles) + var resumenHtml = new StringBuilder(); + var adjuntos = new List<(byte[] content, string name)>(); + + foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada")) { - resumenHtml.Append($""); - } - - var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura); - if (ajustes.Any()) - { - foreach (var ajuste in ajustes) + var factura = item.Factura; + var nombreEmpresa = item.NombreEmpresa; + + var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); + + resumenHtml.Append($"

Resumen para {nombreEmpresa}

"); + resumenHtml.Append("
{detalle.Descripcion}${detalle.ImporteNeto:N2}
"); + + foreach (var detalle in detalles) { - bool esCredito = ajuste.TipoAjuste == "Credito"; - string colorMonto = esCredito ? "#5cb85c" : "#d9534f"; - string signo = esCredito ? "-" : "+"; - resumenHtml.Append($""); + resumenHtml.Append($""); + } + + var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura); + if (ajustes.Any()) + { + foreach (var ajuste in ajustes) + { + bool esCredito = ajuste.TipoAjuste == "Credito"; + string colorMonto = esCredito ? "#5cb85c" : "#d9534f"; + string signo = esCredito ? "-" : "+"; + resumenHtml.Append($""); + } + } + + resumenHtml.Append($""); + resumenHtml.Append("
Ajuste: {ajuste.Motivo}{signo} ${ajuste.Monto:N2}
{detalle.Descripcion}${detalle.ImporteNeto:N2}
Ajuste: {ajuste.Motivo}{signo} ${ajuste.Monto:N2}
Subtotal${factura.ImporteFinal:N2}
"); + + 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_{nombreEmpresa.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); + } } } - - resumenHtml.Append($"Subtotal${factura.ImporteFinal:N2}"); - resumenHtml.Append(""); - 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_{nombreEmpresa.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 = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal); + string asunto = $"Resumen de Cuenta - Período {periodo}"; + string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral); + + // Añadir los parámetros de contexto aquí también + await _emailService.EnviarEmailConsolidadoAsync( + destinatarioEmail: suscriptor.Email, + destinatarioNombre: suscriptor.NombreCompleto, + asunto: asunto, + cuerpoHtml: cuerpoHtml, + adjuntos: adjuntos, + origen: "FacturacionMensual", + referenciaId: $"Suscriptor-{idSuscriptor}", + idUsuarioDisparo: null); // Es null porque es un proceso automático del sistema + + 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."); } - - var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.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 $@" -
-

Hola {suscriptor.NombreCompleto},

-

Le enviamos el resumen de su cuenta para el período {periodo}.

- {resumenHtml} -
- - - - - -
TOTAL:${totalGeneral:N2}
-

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.

-

Gracias por ser parte de nuestra comunidad de lectores.

-

Diario El Día

+
+
+
+ El Día +

Resumen de su Cuenta

+
+
+

Hola {suscriptor.NombreCompleto},

+

Le enviamos el resumen de su cuenta para el período {periodo}.

+ + + {resumenHtml} + +
+ + + + + +
TOTAL A ABONAR:${totalGeneral:N2}
+

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.

+

Gracias por ser parte de nuestra comunidad de lectores.

+
+
+

Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.

+

© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.

+
+
+
"; + } + + private string ConstruirCuerpoEmailFacturaPdf(Suscriptor suscriptor, Factura factura) + { + return $@" +
+
+
+ El Día +

Factura Electrónica Adjunta

+
+
+

Hola {suscriptor.NombreCompleto},

+

Le enviamos adjunta su factura correspondiente al período {factura.Periodo}.

+

Resumen de la Factura

+ + + + + +
Número de Factura:{factura.NumeroFactura}
Período:{factura.Periodo}
Fecha de Envío:{factura.FechaEmision:dd/MM/yyyy}
IMPORTE TOTAL:${factura.ImporteFinal:N2}
+

Puede descargar y guardar el archivo PDF adjunto para sus registros.

+

Gracias por ser parte de nuestra comunidad de lectores.

+
+
+

Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.

+

© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.

+
+
"; } diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs index 7e2eaf2..c5fff1d 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs @@ -9,7 +9,7 @@ namespace GestionIntegral.Api.Services.Suscripciones Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); Task> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor); - Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura); + Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario); Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/appsettings.json b/Backend/GestionIntegral.Api/appsettings.json index 30f1b16..ad27f49 100644 --- a/Backend/GestionIntegral.Api/appsettings.json +++ b/Backend/GestionIntegral.Api/appsettings.json @@ -6,7 +6,7 @@ } }, "AppSettings": { - "FacturasPdfPath": "C:\\Ruta\\A\\Tus\\FacturasPDF" + "FacturasPdfPath": "E:\\Facturas" }, "Jwt": { "Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2", diff --git a/Frontend/src/components/Modals/Suscripciones/HistorialEnviosModal.tsx b/Frontend/src/components/Modals/Suscripciones/HistorialEnviosModal.tsx new file mode 100644 index 0000000..f9a7dd7 --- /dev/null +++ b/Frontend/src/components/Modals/Suscripciones/HistorialEnviosModal.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { Modal, Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, Tooltip, IconButton, CircularProgress, Alert } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto'; + +interface HistorialEnviosModalProps { + open: boolean; + onClose: () => void; + logs: EmailLogDto[]; + isLoading: boolean; + error: string | null; + titulo: string; +} + +const modalStyle = { + position: 'absolute' as 'absolute', + top: '50%', left: '50%', + transform: 'translate(-50%, -50%)', + width: { xs: '95%', sm: '80%', md: '700px' }, + bgcolor: 'background.paper', + boxShadow: 24, p: 4, + borderRadius: 2, +}; + +const HistorialEnviosModal: React.FC = ({ open, onClose, logs, isLoading, error, titulo }) => { + const formatDisplayDateTime = (dateString: string): string => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleString('es-AR', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + }; + + return ( + + + + {titulo} + + + {isLoading ? ( + + ) : error ? ( + {error} + ) : ( + + + + + Fecha de Envío + Estado + Destinatario + Asunto + + + + {logs.length === 0 ? ( + + No se han registrado envíos. + + ) : ( + logs.map((log, index) => ( + + {formatDisplayDateTime(log.fechaEnvio)} + + + + + + {log.destinatarioEmail} + {log.asunto} + + )) + )} + +
+
+ )} +
+
+ ); +}; + +export default HistorialEnviosModal; \ No newline at end of file diff --git a/Frontend/src/models/dtos/Comunicaciones/EmailLogDto.ts b/Frontend/src/models/dtos/Comunicaciones/EmailLogDto.ts new file mode 100644 index 0000000..6780bc3 --- /dev/null +++ b/Frontend/src/models/dtos/Comunicaciones/EmailLogDto.ts @@ -0,0 +1,8 @@ +export interface EmailLogDto { + fechaEnvio: string; // Formato ISO de fecha y hora + estado: 'Enviado' | 'Fallido'; + asunto: string; + destinatarioEmail: string; + error?: string | null; + nombreUsuarioDisparo?: string | null; +} \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx b/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx index 205aa0e..4045118 100644 --- a/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx +++ b/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx @@ -1,17 +1,20 @@ 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 { Box, Typography, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText, Collapse, TextField, Tooltip } 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 MailOutlineIcon from '@mui/icons-material/MailOutline'; 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'; +import type { EmailLogDto } from '../../models/dtos/Comunicaciones/EmailLogDto'; +import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal'; +import HistorialEnviosModal from '../../components/Modals/Suscripciones/HistorialEnviosModal'; const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i); const meses = [ @@ -27,7 +30,8 @@ const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; const SuscriptorRow: React.FC<{ resumen: ResumenCuentaSuscriptorDto; handleMenuOpen: (event: React.MouseEvent, factura: FacturaConsolidadaDto) => void; -}> = ({ resumen, handleMenuOpen }) => { + handleOpenHistorial: (factura: FacturaConsolidadaDto) => void; +}> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => { const [open, setOpen] = useState(false); return ( @@ -38,7 +42,6 @@ const SuscriptorRow: React.FC<{ 0 ? 'error.main' : 'success.main' }}>${resumen.saldoPendienteTotal.toFixed(2)} de ${resumen.importeTotal.toFixed(2)} - {/* La cabecera principal ya no tiene acciones */} @@ -63,10 +66,14 @@ const SuscriptorRow: React.FC<{ {factura.numeroFactura || '-'} - {/* El menú de acciones vuelve a estar aquí, por factura */} handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> + + handleOpenHistorial(factura)}> + + + ))} @@ -92,20 +99,25 @@ const ConsultaFacturasPage: React.FC = () => { const puedeGestionarFactura = isSuperAdmin || tienePermiso("SU006"); const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008"); const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009"); - const [pagoModalOpen, setPagoModalOpen] = useState(false); - const [selectedFactura, setSelectedFactura] = useState(null); - const [anchorEl, setAnchorEl] = useState(null); const [filtroNombre, setFiltroNombre] = useState(''); const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); + const [selectedFactura, setSelectedFactura] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); + const [pagoModalOpen, setPagoModalOpen] = useState(false); + const [historialModalOpen, setHistorialModalOpen] = useState(false); + const [logs, setLogs] = useState([]); + const [loadingLogs, setLoadingLogs] = useState(false); + const [logError, setLogError] = useState(null); + const cargarResumenesDelPeriodo = useCallback(async () => { if (!puedeConsultar) return; setLoading(true); setApiError(null); try { const data = await facturacionService.getResumenesDeCuentaPorPeriodo( - selectedAnio, + selectedAnio, selectedMes, filtroNombre || undefined, filtroEstadoPago || undefined, @@ -118,13 +130,12 @@ const ConsultaFacturasPage: React.FC = () => { } finally { setLoading(false); } - }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]); + }, [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 + }, 500); return () => clearTimeout(timer); }, [cargarResumenesDelPeriodo]); @@ -137,11 +148,26 @@ const ConsultaFacturasPage: React.FC = () => { const handleOpenPagoModal = () => { setPagoModalOpen(true); handleMenuClose(); }; const handleClosePagoModal = () => { setPagoModalOpen(false); setSelectedFactura(null); }; + const handleOpenHistorial = async (factura: FacturaConsolidadaDto) => { + setSelectedFactura(factura); + setHistorialModalOpen(true); + setLoadingLogs(true); + setLogError(null); + try { + const data = await facturacionService.getHistorialEnvios(factura.idFactura); + setLogs(data); + } catch (err) { + setLogError("Error al cargar el historial de envíos."); + } finally { + setLoadingLogs(false); + } + }; + const handleSubmitPagoModal = async (data: CreatePagoDto) => { setApiError(null); try { await facturacionService.registrarPagoManual(data); - setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`); + setApiMessage(`Pago para la factura registrado exitosamente.`); cargarResumenesDelPeriodo(); } catch (err: any) { const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.'; @@ -157,7 +183,7 @@ const ConsultaFacturasPage: React.FC = () => { setApiError(null); try { await facturacionService.actualizarNumeroFactura(factura.idFactura, nuevoNumero.trim()); - setApiMessage(`Número de factura #${factura.idFactura} actualizado.`); + setApiMessage(`Número de factura actualizado.`); cargarResumenesDelPeriodo(); } catch (err: any) { setApiError(err.response?.data?.message || 'Error al actualizar el número de factura.'); @@ -166,12 +192,12 @@ const ConsultaFacturasPage: React.FC = () => { }; 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; + if (!window.confirm(`¿Está seguro de enviar la factura 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.`); + const respuesta = await facturacionService.enviarFacturaPdfPorEmail(idFactura); + setApiMessage(respuesta.message); } catch (err: any) { setApiError(err.response?.data?.message || 'Error al intentar enviar el email.'); } finally { @@ -186,30 +212,12 @@ const ConsultaFacturasPage: React.FC = () => { Consulta de Facturas de Suscripciones Filtros - + Mes Año - setFiltroNombre(e.target.value)} - sx={{flexGrow: 1, minWidth: '200px'}} - /> - - Estado de Pago - - - - Estado de Facturación - - + setFiltroNombre(e.target.value)} sx={{ flexGrow: 1, minWidth: '200px' }} /> + Estado de Pago + Estado de Facturación @@ -218,34 +226,26 @@ const ConsultaFacturasPage: React.FC = () => { - - - - Suscriptor - Saldo Total / Importe Total - - - + SuscriptorSaldo Total / Importe Total {loading ? () : resumenes.length === 0 ? (No hay facturas para el período seleccionado.) - : (resumenes.map(resumen => ()))} + : (resumenes.map(resumen => ( + + )))}
- {/* El menú de acciones ahora opera sobre la 'selectedFactura' */} {selectedFactura && puedeRegistrarPago && (Registrar Pago Manual)} {selectedFactura && puedeGestionarFactura && ( handleUpdateNumeroFactura(selectedFactura)} disabled={selectedFactura.estadoPago === 'Anulada'}>Cargar/Modificar Nro. Factura)} - {selectedFactura && puedeEnviarEmail && ( - handleSendEmail(selectedFactura.idFactura)} - disabled={!selectedFactura.numeroFactura || selectedFactura.estadoPago === 'Anulada'}> - - Enviar Factura (PDF) - - )} + {selectedFactura && puedeEnviarEmail && ( handleSendEmail(selectedFactura.idFactura)} disabled={!selectedFactura.numeroFactura || selectedFactura.estadoPago === 'Anulada'}>Enviar Factura (PDF))} { factura={ selectedFactura ? { idFactura: selectedFactura.idFactura, - nombreSuscriptor: resumenes.find(r => r.idSuscriptor === selectedFactura.idSuscriptor)?.nombreSuscriptor || '', + nombreSuscriptor: resumenes.find(r => r.idSuscriptor === resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.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 + saldoPendiente: selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal, + idSuscriptor: resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor || 0, periodo: '', fechaEmision: '', fechaVencimiento: '', @@ -272,7 +270,17 @@ const ConsultaFacturasPage: React.FC = () => { } : null } errorMessage={apiError} - clearErrorMessage={() => setApiError(null)} /> + clearErrorMessage={() => setApiError(null)} + /> + + setHistorialModalOpen(false)} + logs={logs} + isLoading={loadingLogs} + error={logError} + titulo={`Historial de Envíos para Factura ${selectedFactura?.numeroFactura || `#${selectedFactura?.idFactura}`}`} + /> ); }; diff --git a/Frontend/src/services/Suscripciones/facturacionService.ts b/Frontend/src/services/Suscripciones/facturacionService.ts index cb42973..57940c2 100644 --- a/Frontend/src/services/Suscripciones/facturacionService.ts +++ b/Frontend/src/services/Suscripciones/facturacionService.ts @@ -4,6 +4,7 @@ import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto'; import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto'; import type { ResumenCuentaSuscriptorDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; +import type { EmailLogDto } from '../../models/dtos/Comunicaciones/EmailLogDto'; const API_URL = '/facturacion'; const DEBITOS_URL = '/debitos'; @@ -66,8 +67,14 @@ const enviarAvisoCuentaPorEmail = async (anio: number, mes: number, idSuscriptor await apiClient.post(`${API_URL}/${anio}/${mes}/suscriptor/${idSuscriptor}/enviar-aviso`); }; -const enviarFacturaPdfPorEmail = async (idFactura: number): Promise => { - await apiClient.post(`${API_URL}/${idFactura}/enviar-factura-pdf`); +const enviarFacturaPdfPorEmail = async (idFactura: number): Promise<{ message: string }> => { + const response = await apiClient.post<{ message: string }>(`${API_URL}/${idFactura}/enviar-factura-pdf`); + return response.data; +}; + +const getHistorialEnvios = async (idFactura: number): Promise => { + const response = await apiClient.get(`${API_URL}/${idFactura}/historial-envios`); + return response.data; }; export default { @@ -79,4 +86,5 @@ export default { actualizarNumeroFactura, enviarAvisoCuentaPorEmail, enviarFacturaPdfPorEmail, + getHistorialEnvios, }; \ No newline at end of file