Feat: Implementa auditoría de envíos de email y mejora la UX

Se introduce un sistema completo de logging para todas las comunicaciones por correo electrónico y se realizan mejoras significativas en la experiencia del usuario, tanto en la retroalimentación del sistema como en la estética de los emails enviados al cliente.

###  Nuevas Características

- **Auditoría y Log de Envíos de Email:**
    - Se ha creado una nueva tabla `com_EmailLogs` en la base de datos para registrar cada intento de envío de correo.
    - El `EmailService` ahora centraliza toda la lógica de logging, registrando automáticamente la fecha, destinatario, asunto, estado (`Enviado` o `Fallido`), y mensajes de error detallados.
    - Se implementó un nuevo `EmailLogService` y `EmailLogRepository` para gestionar estos registros.

- **Historial de Envíos en la Interfaz de Usuario:**
    - Se añade un nuevo ícono de "Historial" (<span style="color: #607d8b;">&#x1F4E7;</span>) junto a cada factura en la página de "Consulta de Facturas".
    - Al hacer clic, se abre un modal que muestra una tabla detallada con todos los intentos de envío para esa factura, incluyendo el estado y el motivo del error (si lo hubo).
    - Esto proporciona una trazabilidad completa y una herramienta de diagnóstico para el usuario final.

### 🔄 Refactorización y Mejoras

- **Mensajes de Éxito Dinámicos:**
    - Se ha mejorado la retroalimentación al enviar una factura por PDF. El sistema ahora muestra un mensaje de éxito específico, como "El email... se ha enviado correctamente a suscriptor@email.com", en lugar de un mensaje técnico genérico.
    - Se ajustó la cadena de llamadas (`Controller` -> `Service`) para que el email del destinatario esté disponible para la respuesta de la API.

- **Diseño Unificado de Emails:**
    - Se ha rediseñado el template HTML para el "Aviso de Cuenta Mensual" para que coincida con la estética del email de "Envío de Factura PDF".
    - Ambos correos ahora presentan un diseño profesional y consistente, con cabecera, logo y pie de página, reforzando la imagen de marca.

- **Manejo de Errores de Email Mejorado:**
    - El `EmailService` ahora captura excepciones específicas de la librería `MailKit` (ej. `SmtpCommandException`).
    - Esto permite registrar en el log errores mucho más precisos y útiles, como rechazos de destinatarios por parte del servidor (`User unknown`), fallos de autenticación, etc., que ahora son visibles en el `Tooltip` del historial.
This commit is contained in:
2025-08-09 21:12:11 -03:00
parent 5a806eda38
commit 7dc0940001
17 changed files with 647 additions and 197 deletions

View File

@@ -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<FacturacionController> _logger;
private readonly IEmailLogService _emailLogService;
private const string PermisoGestionarFacturacion = "SU006";
private const string PermisoEnviarEmail = "SU009";
public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger)
public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> 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<IActionResult> 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<EmailLogDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistorialEnvios(int idFactura)
{
var historial = await _emailLogService.ObtenerHistorialPorReferencia("Factura-" + idFactura);
return Ok(historial);
}
}
}

View File

@@ -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<EmailLogRepository> _logger;
public EmailLogRepository(DbConnectionFactory connectionFactory, ILogger<EmailLogRepository> 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<IEnumerable<EmailLog>> 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<EmailLog>(sql, new { ReferenciaId = referenciaId });
}
catch (System.Exception ex)
{
_logger.LogError(ex, "Error al obtener logs de email por ReferenciaId: {ReferenciaId}", referenciaId);
return Enumerable.Empty<EmailLog>();
}
}
}
}

View File

@@ -0,0 +1,19 @@
using GestionIntegral.Api.Models.Comunicaciones;
namespace GestionIntegral.Api.Data.Repositories.Comunicaciones
{
public interface IEmailLogRepository
{
/// <summary>
/// Guarda un nuevo registro de log de email en la base de datos.
/// </summary>
Task CreateAsync(EmailLog log);
/// <summary>
/// Obtiene todos los registros de log de email que coinciden con una referencia específica.
/// </summary>
/// <param name="referenciaId">El identificador de la entidad (ej. "Factura-59").</param>
/// <returns>Una colección de registros de log de email.</returns>
Task<IEnumerable<EmailLog>> GetByReferenceAsync(string referenciaId);
}
}

View File

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

View File

@@ -0,0 +1,20 @@
namespace GestionIntegral.Api.Dtos.Comunicaciones
{
/// <summary>
/// Representa un registro de historial de envío de correo para ser mostrado en la interfaz de usuario.
/// </summary>
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; }
/// <summary>
/// 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).
/// </summary>
public string? NombreUsuarioDisparo { get; set; }
}
}

View File

@@ -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<IAjusteService, AjusteService>();
// --- Comunicaciones ---
builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings"));
builder.Services.AddTransient<IEmailService, EmailService>();
builder.Services.AddScoped<IEmailLogRepository, EmailLogRepository>();
builder.Services.AddScoped<IEmailLogService, EmailLogService>();
// --- SERVICIO DE HEALTH CHECKS ---
// Añadimos una comprobación específica para SQL Server.

View File

@@ -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<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId)
{
var logs = await _emailLogRepository.GetByReferenceAsync(referenciaId);
if (!logs.Any())
{
return Enumerable.Empty<EmailLogDto>();
}
// 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<int, string>();
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"
});
}
}
}

View File

@@ -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<EmailService> _logger;
private readonly IEmailLogRepository _emailLogRepository;
public EmailService(IOptions<MailSettings> mailSettings, ILogger<EmailService> logger)
public EmailService(
IOptions<MailSettings> mailSettings,
ILogger<EmailService> 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);
}
/// <summary>
/// Método privado que centraliza el envío de correo y el registro de logs.
/// </summary>
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);
}
}
}
}

View File

@@ -0,0 +1,9 @@
using GestionIntegral.Api.Dtos.Comunicaciones;
namespace GestionIntegral.Api.Services.Comunicaciones
{
public interface IEmailLogService
{
Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId);
}
}

View File

@@ -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);
/// <summary>
/// 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.
/// </summary>
/// <param name="destinatarioEmail">La dirección de correo del destinatario.</param>
/// <param name="destinatarioNombre">El nombre del destinatario.</param>
/// <param name="asunto">El asunto del correo.</param>
/// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param>
/// <param name="attachment">Los bytes del archivo a adjuntar (opcional).</param>
/// <param name="attachmentName">El nombre del archivo adjunto (requerido si se provee attachment).</param>
/// <param name="origen">Identificador del proceso que dispara el email (ej. "EnvioManualPDF"). Para logging.</param>
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Factura-59"). Para logging.</param>
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
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);
/// <summary>
/// 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.
/// </summary>
/// <param name="destinatarioEmail">La dirección de correo del destinatario.</param>
/// <param name="destinatarioNombre">El nombre del destinatario.</param>
/// <param name="asunto">El asunto del correo.</param>
/// <param name="cuerpoHtml">El contenido del correo en formato HTML.</param>
/// <param name="adjuntos">Una lista de tuplas que contienen los bytes y el nombre de cada archivo a adjuntar.</param>
/// <param name="origen">Identificador del proceso que dispara el email (ej. "FacturacionMensual"). Para logging.</param>
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Suscriptor-3"). Para logging.</param>
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
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);
}
}

View File

@@ -26,6 +26,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<FacturacionService> _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 = $"<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>";
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($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen para {nombreEmpresa}</h4>");
resumenHtml.Append("<table style='width: 100%; border-collapse: collapse; font-size: 0.9em;'>");
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($"<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>");
}
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($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen para {nombreEmpresa}</h4>");
resumenHtml.Append("<table style='width: 100%; border-collapse: collapse; font-size: 0.9em;'>");
foreach (var detalle in detalles)
{
bool esCredito = ajuste.TipoAjuste == "Credito";
string colorMonto = esCredito ? "#5cb85c" : "#d9534f";
string signo = esCredito ? "-" : "+";
resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee; font-style: italic;'>Ajuste: {ajuste.Motivo}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right; color: {colorMonto}; font-style: italic;'>{signo} ${ajuste.Monto:N2}</td></tr>");
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>");
}
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($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee; font-style: italic;'>Ajuste: {ajuste.Motivo}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right; color: {colorMonto}; font-style: italic;'>{signo} ${ajuste.Monto: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_{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($"<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_{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 $@"
<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 style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
<div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
<div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
<img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
<h2>Resumen de su Cuenta</h2>
</div>
<div style='padding: 20px; color: #333;'>
<h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
<p>Le enviamos el resumen de su cuenta para el período <strong>{periodo}</strong>.</p>
<!-- Aquí se insertan las tablas de resumen generadas dinámicamente -->
{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 A ABONAR:</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>
</div>
<div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
<p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
<p>&copy; {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
</div>
</div>
</div>";
}
private string ConstruirCuerpoEmailFacturaPdf(Suscriptor suscriptor, Factura factura)
{
return $@"
<div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
<div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
<div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
<img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
<h2>Factura Electrónica Adjunta</h2>
</div>
<div style='padding: 20px; color: #333;'>
<h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
<p>Le enviamos adjunta su factura correspondiente al período <strong>{factura.Periodo}</strong>.</p>
<h4 style='border-bottom: 2px solid #34515e; padding-bottom: 5px; margin-top: 30px;'>Resumen de la Factura</h4>
<table style='width: 100%; border-collapse: collapse; margin-top: 15px;'>
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Número de Factura:</td><td style='padding: 8px; text-align: right;'>{factura.NumeroFactura}</td></tr>
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Período:</td><td style='padding: 8px; text-align: right;'>{factura.Periodo}</td></tr>
<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Fecha de Envío:</td><td style='padding: 8px; text-align: right;'>{factura.FechaEmision:dd/MM/yyyy}</td></tr>
<tr style='background-color: #f2f2f2;'><td style='padding: 12px; font-weight: bold; font-size: 1.1em;'>IMPORTE TOTAL:</td><td style='padding: 12px; text-align: right; font-weight: bold; font-size: 1.2em; color: #34515e;'>${factura.ImporteFinal:N2}</td></tr>
</table>
<p style='margin-top: 30px;'>Puede descargar y guardar el archivo PDF adjunto para sus registros.</p>
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
</div>
<div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
<p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
<p>&copy; {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
</div>
</div>
</div>";
}

View File

@@ -9,7 +9,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion);
Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor);
Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura);
Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario);
Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario);
}
}

View File

@@ -6,7 +6,7 @@
}
},
"AppSettings": {
"FacturasPdfPath": "C:\\Ruta\\A\\Tus\\FacturasPDF"
"FacturasPdfPath": "E:\\Facturas"
},
"Jwt": {
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",

View File

@@ -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<HistorialEnviosModalProps> = ({ 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 (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" component="h2">{titulo}</Typography>
<IconButton onClick={onClose}><CloseIcon /></IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}><CircularProgress /></Box>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Fecha de Envío</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Estado</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Destinatario</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Asunto</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">No se han registrado envíos.</TableCell>
</TableRow>
) : (
logs.map((log, index) => (
<TableRow key={index}>
<TableCell>{formatDisplayDateTime(log.fechaEnvio)}</TableCell>
<TableCell>
<Tooltip title={log.estado === 'Fallido' ? (log.error || 'Error desconocido') : ''} arrow>
<Chip
label={log.estado}
color={log.estado === 'Enviado' ? 'success' : 'error'}
size="small"
/>
</Tooltip>
</TableCell>
<TableCell>{log.destinatarioEmail}</TableCell>
<TableCell>{log.asunto}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
</Modal>
);
};
export default HistorialEnviosModal;

View File

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

View File

@@ -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<HTMLElement>, factura: FacturaConsolidadaDto) => void;
}> = ({ resumen, handleMenuOpen }) => {
handleOpenHistorial: (factura: FacturaConsolidadaDto) => void;
}> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => {
const [open, setOpen] = useState(false);
return (
<React.Fragment>
@@ -38,7 +42,6 @@ const SuscriptorRow: React.FC<{
<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>
@@ -63,10 +66,14 @@ const SuscriptorRow: React.FC<{
<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>
<Tooltip title="Ver Historial de Envíos">
<IconButton onClick={() => handleOpenHistorial(factura)}>
<MailOutlineIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
@@ -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<FacturaConsolidadaDto | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [filtroNombre, setFiltroNombre] = useState('');
const [filtroEstadoPago, setFiltroEstadoPago] = useState('');
const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState('');
const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [pagoModalOpen, setPagoModalOpen] = useState(false);
const [historialModalOpen, setHistorialModalOpen] = useState(false);
const [logs, setLogs] = useState<EmailLogDto[]>([]);
const [loadingLogs, setLoadingLogs] = useState(false);
const [logError, setLogError] = useState<string | null>(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 = () => {
<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' }}>
<Box sx={{ display: 'flex', gap: 2, my: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<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>
<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>
@@ -218,34 +226,26 @@ const ConsultaFacturasPage: React.FC = () => {
<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>
<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} />)))}
: (resumenes.map(resumen => (
<SuscriptorRow
key={resumen.idSuscriptor}
resumen={resumen}
handleMenuOpen={handleMenuOpen}
handleOpenHistorial={handleOpenHistorial}
/>
)))}
</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>
)}
{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
@@ -255,12 +255,10 @@ const ConsultaFacturasPage: React.FC = () => {
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)}
/>
<HistorialEnviosModal
open={historialModalOpen}
onClose={() => setHistorialModalOpen(false)}
logs={logs}
isLoading={loadingLogs}
error={logError}
titulo={`Historial de Envíos para Factura ${selectedFactura?.numeroFactura || `#${selectedFactura?.idFactura}`}`}
/>
</Box>
);
};

View File

@@ -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<void> => {
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<EmailLogDto[]> => {
const response = await apiClient.get<EmailLogDto[]>(`${API_URL}/${idFactura}/historial-envios`);
return response.data;
};
export default {
@@ -79,4 +86,5 @@ export default {
actualizarNumeroFactura,
enviarAvisoCuentaPorEmail,
enviarFacturaPdfPorEmail,
getHistorialEnvios,
};