Compare commits
	
		
			13 Commits
		
	
	
		
			Suscripcio
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| c27dc2a0ba | |||
| 24b1c07342 | |||
| cb64bbc1f5 | |||
| 057310ca47 | |||
| e95c851e5b | |||
| 038faefd35 | |||
| da50c052f1 | |||
| 5781713b13 | |||
| 9f8d577265 | |||
| b594a48fde | |||
| 2e7d1e36be | |||
| dd2277fce2 | |||
| 9412556fa8 | 
| @@ -26,12 +26,6 @@ jobs: | |||||||
|             set -e |             set -e | ||||||
|             echo "--- INICIO DEL DESPLIEGUE OPTIMIZADO ---" |             echo "--- INICIO DEL DESPLIEGUE OPTIMIZADO ---" | ||||||
|              |              | ||||||
|             # --- Asegurar que el Stack de la Base de Datos esté corriendo --- |  | ||||||
|             echo "Asegurando que el stack de la base de datos esté activo..." |  | ||||||
|             cd /opt/shared-services/database |  | ||||||
|             # El comando 'up -d' es idempotente. Si ya está corriendo, no hace nada. |  | ||||||
|             docker compose up -d |  | ||||||
|              |  | ||||||
|             # 1. Preparar entorno |             # 1. Preparar entorno | ||||||
|             TEMP_DIR=$(mktemp -d) |             TEMP_DIR=$(mktemp -d) | ||||||
|             REPO_OWNER="dmolinari" |             REPO_OWNER="dmolinari" | ||||||
|   | |||||||
| @@ -1,12 +0,0 @@ | |||||||
| # ================================================ |  | ||||||
| # VARIABLES DE ENTORNO PARA LA CONFIGURACIÓN DE CORREO |  | ||||||
| # ================================================ |  | ||||||
| # El separador de doble guion bajo (__) se usa para mapear la jerarquía del JSON. |  | ||||||
| # MailSettings:SmtpHost se convierte en MailSettings__SmtpHost |  | ||||||
|  |  | ||||||
| MailSettings__SmtpHost="mail.eldia.com" |  | ||||||
| MailSettings__SmtpPort=587 |  | ||||||
| MailSettings__SenderName="Club - Diario El Día" |  | ||||||
| MailSettings__SenderEmail="alertas@eldia.com" |  | ||||||
| MailSettings__SmtpUser="alertas@eldia.com" |  | ||||||
| MailSettings__SmtpPass="@Alertas713550@" |  | ||||||
| @@ -75,12 +75,13 @@ namespace GestionIntegral.Api.Controllers.Suscripciones | |||||||
|         int anio, int mes, |         int anio, int mes, | ||||||
|         [FromQuery] string? nombreSuscriptor, |         [FromQuery] string? nombreSuscriptor, | ||||||
|         [FromQuery] string? estadoPago, |         [FromQuery] string? estadoPago, | ||||||
|             [FromQuery] string? estadoFacturacion) |         [FromQuery] string? estadoFacturacion, | ||||||
|  |         [FromQuery] string? tipoFactura) | ||||||
|         { |         { | ||||||
|             if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); |             if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); | ||||||
|             if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." }); |             if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." }); | ||||||
|  |  | ||||||
|             var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion); |             var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura); | ||||||
|             return Ok(resumenes); |             return Ok(resumenes); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -59,10 +59,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|             { |             { | ||||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); |                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             const string sqlInsert = @" |             const string sqlInsert = @" | ||||||
|                 INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion) |                 INSERT INTO dbo.susc_Facturas  | ||||||
|  |                     (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,  | ||||||
|  |                      DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion, TipoFactura) | ||||||
|                 OUTPUT INSERTED.* |                 OUTPUT INSERTED.* | ||||||
|                 VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);"; |                 VALUES  | ||||||
|  |                     (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,  | ||||||
|  |                      @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion, @TipoFactura);"; | ||||||
|  |  | ||||||
|             return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); |             return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); | ||||||
|         } |         } | ||||||
| @@ -104,7 +109,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|             return rowsAffected == idsFacturas.Count(); |             return rowsAffected == idsFacturas.Count(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) |         public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync( | ||||||
|  |         string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura) | ||||||
|         { |         { | ||||||
|             var sqlBuilder = new StringBuilder(@" |             var sqlBuilder = new StringBuilder(@" | ||||||
|                 WITH FacturaConEmpresa AS ( |                 WITH FacturaConEmpresa AS ( | ||||||
| @@ -149,6 +155,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|                 parameters.Add("EstadoFacturacion", estadoFacturacion); |                 parameters.Add("EstadoFacturacion", estadoFacturacion); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrWhiteSpace(tipoFactura)) | ||||||
|  |             { | ||||||
|  |                 sqlBuilder.Append(" AND f.TipoFactura = @TipoFactura"); | ||||||
|  |                 parameters.Add("TipoFactura", tipoFactura); | ||||||
|  |             } | ||||||
|  |  | ||||||
|             sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;"); |             sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;"); | ||||||
|  |  | ||||||
|             try |             try | ||||||
|   | |||||||
| @@ -15,7 +15,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | |||||||
|         Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); |         Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); |         Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); | ||||||
|         Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); |         Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); | ||||||
|         Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); |         Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync( | ||||||
|  |         string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura); | ||||||
|         Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);    |         Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);    | ||||||
|         Task<string?> GetUltimoPeriodoFacturadoAsync(); |         Task<string?> GetUltimoPeriodoFacturadoAsync(); | ||||||
|         Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo); |         Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo); | ||||||
|   | |||||||
| @@ -9,7 +9,6 @@ | |||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> |     <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> | ||||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> |     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> |  | ||||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> |     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> |     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" /> |     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" /> | ||||||
|   | |||||||
| @@ -8,6 +8,9 @@ namespace GestionIntegral.Api.Dtos.Suscripciones | |||||||
|         public string EstadoPago { get; set; } = string.Empty; |         public string EstadoPago { get; set; } = string.Empty; | ||||||
|         public string EstadoFacturacion { get; set; } = string.Empty; |         public string EstadoFacturacion { get; set; } = string.Empty; | ||||||
|         public string? NumeroFactura { get; set; } |         public string? NumeroFactura { get; set; } | ||||||
|  |         public decimal TotalPagado { get; set; } | ||||||
|  |         public string TipoFactura { get; set; } = string.Empty; | ||||||
|  |         public int IdSuscriptor { get; set; } | ||||||
|         public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); |         public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -15,5 +15,6 @@ namespace GestionIntegral.Api.Models.Suscripciones | |||||||
|         public string? NumeroFactura { get; set; } |         public string? NumeroFactura { get; set; } | ||||||
|         public int? IdLoteDebito { get; set; } |         public int? IdLoteDebito { get; set; } | ||||||
|         public string? MotivoRechazo { get; set; } |         public string? MotivoRechazo { get; set; } | ||||||
|  |         public string TipoFactura { get; set; } = string.Empty; | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -24,10 +24,6 @@ using GestionIntegral.Api.Models.Comunicaciones; | |||||||
| using GestionIntegral.Api.Services.Comunicaciones; | using GestionIntegral.Api.Services.Comunicaciones; | ||||||
| using GestionIntegral.Api.Data.Repositories.Comunicaciones; | using GestionIntegral.Api.Data.Repositories.Comunicaciones; | ||||||
|  |  | ||||||
| // Carga las variables de entorno desde el archivo .env al inicio de la aplicación. |  | ||||||
| // Debe ser la primera línea para que la configuración esté disponible para el 'builder'. |  | ||||||
| DotNetEnv.Env.Load(); |  | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
| // --- Registros de Servicios --- | // --- Registros de Servicios --- | ||||||
|   | |||||||
| @@ -4,6 +4,8 @@ using MailKit.Net.Smtp; | |||||||
| using MailKit.Security; | using MailKit.Security; | ||||||
| using Microsoft.Extensions.Options; | using Microsoft.Extensions.Options; | ||||||
| using MimeKit; | using MimeKit; | ||||||
|  | using System.Net.Security; | ||||||
|  | using System.Security.Cryptography.X509Certificates; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Comunicaciones | namespace GestionIntegral.Api.Services.Comunicaciones | ||||||
| { | { | ||||||
| @@ -88,6 +90,30 @@ namespace GestionIntegral.Api.Services.Comunicaciones | |||||||
|             using var smtp = new SmtpClient(); |             using var smtp = new SmtpClient(); | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|  |                 // Se añade una política de validación de certificado personalizada. | ||||||
|  |                 // Esto es necesario para entornos de desarrollo o redes internas donde | ||||||
|  |                 // el nombre del host al que nos conectamos (ej. una IP) no coincide | ||||||
|  |                 // con el nombre en el certificado SSL (ej. mail.eldia.com). | ||||||
|  |                 smtp.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => | ||||||
|  |                 { | ||||||
|  |                     // Si no hay errores, el certificado es válido. | ||||||
|  |                     if (sslPolicyErrors == SslPolicyErrors.None) | ||||||
|  |                         return true; | ||||||
|  |  | ||||||
|  |                     // Si el único error es que el nombre no coincide (RemoteCertificateNameMismatch) | ||||||
|  |                     // Y el certificado es el que esperamos (emitido para "mail.eldia.com"), | ||||||
|  |                     // entonces lo aceptamos como válido. | ||||||
|  |                     if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch) && certificate != null && certificate.Subject.Contains("CN=mail.eldia.com")) | ||||||
|  |                     { | ||||||
|  |                         _logger.LogWarning("Se aceptó un certificado SSL con 'Name Mismatch' para el host de confianza 'mail.eldia.com'."); | ||||||
|  |                         return true; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     // Para cualquier otro error, rechazamos el certificado. | ||||||
|  |                     _logger.LogError("Error de validación de certificado SSL: {Errors}", sslPolicyErrors); | ||||||
|  |                     return false; | ||||||
|  |                 }; | ||||||
|  |  | ||||||
|                 await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); |                 await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); | ||||||
|                 await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); |                 await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); | ||||||
|                 await smtp.SendAsync(emailMessage); |                 await smtp.SendAsync(emailMessage); | ||||||
| @@ -95,20 +121,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones | |||||||
|                 log.Estado = "Enviado"; |                 log.Estado = "Enviado"; | ||||||
|                 _logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); |                 _logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); | ||||||
|             } |             } | ||||||
|             catch (SmtpCommandException scEx) |  | ||||||
|             { |  | ||||||
|                 _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) |  | ||||||
|             { |  | ||||||
|                 _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) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 _logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); |                 _logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); | ||||||
|   | |||||||
| @@ -17,8 +17,8 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|         private readonly DbConnectionFactory _connectionFactory; |         private readonly DbConnectionFactory _connectionFactory; | ||||||
|         private readonly ILogger<DebitoAutomaticoService> _logger; |         private readonly ILogger<DebitoAutomaticoService> _logger; | ||||||
|  |  | ||||||
|         private const string NRO_PRESTACION = "123456"; |         private const string NRO_PRESTACION = "26435"; // Reemplazar por el número real | ||||||
|         private const string ORIGEN_EMPRESA = "ELDIA"; |         private const string ORIGEN_EMPRESA = "EMPRESA"; | ||||||
|  |  | ||||||
|         public DebitoAutomaticoService( |         public DebitoAutomaticoService( | ||||||
|             IFacturaRepository facturaRepository, |             IFacturaRepository facturaRepository, | ||||||
| @@ -40,9 +40,7 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|         public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) |         public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) | ||||||
|         { |         { | ||||||
|             // Se define la identificación del archivo. |             // Este número debe ser gestionado para no repetirse. Por ahora, lo mantenemos como 1. | ||||||
|             // Este número debe ser gestionado para no repetirse en archivos generados |  | ||||||
|             // para la misma prestación y fecha. |  | ||||||
|             const int identificacionArchivo = 1; |             const int identificacionArchivo = 1; | ||||||
|  |  | ||||||
|             var periodo = $"{anio}-{mes:D2}"; |             var periodo = $"{anio}-{mes:D2}"; | ||||||
| @@ -62,8 +60,6 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); |                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); | ||||||
|                 var cantidadRegistros = facturasParaDebito.Count(); |                 var cantidadRegistros = facturasParaDebito.Count(); | ||||||
|                  |  | ||||||
|                 // Se utiliza la variable 'identificacionArchivo' para nombrar el archivo. |  | ||||||
|                 var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt"; |                 var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt"; | ||||||
|  |  | ||||||
|                 var nuevoLote = new LoteDebito |                 var nuevoLote = new LoteDebito | ||||||
| @@ -78,13 +74,11 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); |                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); | ||||||
|  |  | ||||||
|                 var sb = new StringBuilder(); |                 var sb = new StringBuilder(); | ||||||
|                 // Se pasa la 'identificacionArchivo' al método que crea el Header. |  | ||||||
|                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); |                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||||
|                 foreach (var item in facturasParaDebito) |                 foreach (var item in facturasParaDebito) | ||||||
|                 { |                 { | ||||||
|                     sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); |                     sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); | ||||||
|                 } |                 } | ||||||
|                 // Se pasa la 'identificacionArchivo' al método que crea el Trailer. |  | ||||||
|                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); |                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||||
|  |  | ||||||
|                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); |                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); | ||||||
| @@ -108,17 +102,18 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); |             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); | ||||||
|             var resultado = new List<(Factura, Suscriptor)>(); |             var resultado = new List<(Factura, Suscriptor)>(); | ||||||
|  |  | ||||||
|             foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente")) |             // Filtramos por estado Y POR TIPO DE FACTURA | ||||||
|  |             foreach (var f in facturas.Where(fa => | ||||||
|  |                 (fa.EstadoPago == "Pendiente" || fa.EstadoPago == "Pagada Parcialmente" || fa.EstadoPago == "Rechazada") && | ||||||
|  |                 fa.TipoFactura == "Mensual" | ||||||
|  |             )) | ||||||
|             { |             { | ||||||
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor); |                 var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor); | ||||||
|  |  | ||||||
|                 // Se valida que el CBU de Banelco (22 caracteres) exista antes de intentar la conversión. |  | ||||||
|                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22) |                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22) | ||||||
|                 { |                 { | ||||||
|                     _logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", suscriptor?.IdSuscriptor); |                     _logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", f.IdSuscriptor); | ||||||
|                     continue; |                     continue; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); |                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); | ||||||
|                 if (formaPago != null && formaPago.RequiereCBU) |                 if (formaPago != null && formaPago.RequiereCBU) | ||||||
|                 { |                 { | ||||||
| @@ -128,26 +123,13 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             return resultado; |             return resultado; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Lógica de conversión de CBU. |  | ||||||
|         private string ConvertirCbuBanelcoASnp(string cbu22) |         private string ConvertirCbuBanelcoASnp(string cbu22) | ||||||
|         { |         { | ||||||
|             if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) |             if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) return "".PadRight(26); | ||||||
|             { |  | ||||||
|                 _logger.LogError("Se intentó convertir un CBU inválido de {Length} caracteres. Se devolverá un campo vacío.", cbu22?.Length ?? 0); |  | ||||||
|                 // Devolver un string de 26 espacios/ceros según la preferencia del banco para campos erróneos. |  | ||||||
|                 return "".PadRight(26);  |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // El formato SNP de 26 se obtiene insertando un "0" al inicio y "000" después del 8vo caracter del CBU de 22. |  | ||||||
|             // Formato Banelco (22): [BBBSSSSX] [T....Y] |  | ||||||
|             // Posiciones:            (0-7)      (8-21) |  | ||||||
|             // Formato SNP (26):      0[BBBSSSSX]000[T....Y] |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 string bloque1 = cbu22.Substring(0, 8); // Contiene código de banco, sucursal y DV del bloque 1. |                 string bloque1 = cbu22.Substring(0, 8); | ||||||
|                 string bloque2 = cbu22.Substring(8);    // Contiene el resto de la cadena. |                 string bloque2 = cbu22.Substring(8); | ||||||
|  |  | ||||||
|                 // Reconstruir en formato SNP de 26 dígitos según el instructivo. |  | ||||||
|                 return $"0{bloque1}000{bloque2}"; |                 return $"0{bloque1}000{bloque2}"; | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
| @@ -157,9 +139,10 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // --- Métodos de Formateo y Mapeo --- |         // --- Helpers de Formateo --- | ||||||
|         private string FormatString(string? value, int length) => (value ?? "").PadRight(length); |         private string FormatString(string? value, int length) => (value ?? "").PadRight(length); | ||||||
|         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); |         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); | ||||||
|  |         private string FormatNumericString(string? value, int length) => (value ?? "").PadLeft(length, '0'); | ||||||
|         private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch |         private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch | ||||||
|         { |         { | ||||||
|             "DNI" => "0096", |             "DNI" => "0096", | ||||||
| @@ -167,17 +150,17 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             "CUIL" => "0086", |             "CUIL" => "0086", | ||||||
|             "LE" => "0089", |             "LE" => "0089", | ||||||
|             "LC" => "0090", |             "LC" => "0090", | ||||||
|             _ => "0000" // Tipo no especificado o C.I. Policía Federal según anexo. |             _ => "0000" | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) |         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||||
|         { |         { | ||||||
|             var sb = new StringBuilder(); |             var sb = new StringBuilder(); | ||||||
|             sb.Append("00"); // Tipo de Registro Header |             sb.Append("00"); | ||||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); |             sb.Append(FormatNumericString(NRO_PRESTACION, 6)); | ||||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos |             sb.Append("C"); | ||||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); |             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||||
|             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo |             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); | ||||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); |             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); |             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); |             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||||
| @@ -188,35 +171,33 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|  |  | ||||||
|         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) |         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) | ||||||
|         { |         { | ||||||
|             // Convertimos el CBU de 22 (Banelco) a 26 (SNP) antes de usarlo. |  | ||||||
|             string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!); |             string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!); | ||||||
|  |  | ||||||
|             var sb = new StringBuilder(); |             var sb = new StringBuilder(); | ||||||
|             sb.Append("0370"); // Tipo de Registro Detalle (Orden de Débito) |             sb.Append("0370"); | ||||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación de Cliente |             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); | ||||||
|             sb.Append(FormatString(cbu26, 26)); // CBU en formato SNP de 26 caracteres. |             sb.Append(cbu26); | ||||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); // Referencia Unívoca de la factura. |             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); | ||||||
|             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); |             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); | ||||||
|             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); |             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento |             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento |             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento |             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento |             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento | ||||||
|             sb.Append("0"); // Moneda (0 = Pesos) |             sb.Append("0"); | ||||||
|             sb.Append(FormatString("", 3)); // Motivo Rechazo (vacío en el envío) |             sb.Append(FormatString("", 3)); | ||||||
|             sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4)); |             sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4)); | ||||||
|             sb.Append(FormatString(suscriptor.NroDocumento, 11)); |             sb.Append(FormatNumericString(suscriptor.NroDocumento, 11)); | ||||||
|             sb.Append(FormatString("", 22)); // Nueva ID Cliente |             sb.Append(FormatString("", 22)); | ||||||
|             sb.Append(FormatString("", 26)); // Nueva CBU |             sb.Append(FormatString("", 26)); | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo |             sb.Append(FormatNumeric(0, 14)); | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vencimiento |             sb.Append(FormatNumeric(0, 8)); | ||||||
|             sb.Append(FormatString("", 22)); // Identificación Cuenta Anterior |             sb.Append(FormatString("", 22)); | ||||||
|             sb.Append(FormatString("", 40)); // Mensaje ATM |             sb.Append(FormatString("", 40)); | ||||||
|             sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); // Concepto Factura |             sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro |             sb.Append(FormatNumeric(0, 8)); | ||||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Cobrado |             sb.Append(FormatNumeric(0, 14)); | ||||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Acreditamiento |             sb.Append(FormatNumeric(0, 8)); | ||||||
|             sb.Append(FormatString("", 26)); // Libre |             sb.Append(FormatString("", 26)); | ||||||
|             sb.Append("\r\n"); |             sb.Append("\r\n"); | ||||||
|             return sb.ToString(); |             return sb.ToString(); | ||||||
|         } |         } | ||||||
| @@ -224,16 +205,16 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) |         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||||
|         { |         { | ||||||
|             var sb = new StringBuilder(); |             var sb = new StringBuilder(); | ||||||
|             sb.Append("99"); // Tipo de Registro Trailer |             sb.Append("99"); | ||||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); |             sb.Append(FormatNumericString(NRO_PRESTACION, 6)); | ||||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos |             sb.Append("C"); | ||||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); |             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||||
|             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo |             sb.Append(FormatString(identificacionArchivo.ToString(), 1)); | ||||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); |             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); |             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); |             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||||
|             sb.Append(FormatString("", 304)); |             sb.Append(FormatString("", 304)); | ||||||
|             // La última línea del archivo no lleva salto de línea (\r\n). |             sb.Append("\r\n"); | ||||||
|             return sb.ToString(); |             return sb.ToString(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -171,10 +171,13 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                         DescuentoAplicado = descuentoPromocionesTotal, |                         DescuentoAplicado = descuentoPromocionesTotal, | ||||||
|                         ImporteFinal = importeFinal, |                         ImporteFinal = importeFinal, | ||||||
|                         EstadoPago = "Pendiente", |                         EstadoPago = "Pendiente", | ||||||
|                         EstadoFacturacion = "Pendiente de Facturar" |                         EstadoFacturacion = "Pendiente de Facturar", | ||||||
|  |                         TipoFactura = "Mensual" | ||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
|                     var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); |                     var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); | ||||||
|                     if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}"); |                     if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}"); | ||||||
|  |  | ||||||
|                     facturasCreadas.Add(facturaCreada); |                     facturasCreadas.Add(facturaCreada); | ||||||
|                     foreach (var detalle in detallesParaFactura) |                     foreach (var detalle in detallesParaFactura) | ||||||
|                     { |                     { | ||||||
| @@ -278,11 +281,12 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) |         public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo( | ||||||
|  |         int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura) | ||||||
|         { |         { | ||||||
|             var periodo = $"{anio}-{mes:D2}"; |             var periodo = $"{anio}-{mes:D2}"; | ||||||
|             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion); |             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura); | ||||||
|             var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo |             var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); | ||||||
|             var empresas = await _empresaRepository.GetAllAsync(null, null); |             var empresas = await _empresaRepository.GetAllAsync(null, null); | ||||||
|  |  | ||||||
|             var resumenes = facturasData |             var resumenes = facturasData | ||||||
| @@ -301,10 +305,18 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                             EstadoPago = itemFactura.Factura.EstadoPago, |                             EstadoPago = itemFactura.Factura.EstadoPago, | ||||||
|                             EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, |                             EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, | ||||||
|                             NumeroFactura = itemFactura.Factura.NumeroFactura, |                             NumeroFactura = itemFactura.Factura.NumeroFactura, | ||||||
|  |                             TotalPagado = itemFactura.TotalPagado, | ||||||
|  |  | ||||||
|  |                             // Faltaba esta línea para pasar el tipo de factura al frontend. | ||||||
|  |                             TipoFactura = itemFactura.Factura.TipoFactura, | ||||||
|  |  | ||||||
|                             Detalles = detallesData |                             Detalles = detallesData | ||||||
|                                 .Where(d => d.IdFactura == itemFactura.Factura.IdFactura) |                                 .Where(d => d.IdFactura == itemFactura.Factura.IdFactura) | ||||||
|                                 .Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto }) |                                 .Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto }) | ||||||
|                                 .ToList() |                                 .ToList(), | ||||||
|  |  | ||||||
|  |                             // Pasamos el id del suscriptor para facilitar las cosas en el frontend | ||||||
|  |                             IdSuscriptor = itemFactura.Factura.IdSuscriptor | ||||||
|                         }; |                         }; | ||||||
|                     }).ToList(); |                     }).ToList(); | ||||||
|  |  | ||||||
| @@ -314,7 +326,7 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                         NombreSuscriptor = primerItem.NombreSuscriptor, |                         NombreSuscriptor = primerItem.NombreSuscriptor, | ||||||
|                         Facturas = facturasConsolidadas, |                         Facturas = facturasConsolidadas, | ||||||
|                         ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal), |                         ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal), | ||||||
|                         SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.EstadoPago == "Pagada" ? 0 : f.ImporteFinal) |                         SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal - f.TotalPagado) | ||||||
|                     }; |                     }; | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
| @@ -578,7 +590,7 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         private async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction) |         public async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction) | ||||||
|         { |         { | ||||||
|             decimal importeTotal = 0; |             decimal importeTotal = 0; | ||||||
|             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); |             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); | ||||||
|   | |||||||
| @@ -1,5 +1,7 @@ | |||||||
|  | using System.Data; | ||||||
| using GestionIntegral.Api.Dtos.Comunicaciones; | using GestionIntegral.Api.Dtos.Comunicaciones; | ||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Suscripciones; | ||||||
|  | using GestionIntegral.Api.Models.Suscripciones; | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Suscripciones | namespace GestionIntegral.Api.Services.Suscripciones | ||||||
| { | { | ||||||
| @@ -7,8 +9,10 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|     { |     { | ||||||
|         Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); |         Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); | ||||||
|         Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes); |         Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes); | ||||||
|         Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); |         Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo( | ||||||
|  |         int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura); | ||||||
|         Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario); |         Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario); | ||||||
|         Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); |         Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); | ||||||
|  |         Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -70,14 +70,11 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|             { |             { | ||||||
|                 var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura); |                 var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura); | ||||||
|                 if (factura == null) return (null, "La factura especificada no existe."); |                 if (factura == null) return (null, "La factura especificada no existe."); | ||||||
|  |  | ||||||
|                 // Usar EstadoPago para la validación |  | ||||||
|                 if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada."); |                 if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada."); | ||||||
|  |  | ||||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago); |                 var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago); | ||||||
|                 if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida."); |                 if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida."); | ||||||
|  |  | ||||||
|                 // Obtenemos la suma de pagos ANTERIORES |  | ||||||
|                 var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction); |                 var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction); | ||||||
|  |  | ||||||
|                 var nuevoPago = new Pago |                 var nuevoPago = new Pago | ||||||
| @@ -96,37 +93,31 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                 var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); |                 var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||||
|                 if (pagoCreado == null) throw new DataException("No se pudo registrar el pago."); |                 if (pagoCreado == null) throw new DataException("No se pudo registrar el pago."); | ||||||
|  |  | ||||||
|                 // Calculamos el nuevo total EN MEMORIA |  | ||||||
|                 var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto; |                 var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto; | ||||||
|  |  | ||||||
|                 // Comparamos y actualizamos el estado si es necesario |                 // Nueva lógica para manejar todos los estados de pago | ||||||
|                 // CORRECCIÓN: Usar EstadoPago y el método correcto del repositorio |                 string nuevoEstadoPago = factura.EstadoPago; | ||||||
|                 if (factura.EstadoPago != "Pagada" && nuevoTotalPagado >= factura.ImporteFinal) |                 if (nuevoTotalPagado >= factura.ImporteFinal) | ||||||
|                 { |                 { | ||||||
|                     bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, "Pagada", transaction); |                     nuevoEstadoPago = "Pagada"; | ||||||
|                     if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'."); |                 } | ||||||
|  |                 else if (nuevoTotalPagado > 0) | ||||||
|  |                 { | ||||||
|  |                     nuevoEstadoPago = "Pagada Parcialmente"; | ||||||
|  |                 } | ||||||
|  |                 // Si nuevoTotalPagado es 0, el estado no cambia. | ||||||
|  |  | ||||||
|  |                 // Solo actualizamos si el estado calculado es diferente al actual. | ||||||
|  |                 if (nuevoEstadoPago != factura.EstadoPago) | ||||||
|  |                 { | ||||||
|  |                     bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, nuevoEstadoPago, transaction); | ||||||
|  |                     if (!actualizado) throw new DataException($"No se pudo actualizar el estado de la factura a '{nuevoEstadoPago}'."); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 transaction.Commit(); |                 transaction.Commit(); | ||||||
|                 _logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario); |                 _logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario); | ||||||
|  |  | ||||||
|                 // Construimos el DTO de respuesta SIN volver a consultar la base de datos |                 var dto = await MapToDto(pagoCreado); // MapToDto ahora es más simple | ||||||
|                 var usuario = await _usuarioRepository.GetByIdAsync(idUsuario); |  | ||||||
|                 var dto = new PagoDto |  | ||||||
|                 { |  | ||||||
|                     IdPago = pagoCreado.IdPago, |  | ||||||
|                     IdFactura = pagoCreado.IdFactura, |  | ||||||
|                     FechaPago = pagoCreado.FechaPago.ToString("yyyy-MM-dd"), |  | ||||||
|                     IdFormaPago = pagoCreado.IdFormaPago, |  | ||||||
|                     NombreFormaPago = formaPago.Nombre, |  | ||||||
|                     Monto = pagoCreado.Monto, |  | ||||||
|                     Estado = pagoCreado.Estado, |  | ||||||
|                     Referencia = pagoCreado.Referencia, |  | ||||||
|                     Observaciones = pagoCreado.Observaciones, |  | ||||||
|                     IdUsuarioRegistro = pagoCreado.IdUsuarioRegistro, |  | ||||||
|                     NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A" |  | ||||||
|                 }; |  | ||||||
|  |  | ||||||
|                 return (dto, null); |                 return (dto, null); | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|   | |||||||
| @@ -3,12 +3,8 @@ using GestionIntegral.Api.Data.Repositories.Distribucion; | |||||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||||
| using GestionIntegral.Api.Dtos.Suscripciones; | using GestionIntegral.Api.Dtos.Suscripciones; | ||||||
| using GestionIntegral.Api.Models.Suscripciones; | using GestionIntegral.Api.Models.Suscripciones; | ||||||
| using Microsoft.Extensions.Logging; |  | ||||||
| using System; |  | ||||||
| using System.Collections.Generic; |  | ||||||
| using System.Data; | using System.Data; | ||||||
| using System.Linq; | using System.Globalization; | ||||||
| using System.Threading.Tasks; |  | ||||||
|  |  | ||||||
| namespace GestionIntegral.Api.Services.Suscripciones | namespace GestionIntegral.Api.Services.Suscripciones | ||||||
| { | { | ||||||
| @@ -18,23 +14,32 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|         private readonly ISuscriptorRepository _suscriptorRepository; |         private readonly ISuscriptorRepository _suscriptorRepository; | ||||||
|         private readonly IPublicacionRepository _publicacionRepository; |         private readonly IPublicacionRepository _publicacionRepository; | ||||||
|         private readonly IPromocionRepository _promocionRepository; |         private readonly IPromocionRepository _promocionRepository; | ||||||
|         private readonly DbConnectionFactory _connectionFactory; |         private readonly IFacturaRepository _facturaRepository; | ||||||
|  |         private readonly IFacturaDetalleRepository _facturaDetalleRepository; | ||||||
|  |         private readonly IFacturacionService _facturacionService; | ||||||
|         private readonly ILogger<SuscripcionService> _logger; |         private readonly ILogger<SuscripcionService> _logger; | ||||||
|  |         private readonly DbConnectionFactory _connectionFactory; | ||||||
|  |  | ||||||
|         public SuscripcionService( |         public SuscripcionService( | ||||||
|             ISuscripcionRepository suscripcionRepository, |             ISuscripcionRepository suscripcionRepository, | ||||||
|         ISuscriptorRepository suscriptorRepository, |         ISuscriptorRepository suscriptorRepository, | ||||||
|         IPublicacionRepository publicacionRepository, |         IPublicacionRepository publicacionRepository, | ||||||
|         IPromocionRepository promocionRepository, |         IPromocionRepository promocionRepository, | ||||||
|             DbConnectionFactory connectionFactory, |         IFacturaRepository facturaRepository, | ||||||
|             ILogger<SuscripcionService> logger) |         IFacturaDetalleRepository facturaDetalleRepository, | ||||||
|  |         IFacturacionService facturacionService, | ||||||
|  |         ILogger<SuscripcionService> logger, | ||||||
|  |         DbConnectionFactory connectionFactory) | ||||||
|         { |         { | ||||||
|             _suscripcionRepository = suscripcionRepository; |             _suscripcionRepository = suscripcionRepository; | ||||||
|             _suscriptorRepository = suscriptorRepository; |             _suscriptorRepository = suscriptorRepository; | ||||||
|             _publicacionRepository = publicacionRepository; |             _publicacionRepository = publicacionRepository; | ||||||
|             _promocionRepository = promocionRepository; |             _promocionRepository = promocionRepository; | ||||||
|             _connectionFactory = connectionFactory; |             _facturaRepository = facturaRepository; | ||||||
|  |             _facturaDetalleRepository = facturaDetalleRepository; | ||||||
|  |             _facturacionService = facturacionService; | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|  |             _connectionFactory = connectionFactory; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto |         private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto | ||||||
| @@ -122,6 +127,53 @@ namespace GestionIntegral.Api.Services.Suscripciones | |||||||
|                 var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction); |                 var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction); | ||||||
|                 if (creada == null) throw new DataException("Error al crear la suscripción."); |                 if (creada == null) throw new DataException("Error al crear la suscripción."); | ||||||
|  |  | ||||||
|  |                 var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync(); | ||||||
|  |                 if (ultimoPeriodoFacturadoStr != null) | ||||||
|  |                 { | ||||||
|  |                     var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture); | ||||||
|  |                     var periodoSuscripcion = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1); | ||||||
|  |  | ||||||
|  |                     if (periodoSuscripcion <= ultimoPeriodo) | ||||||
|  |                     { | ||||||
|  |                         _logger.LogInformation("Suscripción en período ya cerrado detectada. Generando factura de alta pro-rata."); | ||||||
|  |  | ||||||
|  |                         decimal importeProporcional = await _facturacionService.CalcularImporteParaSuscripcion(creada, creada.FechaInicio.Year, creada.FechaInicio.Month, transaction); | ||||||
|  |  | ||||||
|  |                         if (importeProporcional > 0) | ||||||
|  |                         { | ||||||
|  |                             var facturaDeAlta = new Factura | ||||||
|  |                             { | ||||||
|  |                                 IdSuscriptor = creada.IdSuscriptor, | ||||||
|  |                                 Periodo = creada.FechaInicio.ToString("yyyy-MM"), | ||||||
|  |                                 FechaEmision = DateTime.Now.Date, | ||||||
|  |                                 FechaVencimiento = DateTime.Now.AddDays(10).Date, | ||||||
|  |                                 ImporteBruto = importeProporcional, | ||||||
|  |                                 ImporteFinal = importeProporcional, | ||||||
|  |                                 EstadoPago = "Pendiente", | ||||||
|  |                                 EstadoFacturacion = "Pendiente de Facturar", | ||||||
|  |                                 TipoFactura = "Alta" | ||||||
|  |                             }; | ||||||
|  |  | ||||||
|  |                             var facturaCreada = await _facturaRepository.CreateAsync(facturaDeAlta, transaction); | ||||||
|  |                             if (facturaCreada == null) throw new DataException("No se pudo crear la factura de alta."); | ||||||
|  |  | ||||||
|  |                             var publicacion = await _publicacionRepository.GetByIdSimpleAsync(creada.IdPublicacion); | ||||||
|  |                             var finDeMes = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1).AddMonths(1).AddDays(-1); | ||||||
|  |  | ||||||
|  |                             await _facturaDetalleRepository.CreateAsync(new FacturaDetalle | ||||||
|  |                             { | ||||||
|  |                                 IdFactura = facturaCreada.IdFactura, | ||||||
|  |                                 IdSuscripcion = creada.IdSuscripcion, | ||||||
|  |                                 Descripcion = $"Suscripción proporcional {publicacion?.Nombre} ({creada.FechaInicio:dd/MM} al {finDeMes:dd/MM})", | ||||||
|  |                                 ImporteBruto = importeProporcional, | ||||||
|  |                                 ImporteNeto = importeProporcional, | ||||||
|  |                                 DescuentoAplicado = 0 | ||||||
|  |                             }, transaction); | ||||||
|  |  | ||||||
|  |                             _logger.LogInformation("Factura de alta #{IdFactura} por ${Importe} generada para la nueva suscripción #{IdSuscripcion}.", facturaCreada.IdFactura, importeProporcional, creada.IdSuscripcion); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|                 transaction.Commit(); |                 transaction.Commit(); | ||||||
|                 _logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario); |                 _logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario); | ||||||
|                 return (await MapToDto(creada), null); |                 return (await MapToDto(creada), null); | ||||||
|   | |||||||
| @@ -16,11 +16,11 @@ | |||||||
|   }, |   }, | ||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "MailSettings": { |   "MailSettings": { | ||||||
|     "SmtpHost": "", |     "SmtpHost": "192.168.5.201", | ||||||
|     "SmtpPort": 0, |     "SmtpPort": 587, | ||||||
|     "SenderName": "", |     "SenderName": "Club - Diario El Día", | ||||||
|     "SenderEmail": "", |     "SenderEmail": "alertas@eldia.com", | ||||||
|     "SmtpUser": "", |     "SmtpUser": "alertas@eldia.com", | ||||||
|     "SmtpPass": "" |     "SmtpPass": "@Alertas713550@" | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material'; | import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material'; | ||||||
| import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto'; | import type { FacturaConsolidadaDto } from '../../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; | ||||||
| import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto'; | import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto'; | ||||||
| import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto'; | import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto'; | ||||||
| import formaPagoService from '../../../services/Suscripciones/formaPagoService'; | import formaPagoService from '../../../services/Suscripciones/formaPagoService'; | ||||||
| @@ -23,17 +23,19 @@ interface PagoManualModalProps { | |||||||
|   open: boolean; |   open: boolean; | ||||||
|   onClose: () => void; |   onClose: () => void; | ||||||
|   onSubmit: (data: CreatePagoDto) => Promise<void>; |   onSubmit: (data: CreatePagoDto) => Promise<void>; | ||||||
|   factura: FacturaDto | null; |   factura: FacturaConsolidadaDto | null; | ||||||
|  |   nombreSuscriptor: string; // Se pasa el nombre del suscriptor como prop- | ||||||
|   errorMessage?: string | null; |   errorMessage?: string | null; | ||||||
|   clearErrorMessage: () => void; |   clearErrorMessage: () => void; | ||||||
| } | } | ||||||
|  |  | ||||||
| const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, errorMessage, clearErrorMessage }) => { | const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, nombreSuscriptor, errorMessage, clearErrorMessage }) => { | ||||||
|   const [formData, setFormData] = useState<Partial<CreatePagoDto>>({}); |   const [formData, setFormData] = useState<Partial<CreatePagoDto>>({}); | ||||||
|   const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]); |   const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]); | ||||||
|   const [loading, setLoading] = useState(false); |   const [loading, setLoading] = useState(false); | ||||||
|   const [loadingFormasPago, setLoadingFormasPago] = useState(false); |   const [loadingFormasPago, setLoadingFormasPago] = useState(false); | ||||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); |   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||||
|  |   const saldoPendiente = factura ? factura.importeFinal - factura.totalPagado : 0; | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const fetchFormasDePago = async () => { |     const fetchFormasDePago = async () => { | ||||||
| @@ -52,12 +54,12 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | |||||||
|       fetchFormasDePago(); |       fetchFormasDePago(); | ||||||
|       setFormData({ |       setFormData({ | ||||||
|         idFactura: factura.idFactura, |         idFactura: factura.idFactura, | ||||||
|         monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto |         monto: saldoPendiente, | ||||||
|         fechaPago: new Date().toISOString().split('T')[0] |         fechaPago: new Date().toISOString().split('T')[0] | ||||||
|       }); |       }); | ||||||
|       setLocalErrors({}); |       setLocalErrors({}); | ||||||
|     } |     } | ||||||
|   }, [open, factura]); |   }, [open, factura, saldoPendiente]); | ||||||
|  |  | ||||||
|   const validate = (): boolean => { |   const validate = (): boolean => { | ||||||
|     const errors: { [key: string]: string | null } = {}; |     const errors: { [key: string]: string | null } = {}; | ||||||
| @@ -65,13 +67,11 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | |||||||
|     if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria."; |     if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria."; | ||||||
|  |  | ||||||
|     const monto = formData.monto ?? 0; |     const monto = formData.monto ?? 0; | ||||||
|     const saldo = factura?.saldoPendiente ?? 0; |  | ||||||
|  |  | ||||||
|     if (monto <= 0) { |     if (monto <= 0) { | ||||||
|       errors.monto = "El monto debe ser mayor a cero."; |       errors.monto = "El monto debe ser mayor a cero."; | ||||||
|     } else if (monto > saldo) { |     } else if (monto > saldoPendiente) { | ||||||
|         // Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje |       errors.monto = `El monto no puede superar el saldo pendiente de $${saldoPendiente.toFixed(2)}.`; | ||||||
|         errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     setLocalErrors(errors); |     setLocalErrors(errors); | ||||||
| @@ -117,8 +117,11 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | |||||||
|     <Modal open={open} onClose={onClose}> |     <Modal open={open} onClose={onClose}> | ||||||
|       <Box sx={modalStyle}> |       <Box sx={modalStyle}> | ||||||
|         <Typography variant="h6">Registrar Pago Manual</Typography> |         <Typography variant="h6">Registrar Pago Manual</Typography> | ||||||
|  |         <Typography variant="body1" color="text.secondary" gutterBottom> | ||||||
|  |           Para: {nombreSuscriptor} | ||||||
|  |         </Typography> | ||||||
|         <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}> |         <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}> | ||||||
|           Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)} |           Saldo Pendiente: ${saldoPendiente.toFixed(2)} | ||||||
|         </Typography> |         </Typography> | ||||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> |         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||||
|           <TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} /> |           <TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} /> | ||||||
|   | |||||||
| @@ -14,18 +14,18 @@ const SECCION_PERMISSIONS_PREFIX = "SS"; | |||||||
| // Mapeo de codAcc de sección a su módulo conceptual | // Mapeo de codAcc de sección a su módulo conceptual | ||||||
| const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { | const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { | ||||||
|     if (codAcc === "SS001") return "Distribución";     |     if (codAcc === "SS001") return "Distribución";     | ||||||
|  |     if (codAcc === "SS007") return "Suscripciones"; | ||||||
|     if (codAcc === "SS002") return "Contables"; |     if (codAcc === "SS002") return "Contables"; | ||||||
|     if (codAcc === "SS003") return "Impresión"; |     if (codAcc === "SS003") return "Impresión"; | ||||||
|     if (codAcc === "SS004") return "Reportes"; |     if (codAcc === "SS004") return "Reportes"; | ||||||
|     if (codAcc === "SS005") return "Radios"; |  | ||||||
|     if (codAcc === "SS006") return "Usuarios";     |     if (codAcc === "SS006") return "Usuarios";     | ||||||
|  |     if (codAcc === "SS005") return "Radios"; | ||||||
|     return null; |     return null; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| // Función para determinar el módulo conceptual de un permiso individual | // Función para determinar el módulo conceptual de un permiso individual | ||||||
| const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | ||||||
|     const moduloLower = permisoModulo.toLowerCase();     |     const moduloLower = permisoModulo.toLowerCase();     | ||||||
|  |  | ||||||
|     if (moduloLower.includes("distribuidores") || |     if (moduloLower.includes("distribuidores") || | ||||||
|         moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas" |         moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas" | ||||||
|         moduloLower.includes("publicaciones distribución") || |         moduloLower.includes("publicaciones distribución") || | ||||||
| @@ -36,6 +36,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | |||||||
|         moduloLower.includes("ctrl. devoluciones")) { |         moduloLower.includes("ctrl. devoluciones")) { | ||||||
|         return "Distribución"; |         return "Distribución"; | ||||||
|     } |     } | ||||||
|  |     if (moduloLower.includes("suscripciones")) { | ||||||
|  |         return "Suscripciones"; | ||||||
|  |     } | ||||||
|     if (moduloLower.includes("cuentas pagos") || |     if (moduloLower.includes("cuentas pagos") || | ||||||
|         moduloLower.includes("cuentas notas") || |         moduloLower.includes("cuentas notas") || | ||||||
|         moduloLower.includes("cuentas tipos pagos")) { |         moduloLower.includes("cuentas tipos pagos")) { | ||||||
| @@ -89,7 +92,7 @@ const PermisosChecklist: React.FC<PermisosChecklistProps> = ({ | |||||||
|     return acc; |     return acc; | ||||||
|   }, {} as Record<string, PermisoAsignadoDto[]>); |   }, {} as Record<string, PermisoAsignadoDto[]>); | ||||||
|  |  | ||||||
|   const ordenModulosPrincipales = ["Distribución", "Contables", "Impresión", "Radios", "Usuarios", "Reportes", "Permisos (Definición)"]; |   const ordenModulosPrincipales = ["Distribución", "Suscripciones", "Contables", "Impresión", "Usuarios", "Reportes", "Radios","Permisos (Definición)"]; | ||||||
|   // Añadir módulos que solo tienen permiso de sección (como Radios) pero no hijos (aún) |   // Añadir módulos que solo tienen permiso de sección (como Radios) pero no hijos (aún) | ||||||
|   permisosDeSeccion.forEach(ps => { |   permisosDeSeccion.forEach(ps => { | ||||||
|       const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc); |       const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc); | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| import React, { useState, useEffect } from 'react'; | import React, { useState, useEffect } from 'react'; | ||||||
| import { | import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, |  | ||||||
|     FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox |     FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto'; | import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto'; | ||||||
|   | |||||||
| @@ -1,22 +1,22 @@ | |||||||
| // src/hooks/usePermissions.ts |  | ||||||
| import { useAuth } from '../contexts/AuthContext'; | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { useCallback } from 'react'; | ||||||
|  |  | ||||||
| export const usePermissions = () => { | export const usePermissions = () => { | ||||||
|   const { user } = useAuth(); // user aquí es de tipo UserContextData | null |   const { user } = useAuth(); | ||||||
|  |  | ||||||
|   const tienePermiso = (codigoPermisoRequerido: string): boolean => { |   // Envolvemos la función en useCallback. | ||||||
|     if (!user) { // Si no hay usuario logueado |   // Su dependencia es [user], por lo que la función solo se | ||||||
|  |   // volverá a crear si el objeto 'user' cambia (ej. al iniciar/cerrar sesión). | ||||||
|  |   const tienePermiso = useCallback((codigoPermisoRequerido: string): boolean => { | ||||||
|  |     if (!user) { | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|     if (user.esSuperAdmin) { // SuperAdmin tiene todos los permisos |     if (user.esSuperAdmin) { | ||||||
|       return true; |       return true; | ||||||
|     } |     } | ||||||
|     // Verificar si la lista de permisos del usuario incluye el código requerido |  | ||||||
|     return user.permissions?.includes(codigoPermisoRequerido) ?? false; |     return user.permissions?.includes(codigoPermisoRequerido) ?? false; | ||||||
|   }; |   }, [user]); | ||||||
|  |  | ||||||
|   // También puede exportar el objeto user completo si se necesita en otros lugares |  | ||||||
|   // o propiedades específicas como idPerfil, esSuperAdmin. |  | ||||||
|   return { |   return { | ||||||
|     tienePermiso, |     tienePermiso, | ||||||
|     isSuperAdmin: user?.esSuperAdmin ?? false, |     isSuperAdmin: user?.esSuperAdmin ?? false, | ||||||
|   | |||||||
| @@ -12,6 +12,8 @@ export interface FacturaConsolidadaDto { | |||||||
|     estadoPago: string; |     estadoPago: string; | ||||||
|     estadoFacturacion: string; |     estadoFacturacion: string; | ||||||
|     numeroFactura?: string | null; |     numeroFactura?: string | null; | ||||||
|  |     totalPagado: number; | ||||||
|  |     tipoFactura: 'Mensual' | 'Alta'; | ||||||
|     detalles: FacturaDetalleDto[]; |     detalles: FacturaDetalleDto[]; | ||||||
|     // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers |     // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers | ||||||
|     idSuscriptor: number;  |     idSuscriptor: number;  | ||||||
|   | |||||||
| @@ -3,92 +3,80 @@ import { Box, Paper, Typography, List, ListItemButton, ListItemText, Collapse, C | |||||||
| import { Outlet, useNavigate, useLocation } from 'react-router-dom'; | import { Outlet, useNavigate, useLocation } from 'react-router-dom'; | ||||||
| import ExpandLess from '@mui/icons-material/ExpandLess'; | import ExpandLess from '@mui/icons-material/ExpandLess'; | ||||||
| import ExpandMore from '@mui/icons-material/ExpandMore'; | import ExpandMore from '@mui/icons-material/ExpandMore'; | ||||||
|  | import { usePermissions } from '../../hooks/usePermissions'; | ||||||
|  |  | ||||||
| // Definición de los módulos de reporte con sus categorías, etiquetas y rutas | const allReportModules: { category: string; label: string; path: string; requiredPermission: string; }[] = [ | ||||||
| const allReportModules: { category: string; label: string; path: string }[] = [ |   { category: 'Existencia Papel', label: 'Existencia de Papel', path: 'existencia-papel', requiredPermission: 'RR005' }, | ||||||
|   { category: 'Existencia Papel', label: 'Existencia de Papel', path: 'existencia-papel' }, |   { category: 'Movimientos Bobinas', label: 'Movimiento de Bobinas', path: 'movimiento-bobinas', requiredPermission: 'RR006' }, | ||||||
|   { category: 'Movimientos Bobinas', label: 'Movimiento de Bobinas', path: 'movimiento-bobinas' }, |   { category: 'Movimientos Bobinas', label: 'Mov. Bobinas por Estado', path: 'movimiento-bobinas-estado', requiredPermission: 'RR006' }, | ||||||
|   { category: 'Movimientos Bobinas', label: 'Mov. Bobinas por Estado', path: 'movimiento-bobinas-estado' }, |   { category: 'Listados Distribución', label: 'Distribución Distribuidores', path: 'listado-distribucion-distribuidores', requiredPermission: 'RR002' }, | ||||||
|   { category: 'Listados Distribución', label: 'Distribución Distribuidores', path: 'listado-distribucion-distribuidores' }, |   { category: 'Listados Distribución', label: 'Distribución Canillas', path: 'listado-distribucion-canillas', requiredPermission: 'RR002' }, | ||||||
|   { category: 'Listados Distribución', label: 'Distribución Canillas', path: 'listado-distribucion-canillas' }, |   { category: 'Listados Distribución', label: 'Distribución General', path: 'listado-distribucion-general', requiredPermission: 'RR002' }, | ||||||
|   { category: 'Listados Distribución', label: 'Distribución General', path: 'listado-distribucion-general' }, |   { category: 'Listados Distribución', label: 'Distrib. Canillas (Importe)', path: 'listado-distribucion-canillas-importe', requiredPermission: 'RR002' }, | ||||||
|   { category: 'Listados Distribución', label: 'Distrib. Canillas (Importe)', path: 'listado-distribucion-canillas-importe' }, |   { category: 'Listados Distribución', label: 'Detalle Distribución Canillas', path: 'detalle-distribucion-canillas', requiredPermission: 'MC005' }, | ||||||
|   { category: 'Listados Distribución', label: 'Detalle Distribución Canillas', path: 'detalle-distribucion-canillas' }, |   { category: 'Secretaría', label: 'Venta Mensual Secretaría', path: 'venta-mensual-secretaria', requiredPermission: 'RR002' }, | ||||||
|   { category: 'Secretaría', label: 'Venta Mensual Secretaría', path: 'venta-mensual-secretaria' }, |   { category: 'Tiradas por Publicación', label: 'Tiradas Publicación/Sección', path: 'tiradas-publicaciones-secciones', requiredPermission: 'RR008' }, | ||||||
|   { category: 'Tiradas por Publicación', label: 'Tiradas Publicación/Sección', path: 'tiradas-publicaciones-secciones' }, |   { category: 'Consumos Bobinas', label: 'Consumo Bobinas/Sección', path: 'consumo-bobinas-seccion', requiredPermission: 'RR007' }, | ||||||
|   { category: 'Consumos Bobinas', label: 'Consumo Bobinas/Sección', path: 'consumo-bobinas-seccion' }, |   { category: 'Consumos Bobinas', label: 'Consumo Bobinas/Publicación', path: 'consumo-bobinas-publicacion', requiredPermission: 'RR007' }, | ||||||
|   { category: 'Consumos Bobinas', label: 'Consumo Bobinas/PubPublicación', path: 'consumo-bobinas-publicacion' }, |   { category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas', requiredPermission: 'RR007' }, | ||||||
|   { category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas' }, |   { category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores', requiredPermission: 'RR001' }, | ||||||
|   { category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores' }, |   { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones', requiredPermission: 'RR003' }, | ||||||
|   { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' }, |   { category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas', requiredPermission: 'RR004' }, | ||||||
|   { category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' }, |   { category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual', requiredPermission: 'RR009' }, | ||||||
|   { category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' }, |   { category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad', requiredPermission: 'RR010' }, | ||||||
|   { category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' }, |   { category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion', requiredPermission: 'RR011' }, | ||||||
|   { category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion' }, |  | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| const predefinedCategoryOrder = [ | const predefinedCategoryOrder = [ | ||||||
|   'Balance de Cuentas', |   'Balance de Cuentas', 'Listados Distribución', 'Ctrl. Devoluciones', | ||||||
|   'Listados Distribución', |   'Novedades de Canillitas', 'Suscripciones', 'Existencia Papel', | ||||||
|   'Ctrl. Devoluciones', |   'Movimientos Bobinas', 'Consumos Bobinas', 'Tiradas por Publicación', 'Secretaría', | ||||||
|   'Novedades de Canillitas', |  | ||||||
|   'Suscripciones', |  | ||||||
|   'Existencia Papel', |  | ||||||
|   'Movimientos Bobinas', |  | ||||||
|   'Consumos Bobinas', |  | ||||||
|   'Tiradas por Publicación', |  | ||||||
|   'Secretaría', |  | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
|  |  | ||||||
| const ReportesIndexPage: React.FC = () => { | const ReportesIndexPage: React.FC = () => { | ||||||
|   const navigate = useNavigate(); |   const navigate = useNavigate(); | ||||||
|   const location = useLocation(); |   const location = useLocation(); | ||||||
|  |  | ||||||
|   const [expandedCategory, setExpandedCategory] = useState<string | false>(false); |   const [expandedCategory, setExpandedCategory] = useState<string | false>(false); | ||||||
|   const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true); |   const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true); | ||||||
|  |   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||||
|  |  | ||||||
|   const uniqueCategories = useMemo(() => predefinedCategoryOrder, []); |   const accessibleReportModules = useMemo(() => { | ||||||
|  |     return allReportModules.filter(module => | ||||||
|  |       isSuperAdmin || tienePermiso(module.requiredPermission) | ||||||
|  |     ); | ||||||
|  |   }, [isSuperAdmin, tienePermiso]); | ||||||
|  |    | ||||||
|  |   const accessibleCategories = useMemo(() => { | ||||||
|  |     const categoriesWithAccess = new Set(accessibleReportModules.map(r => r.category)); | ||||||
|  |     return predefinedCategoryOrder.filter(category => categoriesWithAccess.has(category)); | ||||||
|  |   }, [accessibleReportModules]); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const currentBasePath = '/reportes'; |     const currentBasePath = '/reportes'; | ||||||
|     const pathParts = location.pathname.substring(currentBasePath.length + 1).split('/'); |     const pathParts = location.pathname.substring(currentBasePath.length + 1).split('/'); | ||||||
|     const subPathSegment = pathParts[0]; |     const subPathSegment = pathParts[0]; | ||||||
|  |  | ||||||
|     let activeReportFoundInEffect = false; |     if (subPathSegment) { | ||||||
|  |       const activeReport = accessibleReportModules.find(module => module.path === subPathSegment); | ||||||
|     if (subPathSegment && subPathSegment !== "") { // Asegurarse que subPathSegment no esté vacío |  | ||||||
|       const activeReport = allReportModules.find(module => module.path === subPathSegment); |  | ||||||
|       if (activeReport) { |       if (activeReport) { | ||||||
|         setExpandedCategory(activeReport.category); |         setExpandedCategory(activeReport.category); | ||||||
|         activeReportFoundInEffect = true; |  | ||||||
|       } else { |       } else { | ||||||
|  |         // Si la URL apunta a un reporte que no es accesible, no expandimos nada | ||||||
|         setExpandedCategory(false); |         setExpandedCategory(false); | ||||||
|       } |       } | ||||||
|  |     } else { | ||||||
|  |       // Si estamos en la página base (/reportes), expandimos la primera categoría disponible. | ||||||
|  |       if (accessibleCategories.length > 0) { | ||||||
|  |         setExpandedCategory(accessibleCategories[0]); | ||||||
|       } else { |       } else { | ||||||
|         setExpandedCategory(false); |         setExpandedCategory(false); | ||||||
|       } |       } | ||||||
|  |     } | ||||||
|      |      | ||||||
|     if (location.pathname === currentBasePath && allReportModules.length > 0 && isLoadingInitialNavigation) { |     // No hay navegación automática, solo manejamos el estado de carga. | ||||||
|         let firstReportToNavigate: { category: string; label: string; path: string } | null = null; |  | ||||||
|         for (const category of uniqueCategories) { |  | ||||||
|             const reportsInCat = allReportModules.filter(r => r.category === category); |  | ||||||
|             if (reportsInCat.length > 0) { |  | ||||||
|                 firstReportToNavigate = reportsInCat[0]; |  | ||||||
|                 break; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         if (firstReportToNavigate) { |  | ||||||
|             navigate(firstReportToNavigate.path, { replace: true }); |  | ||||||
|             activeReportFoundInEffect = true;  |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     // Solo se establece a false si no estamos en el proceso de navegación inicial O si no se encontró reporte |  | ||||||
|     if (!activeReportFoundInEffect || location.pathname !== currentBasePath) { |  | ||||||
|     setIsLoadingInitialNavigation(false); |     setIsLoadingInitialNavigation(false); | ||||||
|     } |  | ||||||
|  |  | ||||||
|   }, [location.pathname, navigate, uniqueCategories, isLoadingInitialNavigation]); |   }, [location.pathname, accessibleReportModules, accessibleCategories]); | ||||||
|  |  | ||||||
|   const handleCategoryClick = (categoryName: string) => { |   const handleCategoryClick = (categoryName: string) => { | ||||||
|     setExpandedCategory(prev => (prev === categoryName ? false : categoryName)); |     setExpandedCategory(prev => (prev === categoryName ? false : categoryName)); | ||||||
| @@ -99,12 +87,10 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const isReportActive = (reportPath: string) => { |   const isReportActive = (reportPath: string) => { | ||||||
|     return location.pathname === `/reportes/${reportPath}` || location.pathname.startsWith(`/reportes/${reportPath}/`); |     return location.pathname.startsWith(`/reportes/${reportPath}`); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   // Si isLoadingInitialNavigation es true Y estamos en /reportes, mostrar loader |   if (isLoadingInitialNavigation) { | ||||||
|   // Esto evita mostrar el loader si se navega directamente a un sub-reporte. |  | ||||||
|   if (isLoadingInitialNavigation && (location.pathname === '/reportes' || location.pathname === '/reportes/')) { |  | ||||||
|     return ( |     return ( | ||||||
|         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}> |         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}> | ||||||
|             <CircularProgress /> |             <CircularProgress /> | ||||||
| @@ -113,44 +99,25 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     // Contenedor principal que se adaptará a su padre |  | ||||||
|     // Eliminamos 'height: calc(100vh - 64px)' y cualquier margen/padding que controle el espacio exterior |  | ||||||
|     <Box sx={{ display: 'flex', width: '100%', height: '100%' }}> |     <Box sx={{ display: 'flex', width: '100%', height: '100%' }}> | ||||||
|       {/* Panel Lateral para Navegación */} |       <Paper elevation={0} square sx={{ | ||||||
|       <Paper |           width: { xs: 220, sm: 250, md: 280 }, | ||||||
|         elevation={0} // Sin elevación para que se sienta más integrado si el fondo es el mismo |  | ||||||
|         square // Bordes rectos |  | ||||||
|         sx={{ |  | ||||||
|           width: { xs: 220, sm: 250, md: 280 }, // Ancho responsivo del panel lateral |  | ||||||
|           minWidth: { xs: 200, sm: 220 }, |           minWidth: { xs: 200, sm: 220 }, | ||||||
|           height: '100%', // Ocupa toda la altura del Box padre |           height: '100%', | ||||||
|           borderRight: (theme) => `1px solid ${theme.palette.divider}`, |           borderRight: (theme) => `1px solid ${theme.palette.divider}`, | ||||||
|           overflowY: 'auto', |           overflowY: 'auto', | ||||||
|           bgcolor: 'background.paper', // O el color que desees para el menú |           bgcolor: 'background.paper', | ||||||
|           // display: 'flex', flexDirection: 'column' // Para que el título y la lista usen el espacio vertical |         }}> | ||||||
|         }} |         <Box sx={{ p: 1.5 }}> | ||||||
|       > |             <Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 }}> | ||||||
|         {/* Título del Menú Lateral */} |  | ||||||
|         <Box |  | ||||||
|           sx={{ |  | ||||||
|             p: 1.5, // Padding interno para el título |  | ||||||
|             // borderBottom: (theme) => `1px solid ${theme.palette.divider}`, // Opcional: separador |  | ||||||
|             // position: 'sticky', // Si quieres que el título quede fijo al hacer scroll en la lista |  | ||||||
|             // top: 0, |  | ||||||
|             // zIndex: 1, |  | ||||||
|             // bgcolor: 'background.paper' // Necesario si es sticky y tiene scroll la lista |  | ||||||
|           }} |  | ||||||
|         > |  | ||||||
|             <Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 /* Pequeño margen para alinear con items */ }}> |  | ||||||
|             Reportes |             Reportes | ||||||
|             </Typography> |             </Typography> | ||||||
|         </Box> |         </Box> | ||||||
|          |          | ||||||
|         {/* Lista de Categorías y Reportes */} |         {accessibleCategories.length > 0 ? ( | ||||||
|         {uniqueCategories.length > 0 ? ( |         <List component="nav" dense sx={{ pt: 0 }}> | ||||||
|         <List component="nav" dense sx={{ pt: 0 }} /* Quitar padding superior de la lista si el título ya lo tiene */ > |           {accessibleCategories.map((category) => { | ||||||
|           {uniqueCategories.map((category) => { |             const reportsInCategory = accessibleReportModules.filter(r => r.category === category); | ||||||
|             const reportsInCategory = allReportModules.filter(r => r.category === category); |  | ||||||
|             const isExpanded = expandedCategory === category; |             const isExpanded = expandedCategory === category; | ||||||
|  |  | ||||||
|             return ( |             return ( | ||||||
| @@ -158,25 +125,13 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|                 <ListItemButton |                 <ListItemButton | ||||||
|                   onClick={() => handleCategoryClick(category)} |                   onClick={() => handleCategoryClick(category)} | ||||||
|                   sx={{ |                   sx={{ | ||||||
|                     // py: 1.2, // Ajustar padding vertical de items de categoría |  | ||||||
|                     // backgroundColor: isExpanded ? 'action.selected' : 'transparent', |  | ||||||
|                     borderLeft: isExpanded ? (theme) => `4px solid ${theme.palette.primary.main}` : '4px solid transparent', |                     borderLeft: isExpanded ? (theme) => `4px solid ${theme.palette.primary.main}` : '4px solid transparent', | ||||||
|                     pr: 1, // Menos padding a la derecha para dar espacio al ícono expander |                     pr: 1, | ||||||
|                     '&:hover': { |                     '&:hover': { backgroundColor: 'action.hover' } | ||||||
|                         backgroundColor: 'action.hover' |                   }}> | ||||||
|                     } |                   <ListItemText primary={category} primaryTypographyProps={{ fontWeight: isExpanded ? 'bold' : 'normal' }}/> | ||||||
|                   }} |                   {isExpanded ? <ExpandLess /> : <ExpandMore />} | ||||||
|                 > |  | ||||||
|                   <ListItemText |  | ||||||
|                     primary={category} |  | ||||||
|                     primaryTypographyProps={{  |  | ||||||
|                         fontWeight: isExpanded ? 'bold' : 'normal', |  | ||||||
|                         // color: isExpanded ? 'primary.main' : 'text.primary'  |  | ||||||
|                     }} |  | ||||||
|                   /> |  | ||||||
|                   {reportsInCategory.length > 0 && (isExpanded ? <ExpandLess /> : <ExpandMore />)} |  | ||||||
|                 </ListItemButton> |                 </ListItemButton> | ||||||
|                 {reportsInCategory.length > 0 && ( |  | ||||||
|                 <Collapse in={isExpanded} timeout="auto" unmountOnExit> |                 <Collapse in={isExpanded} timeout="auto" unmountOnExit> | ||||||
|                     <List component="div" disablePadding dense> |                     <List component="div" disablePadding dense> | ||||||
|                       {reportsInCategory.map((report) => ( |                       {reportsInCategory.map((report) => ( | ||||||
| @@ -185,62 +140,39 @@ const ReportesIndexPage: React.FC = () => { | |||||||
|                           selected={isReportActive(report.path)} |                           selected={isReportActive(report.path)} | ||||||
|                           onClick={() => handleReportClick(report.path)} |                           onClick={() => handleReportClick(report.path)} | ||||||
|                           sx={{  |                           sx={{  | ||||||
|                             pl: 3.5, // Indentación para los reportes (ajustar si se cambió el padding del título) |                             pl: 3.5, py: 0.8, | ||||||
|                             py: 0.8, // Padding vertical de items de reporte |  | ||||||
|                             ...(isReportActive(report.path) && { |                             ...(isReportActive(report.path) && { | ||||||
|                                 backgroundColor: (theme) => theme.palette.action.selected, // Un color de fondo sutil |                                 backgroundColor: (theme) => theme.palette.action.selected, | ||||||
|                                 borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`, // Un borde para el activo |                                 borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`, | ||||||
|                                 '& .MuiListItemText-primary': { |                                 '& .MuiListItemText-primary': { fontWeight: 'medium' }, | ||||||
|                                     fontWeight: 'medium', // O 'bold' |  | ||||||
|                                     // color: 'primary.main' |  | ||||||
|                                 }, |  | ||||||
|                             }), |                             }), | ||||||
|                             '&:hover': { |                             '&:hover': { backgroundColor: (theme) => theme.palette.action.hover } | ||||||
|                                 backgroundColor: (theme) => theme.palette.action.hover |                           }}> | ||||||
|                             } |  | ||||||
|                           }} |  | ||||||
|                         > |  | ||||||
|                           <ListItemText primary={report.label} primaryTypographyProps={{ variant: 'body2' }}/> |                           <ListItemText primary={report.label} primaryTypographyProps={{ variant: 'body2' }}/> | ||||||
|                         </ListItemButton> |                         </ListItemButton> | ||||||
|                       ))} |                       ))} | ||||||
|                     </List> |                     </List> | ||||||
|                 </Collapse> |                 </Collapse> | ||||||
|                 )} |  | ||||||
|                 {reportsInCategory.length === 0 && isExpanded && ( |  | ||||||
|                      <ListItemText  |  | ||||||
|                         primary="No hay reportes en esta categoría."  |  | ||||||
|                         sx={{ pl: 3.5, fontStyle: 'italic', color: 'text.secondary', py:1, typography: 'body2' }} |  | ||||||
|                      /> |  | ||||||
|                  )} |  | ||||||
|               </React.Fragment> |               </React.Fragment> | ||||||
|             ); |             ); | ||||||
|           })} |           })} | ||||||
|         </List> |         </List> | ||||||
|         ) : ( |         ) : ( | ||||||
|             <Typography sx={{p:2, fontStyle: 'italic'}}>No hay categorías configuradas.</Typography> |             <Typography sx={{p:2, fontStyle: 'italic'}}>No tiene acceso a ningún reporte.</Typography> | ||||||
|         )} |         )} | ||||||
|       </Paper> |       </Paper> | ||||||
|  |  | ||||||
|       {/* Área Principal para el Contenido del Reporte */} |       <Box component="main" sx={{ | ||||||
|       <Box |           flexGrow: 1, p: { xs: 1, sm: 2, md: 3 }, | ||||||
|         component="main" |           overflowY: 'auto', height: '100%', bgcolor: 'grey.100' | ||||||
|         sx={{ |         }}> | ||||||
|           flexGrow: 1, // Ocupa el espacio restante |         {/* Lógica para mostrar el mensaje de bienvenida */} | ||||||
|           p: { xs: 1, sm: 2, md: 3 }, // Padding interno para el contenido, responsivo |         {location.pathname === '/reportes' && !isLoadingInitialNavigation && ( | ||||||
|           overflowY: 'auto', |  | ||||||
|           height: '100%', // Ocupa toda la altura del Box padre |  | ||||||
|           bgcolor: 'grey.100' // Un color de fondo diferente para distinguir el área de contenido |  | ||||||
|         }} |  | ||||||
|       > |  | ||||||
|         {/* El Outlet renderiza el componente del reporte específico */} |  | ||||||
|         {(!location.pathname.startsWith('/reportes/') || !allReportModules.some(r => isReportActive(r.path))) && location.pathname !== '/reportes/' && location.pathname !== '/reportes' && !isLoadingInitialNavigation && ( |  | ||||||
|              <Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}> |              <Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}> | ||||||
|                 El reporte solicitado no existe o la ruta no es válida. |                 {accessibleReportModules.length > 0  | ||||||
|             </Typography> |                     ? "Seleccione una categoría y un reporte del menú lateral."  | ||||||
|         )} |                     : "No tiene acceso a ningún reporte." | ||||||
|         {(location.pathname === '/reportes/' || location.pathname === '/reportes') && !allReportModules.some(r => isReportActive(r.path)) && !isLoadingInitialNavigation && ( |                 } | ||||||
|              <Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}> |  | ||||||
|                 {allReportModules.length > 0 ? "Seleccione una categoría y un reporte del menú lateral." : "No hay reportes configurados."} |  | ||||||
|             </Typography> |             </Typography> | ||||||
|         )} |         )} | ||||||
|         <Outlet /> |         <Outlet /> | ||||||
|   | |||||||
| @@ -24,8 +24,9 @@ const meses = [ | |||||||
|     { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } |     { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| const estadosPago = ['Pendiente', 'Pagada', 'Rechazada', 'Anulada']; | const estadosPago = ['Pendiente', 'Pagada', 'Pagada Parcialmente', 'Rechazada', 'Anulada']; | ||||||
| const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; | const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; | ||||||
|  | const tiposFactura = ['Mensual', 'Alta']; | ||||||
|  |  | ||||||
| const SuscriptorRow: React.FC<{ | const SuscriptorRow: React.FC<{ | ||||||
|     resumen: ResumenCuentaSuscriptorDto; |     resumen: ResumenCuentaSuscriptorDto; | ||||||
| @@ -33,50 +34,60 @@ const SuscriptorRow: React.FC<{ | |||||||
|     handleOpenHistorial: (factura: FacturaConsolidadaDto) => void; |     handleOpenHistorial: (factura: FacturaConsolidadaDto) => void; | ||||||
| }> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => { | }> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => { | ||||||
|     const [open, setOpen] = useState(false); |     const [open, setOpen] = useState(false); | ||||||
|  |     const formatCurrency = (value: number) => `$${value.toFixed(2)}`; | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <React.Fragment> |         <React.Fragment> | ||||||
|             <TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover> |             <TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover> | ||||||
|                 <TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell> |                 <TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell> | ||||||
|                 <TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell> |                 <TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell> | ||||||
|                 <TableCell align="right"> |                 <TableCell align="right"> | ||||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>${resumen.saldoPendienteTotal.toFixed(2)}</Typography> |                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>{formatCurrency(resumen.saldoPendienteTotal)}</Typography> | ||||||
|                     <Typography variant="caption" color="text.secondary">de ${resumen.importeTotal.toFixed(2)}</Typography> |                     <Typography variant="caption" color="text.secondary">de {formatCurrency(resumen.importeTotal)}</Typography> | ||||||
|                 </TableCell> |                 </TableCell> | ||||||
|                 <TableCell colSpan={5}></TableCell> |                 <TableCell colSpan={6}></TableCell> | ||||||
|             </TableRow> |             </TableRow> | ||||||
|             <TableRow> |             <TableRow> | ||||||
|                 <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}> |                 <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={9}> {/* <-- Ajustado para la nueva columna */} | ||||||
|                     <Collapse in={open} timeout="auto" unmountOnExit> |                     <Collapse in={open} timeout="auto" unmountOnExit> | ||||||
|                         <Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> |                         <Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> | ||||||
|                             <Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography> |                             <Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography> | ||||||
|                             <Table size="small"> |                             <Table size="small"> | ||||||
|                                 <TableHead> |                                 <TableHead> | ||||||
|                                     <TableRow> |                                     <TableRow> | ||||||
|                                         <TableCell>Empresa</TableCell><TableCell align="right">Importe</TableCell> |                                         <TableCell>Empresa</TableCell> | ||||||
|                                         <TableCell>Estado Pago</TableCell><TableCell>Estado Facturación</TableCell> |                                         <TableCell align="right">Importe Total</TableCell> | ||||||
|                                         <TableCell>Nro. Factura</TableCell><TableCell align="right">Acciones</TableCell> |                                         <TableCell align="right">Pagado</TableCell> | ||||||
|  |                                         <TableCell align="right">Saldo</TableCell> | ||||||
|  |                                         <TableCell>Tipo Factura</TableCell> | ||||||
|  |                                         <TableCell>Estado Pago</TableCell> | ||||||
|  |                                         <TableCell>Estado Facturación</TableCell> | ||||||
|  |                                         <TableCell>Nro. Factura</TableCell> | ||||||
|  |                                         <TableCell align="right">Acciones</TableCell> | ||||||
|                                     </TableRow> |                                     </TableRow> | ||||||
|                                 </TableHead> |                                 </TableHead> | ||||||
|                                 <TableBody> |                                 <TableBody> | ||||||
|                                     {resumen.facturas.map((factura) => ( |                                     {resumen.facturas.map((factura) => { | ||||||
|  |                                         const saldo = factura.importeFinal - factura.totalPagado; | ||||||
|  |                                         return ( | ||||||
|                                             <TableRow key={factura.idFactura}> |                                             <TableRow key={factura.idFactura}> | ||||||
|                                                 <TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell> |                                                 <TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell> | ||||||
|                                             <TableCell align="right">${factura.importeFinal.toFixed(2)}</TableCell> |                                                 <TableCell align="right">{formatCurrency(factura.importeFinal)}</TableCell> | ||||||
|                                             <TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default')} /></TableCell> |                                                 <TableCell align="right" sx={{ color: 'success.dark' }}>{formatCurrency(factura.totalPagado)}</TableCell> | ||||||
|  |                                                 <TableCell align="right" sx={{ fontWeight: 'bold', color: saldo > 0 ? 'error.main' : 'inherit' }}>{formatCurrency(saldo)}</TableCell> | ||||||
|  |                                                 <TableCell> | ||||||
|  |                                                     <Chip label={factura.tipoFactura} size="small" color={factura.tipoFactura === 'Alta' ? 'secondary' : 'default'} /> | ||||||
|  |                                                 </TableCell> | ||||||
|  |                                                 <TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Pagada Parcialmente' ? 'primary' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default'))} /></TableCell> | ||||||
|                                                 <TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell> |                                                 <TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell> | ||||||
|                                                 <TableCell>{factura.numeroFactura || '-'}</TableCell> |                                                 <TableCell>{factura.numeroFactura || '-'}</TableCell> | ||||||
|                                                 <TableCell align="right"> |                                                 <TableCell align="right"> | ||||||
|                                                 <IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> |                                                     <IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}><MoreVertIcon /></IconButton> | ||||||
|                                                     <MoreVertIcon /> |                                                     <Tooltip title="Ver Historial de Envíos"><IconButton onClick={() => handleOpenHistorial(factura)}><MailOutlineIcon /></IconButton></Tooltip> | ||||||
|                                                 </IconButton> |  | ||||||
|                                                 <Tooltip title="Ver Historial de Envíos"> |  | ||||||
|                                                     <IconButton onClick={() => handleOpenHistorial(factura)}> |  | ||||||
|                                                         <MailOutlineIcon /> |  | ||||||
|                                                     </IconButton> |  | ||||||
|                                                 </Tooltip> |  | ||||||
|                                                 </TableCell> |                                                 </TableCell> | ||||||
|                                             </TableRow> |                                             </TableRow> | ||||||
|                                     ))} |                                         ); | ||||||
|  |                                     })} | ||||||
|                                 </TableBody> |                                 </TableBody> | ||||||
|                             </Table> |                             </Table> | ||||||
|                         </Box> |                         </Box> | ||||||
| @@ -102,6 +113,7 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|     const [filtroNombre, setFiltroNombre] = useState(''); |     const [filtroNombre, setFiltroNombre] = useState(''); | ||||||
|     const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); |     const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); | ||||||
|     const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); |     const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); | ||||||
|  |     const [filtroTipoFactura, setFiltroTipoFactura] = useState(''); | ||||||
|  |  | ||||||
|     const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null); |     const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null); | ||||||
|     const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); |     const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||||
| @@ -121,7 +133,8 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|                 selectedMes, |                 selectedMes, | ||||||
|                 filtroNombre || undefined, |                 filtroNombre || undefined, | ||||||
|                 filtroEstadoPago || undefined, |                 filtroEstadoPago || undefined, | ||||||
|                 filtroEstadoFacturacion || undefined |                 filtroEstadoFacturacion || undefined, | ||||||
|  |                 filtroTipoFactura || undefined | ||||||
|             ); |             ); | ||||||
|             setResumenes(data); |             setResumenes(data); | ||||||
|         } catch (err) { |         } catch (err) { | ||||||
| @@ -130,7 +143,7 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|         } finally { |         } finally { | ||||||
|             setLoading(false); |             setLoading(false); | ||||||
|         } |         } | ||||||
|     }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]); |     }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion, filtroTipoFactura]); | ||||||
|  |  | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         const timer = setTimeout(() => { |         const timer = setTimeout(() => { | ||||||
| @@ -218,6 +231,17 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|                     <TextField label="Buscar por Suscriptor" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1, minWidth: '200px' }} /> |                     <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 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> |                     <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> | ||||||
|  |                     <FormControl sx={{ minWidth: 200 }} size="small"> | ||||||
|  |                         <InputLabel>Tipo de Factura</InputLabel> | ||||||
|  |                         <Select | ||||||
|  |                             value={filtroTipoFactura} | ||||||
|  |                             label="Tipo de Factura" | ||||||
|  |                             onChange={(e) => setFiltroTipoFactura(e.target.value)} | ||||||
|  |                         > | ||||||
|  |                             <MenuItem value=""><em>Todos</em></MenuItem> | ||||||
|  |                             {tiposFactura.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)} | ||||||
|  |                         </Select> | ||||||
|  |                     </FormControl> | ||||||
|                 </Box> |                 </Box> | ||||||
|             </Paper> |             </Paper> | ||||||
|  |  | ||||||
| @@ -226,7 +250,7 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|  |  | ||||||
|             <TableContainer component={Paper}> |             <TableContainer component={Paper}> | ||||||
|                 <Table aria-label="collapsible table"> |                 <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={6}></TableCell></TableRow></TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
|                         {loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>) |                         {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.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>) | ||||||
| @@ -252,22 +276,9 @@ const ConsultaFacturasPage: React.FC = () => { | |||||||
|                 open={pagoModalOpen} |                 open={pagoModalOpen} | ||||||
|                 onClose={handleClosePagoModal} |                 onClose={handleClosePagoModal} | ||||||
|                 onSubmit={handleSubmitPagoModal} |                 onSubmit={handleSubmitPagoModal} | ||||||
|                 factura={ |                 factura={selectedFactura} | ||||||
|                     selectedFactura ? { |                 nombreSuscriptor={ | ||||||
|                         idFactura: selectedFactura.idFactura, |                     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, |  | ||||||
|                         saldoPendiente: selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal, |  | ||||||
|                         idSuscriptor: resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor || 0, |  | ||||||
|                         periodo: '', |  | ||||||
|                         fechaEmision: '', |  | ||||||
|                         fechaVencimiento: '', |  | ||||||
|                         totalPagado: selectedFactura.importeFinal - (selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal), |  | ||||||
|                         estadoPago: selectedFactura.estadoPago, |  | ||||||
|                         estadoFacturacion: selectedFactura.estadoFacturacion, |  | ||||||
|                         numeroFactura: selectedFactura.numeroFactura, |  | ||||||
|                         detalles: selectedFactura.detalles, |  | ||||||
|                     } : null |  | ||||||
|                 } |                 } | ||||||
|                 errorMessage={apiError} |                 errorMessage={apiError} | ||||||
|                 clearErrorMessage={() => setApiError(null)} |                 clearErrorMessage={() => setApiError(null)} | ||||||
|   | |||||||
| @@ -16,11 +16,12 @@ const SECCION_PERMISSIONS_PREFIX = "SS"; | |||||||
|  |  | ||||||
| const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { | const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { | ||||||
|   if (codAcc === "SS001") return "Distribución";   |   if (codAcc === "SS001") return "Distribución";   | ||||||
|  |   if (codAcc === "SS007") return "Suscripciones";  | ||||||
|   if (codAcc === "SS002") return "Contables"; |   if (codAcc === "SS002") return "Contables"; | ||||||
|   if (codAcc === "SS003") return "Impresión"; |   if (codAcc === "SS003") return "Impresión"; | ||||||
|   if (codAcc === "SS004") return "Reportes"; |   if (codAcc === "SS004") return "Reportes"; | ||||||
|   if (codAcc === "SS005") return "Radios"; |  | ||||||
|   if (codAcc === "SS006") return "Usuarios";  |   if (codAcc === "SS006") return "Usuarios";  | ||||||
|  |   if (codAcc === "SS005") return "Radios"; | ||||||
|   return null; |   return null; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -38,6 +39,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | |||||||
|     moduloLower.includes("salidas otros destinos")) { |     moduloLower.includes("salidas otros destinos")) { | ||||||
|     return "Distribución"; |     return "Distribución"; | ||||||
|   }   |   }   | ||||||
|  |   if (moduloLower.includes("suscripciones")) { | ||||||
|  |     return "Suscripciones"; | ||||||
|  |   } | ||||||
|   if (moduloLower.includes("cuentas pagos") || |   if (moduloLower.includes("cuentas pagos") || | ||||||
|     moduloLower.includes("cuentas notas") || |     moduloLower.includes("cuentas notas") || | ||||||
|     moduloLower.includes("cuentas tipos pagos")) { |     moduloLower.includes("cuentas tipos pagos")) { | ||||||
| @@ -50,9 +54,6 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | |||||||
|     moduloLower.includes("tipos bobinas")) { |     moduloLower.includes("tipos bobinas")) { | ||||||
|     return "Impresión"; |     return "Impresión"; | ||||||
|   } |   } | ||||||
|   if (moduloLower.includes("radios")) { |  | ||||||
|     return "Radios"; |  | ||||||
|   } |  | ||||||
|   if (moduloLower.includes("usuarios") || |   if (moduloLower.includes("usuarios") || | ||||||
|     moduloLower.includes("perfiles")) { |     moduloLower.includes("perfiles")) { | ||||||
|     return "Usuarios"; |     return "Usuarios"; | ||||||
| @@ -63,6 +64,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | |||||||
|   if (moduloLower.includes("permisos")) { |   if (moduloLower.includes("permisos")) { | ||||||
|     return "Permisos (Definición)"; |     return "Permisos (Definición)"; | ||||||
|   }   |   }   | ||||||
|  |   if (moduloLower.includes("radios")) { | ||||||
|  |     return "Radios"; | ||||||
|  |   } | ||||||
|   return permisoModulo; |   return permisoModulo; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,11 +1,9 @@ | |||||||
| // src/routes/AppRoutes.tsx |  | ||||||
| import React, { type JSX } from 'react'; | import React, { type JSX } from 'react'; | ||||||
| import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; | import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; | ||||||
| import LoginPage from '../pages/LoginPage'; | import LoginPage from '../pages/LoginPage'; | ||||||
| import HomePage from '../pages/HomePage'; | import HomePage from '../pages/HomePage'; | ||||||
| import { useAuth } from '../contexts/AuthContext'; | import { useAuth } from '../contexts/AuthContext'; | ||||||
| import MainLayout from '../layouts/MainLayout'; | import MainLayout from '../layouts/MainLayout'; | ||||||
| import { Typography } from '@mui/material'; |  | ||||||
| import SectionProtectedRoute from './SectionProtectedRoute'; | import SectionProtectedRoute from './SectionProtectedRoute'; | ||||||
|  |  | ||||||
| // Distribución | // Distribución | ||||||
| @@ -267,7 +265,6 @@ const AppRoutes = () => { | |||||||
|                 <ReportesIndexPage /> |                 <ReportesIndexPage /> | ||||||
|               </SectionProtectedRoute>} |               </SectionProtectedRoute>} | ||||||
|           > |           > | ||||||
|             <Route index element={<Typography sx={{ p: 2 }}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */} |  | ||||||
|             <Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} /> |             <Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} /> | ||||||
|             <Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} /> |             <Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} /> | ||||||
|             <Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} /> |             <Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} /> | ||||||
|   | |||||||
| @@ -19,11 +19,16 @@ const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLot | |||||||
|     return response.data; |     return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise<ResumenCuentaSuscriptorDto[]> => { | const getResumenesDeCuentaPorPeriodo = async ( | ||||||
|  |     anio: number, mes: number, | ||||||
|  |     nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string, | ||||||
|  |     tipoFactura?: string | ||||||
|  | ): Promise<ResumenCuentaSuscriptorDto[]> => { | ||||||
|     const params = new URLSearchParams(); |     const params = new URLSearchParams(); | ||||||
|     if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor); |     if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor); | ||||||
|     if (estadoPago) params.append('estadoPago', estadoPago); |     if (estadoPago) params.append('estadoPago', estadoPago); | ||||||
|     if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion); |     if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion); | ||||||
|  |     if (tipoFactura) params.append('tipoFactura', tipoFactura); | ||||||
|  |  | ||||||
|     const queryString = params.toString(); |     const queryString = params.toString(); | ||||||
|     const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`; |     const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`; | ||||||
|   | |||||||
| @@ -33,19 +33,18 @@ apiClient.interceptors.response.use( | |||||||
|   (error) => { |   (error) => { | ||||||
|     // Cualquier código de estado que este fuera del rango de 2xx causa la ejecución de esta función |     // Cualquier código de estado que este fuera del rango de 2xx causa la ejecución de esta función | ||||||
|     if (axios.isAxiosError(error) && error.response) { |     if (axios.isAxiosError(error) && error.response) { | ||||||
|       if (error.response.status === 401) { |       // Verificamos si la petición fallida NO fue al endpoint de login. | ||||||
|         // Token inválido o expirado |       const isLoginAttempt = error.config?.url?.endsWith('/auth/login'); | ||||||
|         console.warn("Error 401: Token inválido o expirado. Deslogueando..."); |  | ||||||
|  |       // Solo activamos el deslogueo automático si el error 401 NO es de un intento de login. | ||||||
|  |       if (error.response.status === 401 && !isLoginAttempt) { | ||||||
|  |         console.warn("Error 401 (Token inválido o expirado) detectado. Deslogueando..."); | ||||||
|          |          | ||||||
|         // Limpiar localStorage y recargar la página. |  | ||||||
|         // AuthContext se encargará de redirigir a /login al recargar porque no encontrará token. |  | ||||||
|         localStorage.removeItem('authToken'); |         localStorage.removeItem('authToken'); | ||||||
|         localStorage.removeItem('authUser'); // Asegurar limpiar también el usuario |         localStorage.removeItem('authUser'); | ||||||
|         // Forzar un hard refresh para que AuthContext se reinicialice y redirija |          | ||||||
|         // Esto también limpiará cualquier estado de React. |  | ||||||
|         // --- Mostrar mensaje antes de redirigir --- |  | ||||||
|         alert("Tu sesión ha expirado o no es válida. Serás redirigido a la página de inicio de sesión."); |         alert("Tu sesión ha expirado o no es válida. Serás redirigido a la página de inicio de sesión."); | ||||||
|         window.location.href = '/login'; // Redirección más directa |         window.location.href = '/login'; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     // Es importante devolver el error para que el componente que hizo la llamada pueda manejarlo también si es necesario |     // Es importante devolver el error para que el componente que hizo la llamada pueda manejarlo también si es necesario | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								README.md
									
									
									
									
									
								
							| @@ -60,6 +60,22 @@ El sistema está organizado en varios módulos clave para cubrir todas las área | |||||||
| - **Autenticación Segura:** Mediante JSON Web Tokens (JWT). | - **Autenticación Segura:** Mediante JSON Web Tokens (JWT). | ||||||
| - **Auditoría:** Todas las modificaciones a los datos maestros y transacciones importantes se registran en tablas de historial (`_H`). | - **Auditoría:** Todas las modificaciones a los datos maestros y transacciones importantes se registran en tablas de historial (`_H`). | ||||||
|  |  | ||||||
|  | ### 📨 Suscripciones | ||||||
|  | - **Gestión de Suscriptores:** ABM completo de clientes, incluyendo datos de contacto, dirección de entrega y forma de pago preferida. | ||||||
|  | - **Ciclo de Vida de la Suscripción:** Creación y administración de suscripciones por cliente y publicación, con fechas de inicio, fin, días de entrega y estados (`Activa`, `Pausada`, `Cancelada`). | ||||||
|  | - **Facturación Proporcional (Pro-rata):** El sistema genera automáticamente una "Factura de Alta" por el monto proporcional cuando un cliente se suscribe en un período ya cerrado, evitando cobros excesivos en la primera factura. | ||||||
|  | - **Gestión de Promociones:** ABM de promociones (ej. descuentos porcentuales, bonificación de días) y asignación a suscripciones específicas con vigencia definida. | ||||||
|  | - **Cuenta Corriente del Suscriptor:** Administración de ajustes manuales (`Crédito`/`Débito`) para manejar situaciones excepcionales como notas de crédito, devoluciones o cargos especiales. | ||||||
|  | - **Procesos de Cierre Mensual:** | ||||||
|  |   - **Generación de Cierre:** Proceso masivo que calcula y genera todas las facturas del período, aplicando promociones y ajustes. | ||||||
|  |   - **Notificaciones Automáticas:** Envío automático de resúmenes de cuenta por email a cada suscriptor al generar el cierre. | ||||||
|  | - **Gestión de Débito Automático:** | ||||||
|  |   - **Generación de Archivo:** Creación del archivo de texto plano en formato "Pago Directo" para el Banco Galicia. Las "Facturas de Alta" se excluyen automáticamente de este proceso. | ||||||
|  |   - **Procesamiento de Respuesta:** Herramienta para procesar el archivo de respuesta del banco, actualizando los estados de pago (`Pagada`/`Rechazada`) de forma masiva. | ||||||
|  | - **Auditoría de Comunicaciones:** | ||||||
|  |   - **Log de Envíos:** Registro detallado de cada correo electrónico enviado (individual o masivo), incluyendo estado (`Enviado`/`Fallido`) y mensajes de error. | ||||||
|  |   - **Historial de Envíos:** Interfaz para consultar el historial de notificaciones enviadas por cada factura o por cada lote de cierre mensual. | ||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
| ## 🛠️ Stack Tecnológico | ## 🛠️ Stack Tecnológico | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user