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 | ||||
|             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 | ||||
|             TEMP_DIR=$(mktemp -d) | ||||
|             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, | ||||
|         [FromQuery] string? nombreSuscriptor, | ||||
|         [FromQuery] string? estadoPago, | ||||
|             [FromQuery] string? estadoFacturacion) | ||||
|         [FromQuery] string? estadoFacturacion, | ||||
|         [FromQuery] string? tipoFactura) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); | ||||
|             if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." }); | ||||
|  | ||||
|             var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion); | ||||
|             var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura); | ||||
|             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."); | ||||
|             } | ||||
|  | ||||
|             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.* | ||||
|                 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); | ||||
|         } | ||||
| @@ -104,7 +109,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
|             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(@" | ||||
|                 WITH FacturaConEmpresa AS ( | ||||
| @@ -149,6 +155,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
|                 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;"); | ||||
|  | ||||
|             try | ||||
|   | ||||
| @@ -15,7 +15,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
|         Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); | ||||
|         Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); | ||||
|         Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); | ||||
|         Task<IEnumerable<(Factura Factura, string NombreSuscriptor, 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<string?> GetUltimoPeriodoFacturadoAsync(); | ||||
|         Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo); | ||||
|   | ||||
| @@ -9,7 +9,6 @@ | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> | ||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||
|     <PackageReference Include="DotNetEnv" Version="3.1.1" /> | ||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> | ||||
|     <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 EstadoFacturacion { get; set; } = string.Empty; | ||||
|         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>(); | ||||
|     } | ||||
| } | ||||
| @@ -15,5 +15,6 @@ namespace GestionIntegral.Api.Models.Suscripciones | ||||
|         public string? NumeroFactura { get; set; } | ||||
|         public int? IdLoteDebito { 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.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); | ||||
|  | ||||
| // --- Registros de Servicios --- | ||||
|   | ||||
| @@ -4,6 +4,8 @@ using MailKit.Net.Smtp; | ||||
| using MailKit.Security; | ||||
| using Microsoft.Extensions.Options; | ||||
| using MimeKit; | ||||
| using System.Net.Security; | ||||
| using System.Security.Cryptography.X509Certificates; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Comunicaciones | ||||
| { | ||||
| @@ -88,6 +90,30 @@ namespace GestionIntegral.Api.Services.Comunicaciones | ||||
|             using var smtp = new SmtpClient(); | ||||
|             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.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); | ||||
|                 await smtp.SendAsync(emailMessage); | ||||
| @@ -95,20 +121,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones | ||||
|                 log.Estado = "Enviado"; | ||||
|                 _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) | ||||
|             { | ||||
|                 _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 ILogger<DebitoAutomaticoService> _logger; | ||||
|  | ||||
|         private const string NRO_PRESTACION = "123456"; | ||||
|         private const string ORIGEN_EMPRESA = "ELDIA"; | ||||
|         private const string NRO_PRESTACION = "26435"; // Reemplazar por el número real | ||||
|         private const string ORIGEN_EMPRESA = "EMPRESA"; | ||||
|  | ||||
|         public DebitoAutomaticoService( | ||||
|             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) | ||||
|         { | ||||
|             // Se define la identificación del archivo. | ||||
|             // Este número debe ser gestionado para no repetirse en archivos generados | ||||
|             // para la misma prestación y fecha. | ||||
|             // Este número debe ser gestionado para no repetirse. Por ahora, lo mantenemos como 1. | ||||
|             const int identificacionArchivo = 1; | ||||
|  | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
| @@ -62,8 +60,6 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|  | ||||
|                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); | ||||
|                 var cantidadRegistros = facturasParaDebito.Count(); | ||||
|                  | ||||
|                 // Se utiliza la variable 'identificacionArchivo' para nombrar el archivo. | ||||
|                 var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt"; | ||||
|  | ||||
|                 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."); | ||||
|  | ||||
|                 var sb = new StringBuilder(); | ||||
|                 // Se pasa la 'identificacionArchivo' al método que crea el Header. | ||||
|                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); | ||||
|                 foreach (var item in facturasParaDebito) | ||||
|                 { | ||||
|                     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)); | ||||
|  | ||||
|                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); | ||||
| @@ -108,17 +102,18 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); | ||||
|             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); | ||||
|  | ||||
|                 // 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) | ||||
|                 { | ||||
|                     _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; | ||||
|                 } | ||||
|  | ||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); | ||||
|                 if (formaPago != null && formaPago.RequiereCBU) | ||||
|                 { | ||||
| @@ -128,26 +123,13 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|             return resultado; | ||||
|         } | ||||
|  | ||||
|         // Lógica de conversión de CBU. | ||||
|         private string ConvertirCbuBanelcoASnp(string cbu22) | ||||
|         { | ||||
|             if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) | ||||
|             { | ||||
|                 _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] | ||||
|             if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) return "".PadRight(26); | ||||
|             try | ||||
|             { | ||||
|                 string bloque1 = cbu22.Substring(0, 8); // Contiene código de banco, sucursal y DV del bloque 1. | ||||
|                 string bloque2 = cbu22.Substring(8);    // Contiene el resto de la cadena. | ||||
|  | ||||
|                 // Reconstruir en formato SNP de 26 dígitos según el instructivo. | ||||
|                 string bloque1 = cbu22.Substring(0, 8); | ||||
|                 string bloque2 = cbu22.Substring(8); | ||||
|                 return $"0{bloque1}000{bloque2}"; | ||||
|             } | ||||
|             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 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 | ||||
|         { | ||||
|             "DNI" => "0096", | ||||
| @@ -167,17 +150,17 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|             "CUIL" => "0086", | ||||
|             "LE" => "0089", | ||||
|             "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) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("00"); // Tipo de Registro Header | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos | ||||
|             sb.Append("00"); | ||||
|             sb.Append(FormatNumericString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); | ||||
|             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(FormatNumeric((long)(importeTotal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
| @@ -188,35 +171,33 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|  | ||||
|         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) | ||||
|         { | ||||
|             // Convertimos el CBU de 22 (Banelco) a 26 (SNP) antes de usarlo. | ||||
|             string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!); | ||||
|  | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("0370"); // Tipo de Registro Detalle (Orden de Débito) | ||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación de Cliente | ||||
|             sb.Append(FormatString(cbu26, 26)); // CBU en formato SNP de 26 caracteres. | ||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); // Referencia Unívoca de la factura. | ||||
|             sb.Append("0370"); | ||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); | ||||
|             sb.Append(cbu26); | ||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); | ||||
|             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); | ||||
|             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento | ||||
|             sb.Append("0"); // Moneda (0 = Pesos) | ||||
|             sb.Append(FormatString("", 3)); // Motivo Rechazo (vacío en el envío) | ||||
|             sb.Append("0"); | ||||
|             sb.Append(FormatString("", 3)); | ||||
|             sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4)); | ||||
|             sb.Append(FormatString(suscriptor.NroDocumento, 11)); | ||||
|             sb.Append(FormatString("", 22)); // Nueva ID Cliente | ||||
|             sb.Append(FormatString("", 26)); // Nueva CBU | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vencimiento | ||||
|             sb.Append(FormatString("", 22)); // Identificación Cuenta Anterior | ||||
|             sb.Append(FormatString("", 40)); // Mensaje ATM | ||||
|             sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); // Concepto Factura | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Cobrado | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Acreditamiento | ||||
|             sb.Append(FormatString("", 26)); // Libre | ||||
|             sb.Append(FormatNumericString(suscriptor.NroDocumento, 11)); | ||||
|             sb.Append(FormatString("", 22)); | ||||
|             sb.Append(FormatString("", 26)); | ||||
|             sb.Append(FormatNumeric(0, 14)); | ||||
|             sb.Append(FormatNumeric(0, 8)); | ||||
|             sb.Append(FormatString("", 22)); | ||||
|             sb.Append(FormatString("", 40)); | ||||
|             sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); | ||||
|             sb.Append(FormatNumeric(0, 8)); | ||||
|             sb.Append(FormatNumeric(0, 14)); | ||||
|             sb.Append(FormatNumeric(0, 8)); | ||||
|             sb.Append(FormatString("", 26)); | ||||
|             sb.Append("\r\n"); | ||||
|             return sb.ToString(); | ||||
|         } | ||||
| @@ -224,16 +205,16 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("99"); // Tipo de Registro Trailer | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio: Sistema Nacional de Pagos | ||||
|             sb.Append("99"); | ||||
|             sb.Append(FormatNumericString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); | ||||
|             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(FormatNumeric((long)(importeTotal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
|             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(); | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -171,10 +171,13 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|                         DescuentoAplicado = descuentoPromocionesTotal, | ||||
|                         ImporteFinal = importeFinal, | ||||
|                         EstadoPago = "Pendiente", | ||||
|                         EstadoFacturacion = "Pendiente de Facturar" | ||||
|                         EstadoFacturacion = "Pendiente de Facturar", | ||||
|                         TipoFactura = "Mensual" | ||||
|                     }; | ||||
|  | ||||
|                     var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); | ||||
|                     if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}"); | ||||
|  | ||||
|                     facturasCreadas.Add(facturaCreada); | ||||
|                     foreach (var detalle in detallesParaFactura) | ||||
|                     { | ||||
| @@ -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 facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion); | ||||
|             var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo | ||||
|             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura); | ||||
|             var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); | ||||
|             var empresas = await _empresaRepository.GetAllAsync(null, null); | ||||
|  | ||||
|             var resumenes = facturasData | ||||
| @@ -301,10 +305,18 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|                             EstadoPago = itemFactura.Factura.EstadoPago, | ||||
|                             EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, | ||||
|                             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 | ||||
|                                 .Where(d => d.IdFactura == itemFactura.Factura.IdFactura) | ||||
|                                 .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(); | ||||
|  | ||||
| @@ -314,7 +326,7 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|                         NombreSuscriptor = primerItem.NombreSuscriptor, | ||||
|                         Facturas = facturasConsolidadas, | ||||
|                         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; | ||||
|             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| using System.Data; | ||||
| using GestionIntegral.Api.Dtos.Comunicaciones; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Models.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<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)> 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); | ||||
|                 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."); | ||||
|  | ||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago); | ||||
|                 if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida."); | ||||
|  | ||||
|                 // Obtenemos la suma de pagos ANTERIORES | ||||
|                 var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction); | ||||
|  | ||||
|                 var nuevoPago = new Pago | ||||
| @@ -96,37 +93,31 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|                 var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||
|                 if (pagoCreado == null) throw new DataException("No se pudo registrar el pago."); | ||||
|  | ||||
|                 // Calculamos el nuevo total EN MEMORIA | ||||
|                 var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto; | ||||
|  | ||||
|                 // Comparamos y actualizamos el estado si es necesario | ||||
|                 // CORRECCIÓN: Usar EstadoPago y el método correcto del repositorio | ||||
|                 if (factura.EstadoPago != "Pagada" && nuevoTotalPagado >= factura.ImporteFinal) | ||||
|                 // Nueva lógica para manejar todos los estados de pago | ||||
|                 string nuevoEstadoPago = factura.EstadoPago; | ||||
|                 if (nuevoTotalPagado >= factura.ImporteFinal) | ||||
|                 { | ||||
|                     bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, "Pagada", transaction); | ||||
|                     if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'."); | ||||
|                     nuevoEstadoPago = "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(); | ||||
|                 _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 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" | ||||
|                 }; | ||||
|  | ||||
|                 var dto = await MapToDto(pagoCreado); // MapToDto ahora es más simple | ||||
|                 return (dto, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|   | ||||
| @@ -3,12 +3,8 @@ using GestionIntegral.Api.Data.Repositories.Distribucion; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Data; | ||||
| using System.Linq; | ||||
| using System.Threading.Tasks; | ||||
| using System.Globalization; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
| @@ -18,23 +14,32 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; | ||||
|         private readonly IPublicacionRepository _publicacionRepository; | ||||
|         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 DbConnectionFactory _connectionFactory; | ||||
|  | ||||
|         public SuscripcionService( | ||||
|             ISuscripcionRepository suscripcionRepository, | ||||
|         ISuscriptorRepository suscriptorRepository, | ||||
|         IPublicacionRepository publicacionRepository, | ||||
|         IPromocionRepository promocionRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<SuscripcionService> logger) | ||||
|         IFacturaRepository facturaRepository, | ||||
|         IFacturaDetalleRepository facturaDetalleRepository, | ||||
|         IFacturacionService facturacionService, | ||||
|         ILogger<SuscripcionService> logger, | ||||
|         DbConnectionFactory connectionFactory) | ||||
|         { | ||||
|             _suscripcionRepository = suscripcionRepository; | ||||
|             _suscriptorRepository = suscriptorRepository; | ||||
|             _publicacionRepository = publicacionRepository; | ||||
|             _promocionRepository = promocionRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _facturaRepository = facturaRepository; | ||||
|             _facturaDetalleRepository = facturaDetalleRepository; | ||||
|             _facturacionService = facturacionService; | ||||
|             _logger = logger; | ||||
|             _connectionFactory = connectionFactory; | ||||
|         } | ||||
|  | ||||
|         private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto | ||||
| @@ -122,6 +127,53 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|                 var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction); | ||||
|                 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(); | ||||
|                 _logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario); | ||||
|                 return (await MapToDto(creada), null); | ||||
|   | ||||
| @@ -16,11 +16,11 @@ | ||||
|   }, | ||||
|   "AllowedHosts": "*", | ||||
|   "MailSettings": { | ||||
|     "SmtpHost": "", | ||||
|     "SmtpPort": 0, | ||||
|     "SenderName": "", | ||||
|     "SenderEmail": "", | ||||
|     "SmtpUser": "", | ||||
|     "SmtpPass": "" | ||||
|     "SmtpHost": "192.168.5.201", | ||||
|     "SmtpPort": 587, | ||||
|     "SenderName": "Club - Diario El Día", | ||||
|     "SenderEmail": "alertas@eldia.com", | ||||
|     "SmtpUser": "alertas@eldia.com", | ||||
|     "SmtpPass": "@Alertas713550@" | ||||
|   } | ||||
| } | ||||
| @@ -1,6 +1,6 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material'; | ||||
| import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto'; | ||||
| import type { FacturaConsolidadaDto } from '../../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; | ||||
| import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto'; | ||||
| import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto'; | ||||
| import formaPagoService from '../../../services/Suscripciones/formaPagoService'; | ||||
| @@ -23,17 +23,19 @@ interface PagoManualModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => 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; | ||||
|   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 [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingFormasPago, setLoadingFormasPago] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|   const saldoPendiente = factura ? factura.importeFinal - factura.totalPagado : 0; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchFormasDePago = async () => { | ||||
| @@ -52,12 +54,12 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | ||||
|       fetchFormasDePago(); | ||||
|       setFormData({ | ||||
|         idFactura: factura.idFactura, | ||||
|         monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto | ||||
|         monto: saldoPendiente, | ||||
|         fechaPago: new Date().toISOString().split('T')[0] | ||||
|       }); | ||||
|       setLocalErrors({}); | ||||
|     } | ||||
|   }, [open, factura]); | ||||
|   }, [open, factura, saldoPendiente]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     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."; | ||||
|  | ||||
|     const monto = formData.monto ?? 0; | ||||
|     const saldo = factura?.saldoPendiente ?? 0; | ||||
|  | ||||
|     if (monto <= 0) { | ||||
|       errors.monto = "El monto debe ser mayor a cero."; | ||||
|     } else if (monto > saldo) { | ||||
|         // Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje | ||||
|         errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`; | ||||
|     } else if (monto > saldoPendiente) { | ||||
|       errors.monto = `El monto no puede superar el saldo pendiente de $${saldoPendiente.toFixed(2)}.`; | ||||
|     } | ||||
|  | ||||
|     setLocalErrors(errors); | ||||
| @@ -117,8 +117,11 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6">Registrar Pago Manual</Typography> | ||||
|         <Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}> | ||||
|           Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)} | ||||
|         <Typography variant="body1" color="text.secondary" gutterBottom> | ||||
|           Para: {nombreSuscriptor} | ||||
|         </Typography> | ||||
|         <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}> | ||||
|           Saldo Pendiente: ${saldoPendiente.toFixed(2)} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||
|           <TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} /> | ||||
|   | ||||
| @@ -14,18 +14,18 @@ const SECCION_PERMISSIONS_PREFIX = "SS"; | ||||
| // Mapeo de codAcc de sección a su módulo conceptual | ||||
| const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { | ||||
|     if (codAcc === "SS001") return "Distribución";     | ||||
|     if (codAcc === "SS007") return "Suscripciones"; | ||||
|     if (codAcc === "SS002") return "Contables"; | ||||
|     if (codAcc === "SS003") return "Impresión"; | ||||
|     if (codAcc === "SS004") return "Reportes"; | ||||
|     if (codAcc === "SS005") return "Radios"; | ||||
|     if (codAcc === "SS006") return "Usuarios";     | ||||
|     if (codAcc === "SS005") return "Radios"; | ||||
|     return null; | ||||
| }; | ||||
|  | ||||
| // Función para determinar el módulo conceptual de un permiso individual | ||||
| const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | ||||
|     const moduloLower = permisoModulo.toLowerCase();     | ||||
|  | ||||
|     if (moduloLower.includes("distribuidores") || | ||||
|         moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas" | ||||
|         moduloLower.includes("publicaciones distribución") || | ||||
| @@ -36,6 +36,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | ||||
|         moduloLower.includes("ctrl. devoluciones")) { | ||||
|         return "Distribución"; | ||||
|     } | ||||
|     if (moduloLower.includes("suscripciones")) { | ||||
|         return "Suscripciones"; | ||||
|     } | ||||
|     if (moduloLower.includes("cuentas pagos") || | ||||
|         moduloLower.includes("cuentas notas") || | ||||
|         moduloLower.includes("cuentas tipos pagos")) { | ||||
| @@ -89,7 +92,7 @@ const PermisosChecklist: React.FC<PermisosChecklistProps> = ({ | ||||
|     return acc; | ||||
|   }, {} 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) | ||||
|   permisosDeSeccion.forEach(ps => { | ||||
|       const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc); | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|     FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox | ||||
| } from '@mui/material'; | ||||
| import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto'; | ||||
|   | ||||
| @@ -1,22 +1,22 @@ | ||||
| // src/hooks/usePermissions.ts | ||||
| import { useAuth } from '../contexts/AuthContext'; | ||||
| import { useCallback } from 'react'; | ||||
|  | ||||
| export const usePermissions = () => { | ||||
|   const { user } = useAuth(); // user aquí es de tipo UserContextData | null | ||||
|   const { user } = useAuth(); | ||||
|  | ||||
|   const tienePermiso = (codigoPermisoRequerido: string): boolean => { | ||||
|     if (!user) { // Si no hay usuario logueado | ||||
|   // Envolvemos la función en useCallback. | ||||
|   // 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; | ||||
|     } | ||||
|     if (user.esSuperAdmin) { // SuperAdmin tiene todos los permisos | ||||
|     if (user.esSuperAdmin) { | ||||
|       return true; | ||||
|     } | ||||
|     // Verificar si la lista de permisos del usuario incluye el código requerido | ||||
|     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 { | ||||
|     tienePermiso, | ||||
|     isSuperAdmin: user?.esSuperAdmin ?? false, | ||||
|   | ||||
| @@ -12,6 +12,8 @@ export interface FacturaConsolidadaDto { | ||||
|     estadoPago: string; | ||||
|     estadoFacturacion: string; | ||||
|     numeroFactura?: string | null; | ||||
|     totalPagado: number; | ||||
|     tipoFactura: 'Mensual' | 'Alta'; | ||||
|     detalles: FacturaDetalleDto[]; | ||||
|     // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers | ||||
|     idSuscriptor: number;  | ||||
|   | ||||
| @@ -3,92 +3,80 @@ import { Box, Paper, Typography, List, ListItemButton, ListItemText, Collapse, C | ||||
| import { Outlet, useNavigate, useLocation } from 'react-router-dom'; | ||||
| import ExpandLess from '@mui/icons-material/ExpandLess'; | ||||
| 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 }[] = [ | ||||
|   { category: 'Existencia Papel', label: 'Existencia de Papel', path: 'existencia-papel' }, | ||||
|   { category: 'Movimientos Bobinas', label: 'Movimiento de Bobinas', path: 'movimiento-bobinas' }, | ||||
|   { category: 'Movimientos Bobinas', label: 'Mov. Bobinas por Estado', path: 'movimiento-bobinas-estado' }, | ||||
|   { category: 'Listados Distribución', label: 'Distribución Distribuidores', path: 'listado-distribucion-distribuidores' }, | ||||
|   { category: 'Listados Distribución', label: 'Distribución Canillas', path: 'listado-distribucion-canillas' }, | ||||
|   { 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' }, | ||||
|   { 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' }, | ||||
|   { 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' }, | ||||
|   { category: 'Consumos Bobinas', label: 'Consumo Bobinas/PubPublicación', path: 'consumo-bobinas-publicacion' }, | ||||
|   { category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas' }, | ||||
|   { category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores' }, | ||||
|   { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' }, | ||||
|   { category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' }, | ||||
|   { category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' }, | ||||
|   { category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' }, | ||||
|   { category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion' }, | ||||
| const allReportModules: { category: string; label: string; path: string; requiredPermission: string; }[] = [ | ||||
|   { category: 'Existencia Papel', label: 'Existencia de Papel', path: 'existencia-papel', requiredPermission: 'RR005' }, | ||||
|   { category: 'Movimientos Bobinas', label: 'Movimiento de Bobinas', path: 'movimiento-bobinas', requiredPermission: 'RR006' }, | ||||
|   { category: 'Movimientos Bobinas', label: 'Mov. Bobinas por Estado', path: 'movimiento-bobinas-estado', requiredPermission: 'RR006' }, | ||||
|   { category: 'Listados Distribución', label: 'Distribución Distribuidores', path: 'listado-distribucion-distribuidores', requiredPermission: 'RR002' }, | ||||
|   { category: 'Listados Distribución', label: 'Distribución Canillas', path: 'listado-distribucion-canillas', requiredPermission: 'RR002' }, | ||||
|   { category: 'Listados Distribución', label: 'Distribución General', path: 'listado-distribucion-general', requiredPermission: 'RR002' }, | ||||
|   { category: 'Listados Distribución', label: 'Distrib. Canillas (Importe)', path: 'listado-distribucion-canillas-importe', requiredPermission: 'RR002' }, | ||||
|   { category: 'Listados Distribución', label: 'Detalle Distribución Canillas', path: 'detalle-distribucion-canillas', requiredPermission: 'MC005' }, | ||||
|   { category: 'Secretaría', label: 'Venta Mensual Secretaría', path: 'venta-mensual-secretaria', requiredPermission: 'RR002' }, | ||||
|   { category: 'Tiradas por Publicación', label: 'Tiradas Publicación/Sección', path: 'tiradas-publicaciones-secciones', requiredPermission: 'RR008' }, | ||||
|   { category: 'Consumos Bobinas', label: 'Consumo Bobinas/Sección', path: 'consumo-bobinas-seccion', requiredPermission: 'RR007' }, | ||||
|   { category: 'Consumos Bobinas', label: 'Consumo Bobinas/Publicación', path: 'consumo-bobinas-publicacion', requiredPermission: 'RR007' }, | ||||
|   { category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas', requiredPermission: 'RR007' }, | ||||
|   { category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores', requiredPermission: 'RR001' }, | ||||
|   { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones', requiredPermission: 'RR003' }, | ||||
|   { category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas', requiredPermission: 'RR004' }, | ||||
|   { category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual', requiredPermission: 'RR009' }, | ||||
|   { category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad', requiredPermission: 'RR010' }, | ||||
|   { category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion', requiredPermission: 'RR011' }, | ||||
| ]; | ||||
|  | ||||
| const predefinedCategoryOrder = [ | ||||
|   'Balance de Cuentas', | ||||
|   'Listados Distribución', | ||||
|   'Ctrl. Devoluciones', | ||||
|   'Novedades de Canillitas', | ||||
|   'Suscripciones', | ||||
|   'Existencia Papel', | ||||
|   'Movimientos Bobinas', | ||||
|   'Consumos Bobinas', | ||||
|   'Tiradas por Publicación', | ||||
|   'Secretaría', | ||||
|   'Balance de Cuentas', 'Listados Distribución', 'Ctrl. Devoluciones', | ||||
|   'Novedades de Canillitas', 'Suscripciones', 'Existencia Papel', | ||||
|   'Movimientos Bobinas', 'Consumos Bobinas', 'Tiradas por Publicación', 'Secretaría', | ||||
| ]; | ||||
|  | ||||
|  | ||||
| const ReportesIndexPage: React.FC = () => { | ||||
|   const navigate = useNavigate(); | ||||
|   const location = useLocation(); | ||||
|  | ||||
|   const [expandedCategory, setExpandedCategory] = useState<string | false>(false); | ||||
|   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(() => { | ||||
|     const currentBasePath = '/reportes'; | ||||
|     const pathParts = location.pathname.substring(currentBasePath.length + 1).split('/'); | ||||
|     const subPathSegment = pathParts[0]; | ||||
|  | ||||
|     let activeReportFoundInEffect = false; | ||||
|  | ||||
|     if (subPathSegment && subPathSegment !== "") { // Asegurarse que subPathSegment no esté vacío | ||||
|       const activeReport = allReportModules.find(module => module.path === subPathSegment); | ||||
|     if (subPathSegment) { | ||||
|       const activeReport = accessibleReportModules.find(module => module.path === subPathSegment); | ||||
|       if (activeReport) { | ||||
|         setExpandedCategory(activeReport.category); | ||||
|         activeReportFoundInEffect = true; | ||||
|       } else { | ||||
|         // Si la URL apunta a un reporte que no es accesible, no expandimos nada | ||||
|         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 { | ||||
|         setExpandedCategory(false); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     if (location.pathname === currentBasePath && allReportModules.length > 0 && isLoadingInitialNavigation) { | ||||
|         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) { | ||||
|     // No hay navegación automática, solo manejamos el estado de carga. | ||||
|     setIsLoadingInitialNavigation(false); | ||||
|     } | ||||
|  | ||||
|   }, [location.pathname, navigate, uniqueCategories, isLoadingInitialNavigation]); | ||||
|   }, [location.pathname, accessibleReportModules, accessibleCategories]); | ||||
|  | ||||
|   const handleCategoryClick = (categoryName: string) => { | ||||
|     setExpandedCategory(prev => (prev === categoryName ? false : categoryName)); | ||||
| @@ -99,12 +87,10 @@ const ReportesIndexPage: React.FC = () => { | ||||
|   }; | ||||
|  | ||||
|   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 | ||||
|   // Esto evita mostrar el loader si se navega directamente a un sub-reporte. | ||||
|   if (isLoadingInitialNavigation && (location.pathname === '/reportes' || location.pathname === '/reportes/')) { | ||||
|   if (isLoadingInitialNavigation) { | ||||
|     return ( | ||||
|         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}> | ||||
|             <CircularProgress /> | ||||
| @@ -113,44 +99,25 @@ const ReportesIndexPage: React.FC = () => { | ||||
|   } | ||||
|  | ||||
|   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%' }}> | ||||
|       {/* Panel Lateral para Navegación */} | ||||
|       <Paper | ||||
|         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 | ||||
|       <Paper elevation={0} square sx={{ | ||||
|           width: { xs: 220, sm: 250, md: 280 }, | ||||
|           minWidth: { xs: 200, sm: 220 }, | ||||
|           height: '100%', // Ocupa toda la altura del Box padre | ||||
|           height: '100%', | ||||
|           borderRight: (theme) => `1px solid ${theme.palette.divider}`, | ||||
|           overflowY: 'auto', | ||||
|           bgcolor: 'background.paper', // O el color que desees para el menú | ||||
|           // display: 'flex', flexDirection: 'column' // Para que el título y la lista usen el espacio vertical | ||||
|         }} | ||||
|       > | ||||
|         {/* 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 */ }}> | ||||
|           bgcolor: 'background.paper', | ||||
|         }}> | ||||
|         <Box sx={{ p: 1.5 }}> | ||||
|             <Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 }}> | ||||
|             Reportes | ||||
|             </Typography> | ||||
|         </Box> | ||||
|          | ||||
|         {/* Lista de Categorías y Reportes */} | ||||
|         {uniqueCategories.length > 0 ? ( | ||||
|         <List component="nav" dense sx={{ pt: 0 }} /* Quitar padding superior de la lista si el título ya lo tiene */ > | ||||
|           {uniqueCategories.map((category) => { | ||||
|             const reportsInCategory = allReportModules.filter(r => r.category === category); | ||||
|         {accessibleCategories.length > 0 ? ( | ||||
|         <List component="nav" dense sx={{ pt: 0 }}> | ||||
|           {accessibleCategories.map((category) => { | ||||
|             const reportsInCategory = accessibleReportModules.filter(r => r.category === category); | ||||
|             const isExpanded = expandedCategory === category; | ||||
|  | ||||
|             return ( | ||||
| @@ -158,25 +125,13 @@ const ReportesIndexPage: React.FC = () => { | ||||
|                 <ListItemButton | ||||
|                   onClick={() => handleCategoryClick(category)} | ||||
|                   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', | ||||
|                     pr: 1, // Menos padding a la derecha para dar espacio al ícono expander | ||||
|                     '&:hover': { | ||||
|                         backgroundColor: 'action.hover' | ||||
|                     } | ||||
|                   }} | ||||
|                 > | ||||
|                   <ListItemText | ||||
|                     primary={category} | ||||
|                     primaryTypographyProps={{  | ||||
|                         fontWeight: isExpanded ? 'bold' : 'normal', | ||||
|                         // color: isExpanded ? 'primary.main' : 'text.primary'  | ||||
|                     }} | ||||
|                   /> | ||||
|                   {reportsInCategory.length > 0 && (isExpanded ? <ExpandLess /> : <ExpandMore />)} | ||||
|                     pr: 1, | ||||
|                     '&:hover': { backgroundColor: 'action.hover' } | ||||
|                   }}> | ||||
|                   <ListItemText primary={category} primaryTypographyProps={{ fontWeight: isExpanded ? 'bold' : 'normal' }}/> | ||||
|                   {isExpanded ? <ExpandLess /> : <ExpandMore />} | ||||
|                 </ListItemButton> | ||||
|                 {reportsInCategory.length > 0 && ( | ||||
|                 <Collapse in={isExpanded} timeout="auto" unmountOnExit> | ||||
|                     <List component="div" disablePadding dense> | ||||
|                       {reportsInCategory.map((report) => ( | ||||
| @@ -185,62 +140,39 @@ const ReportesIndexPage: React.FC = () => { | ||||
|                           selected={isReportActive(report.path)} | ||||
|                           onClick={() => handleReportClick(report.path)} | ||||
|                           sx={{  | ||||
|                             pl: 3.5, // Indentación para los reportes (ajustar si se cambió el padding del título) | ||||
|                             py: 0.8, // Padding vertical de items de reporte | ||||
|                             pl: 3.5, py: 0.8, | ||||
|                             ...(isReportActive(report.path) && { | ||||
|                                 backgroundColor: (theme) => theme.palette.action.selected, // Un color de fondo sutil | ||||
|                                 borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`, // Un borde para el activo | ||||
|                                 '& .MuiListItemText-primary': { | ||||
|                                     fontWeight: 'medium', // O 'bold' | ||||
|                                     // color: 'primary.main' | ||||
|                                 }, | ||||
|                                 backgroundColor: (theme) => theme.palette.action.selected, | ||||
|                                 borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`, | ||||
|                                 '& .MuiListItemText-primary': { fontWeight: 'medium' }, | ||||
|                             }), | ||||
|                             '&:hover': { | ||||
|                                 backgroundColor: (theme) => theme.palette.action.hover | ||||
|                             } | ||||
|                           }} | ||||
|                         > | ||||
|                             '&:hover': { backgroundColor: (theme) => theme.palette.action.hover } | ||||
|                           }}> | ||||
|                           <ListItemText primary={report.label} primaryTypographyProps={{ variant: 'body2' }}/> | ||||
|                         </ListItemButton> | ||||
|                       ))} | ||||
|                     </List> | ||||
|                 </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> | ||||
|             ); | ||||
|           })} | ||||
|         </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> | ||||
|  | ||||
|       {/* Área Principal para el Contenido del Reporte */} | ||||
|       <Box | ||||
|         component="main" | ||||
|         sx={{ | ||||
|           flexGrow: 1, // Ocupa el espacio restante | ||||
|           p: { xs: 1, sm: 2, md: 3 }, // Padding interno para el contenido, responsivo | ||||
|           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 && ( | ||||
|       <Box component="main" sx={{ | ||||
|           flexGrow: 1, p: { xs: 1, sm: 2, md: 3 }, | ||||
|           overflowY: 'auto', height: '100%', bgcolor: 'grey.100' | ||||
|         }}> | ||||
|         {/* Lógica para mostrar el mensaje de bienvenida */} | ||||
|         {location.pathname === '/reportes' && !isLoadingInitialNavigation && ( | ||||
|              <Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}> | ||||
|                 El reporte solicitado no existe o la ruta no es válida. | ||||
|             </Typography> | ||||
|         )} | ||||
|         {(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."} | ||||
|                 {accessibleReportModules.length > 0  | ||||
|                     ? "Seleccione una categoría y un reporte del menú lateral."  | ||||
|                     : "No tiene acceso a ningún reporte." | ||||
|                 } | ||||
|             </Typography> | ||||
|         )} | ||||
|         <Outlet /> | ||||
|   | ||||
| @@ -24,8 +24,9 @@ const meses = [ | ||||
|     { 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 tiposFactura = ['Mensual', 'Alta']; | ||||
|  | ||||
| const SuscriptorRow: React.FC<{ | ||||
|     resumen: ResumenCuentaSuscriptorDto; | ||||
| @@ -33,50 +34,60 @@ const SuscriptorRow: React.FC<{ | ||||
|     handleOpenHistorial: (factura: FacturaConsolidadaDto) => void; | ||||
| }> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => { | ||||
|     const [open, setOpen] = useState(false); | ||||
|     const formatCurrency = (value: number) => `$${value.toFixed(2)}`; | ||||
|  | ||||
|     return ( | ||||
|         <React.Fragment> | ||||
|             <TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover> | ||||
|                 <TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell> | ||||
|                 <TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell> | ||||
|                 <TableCell align="right"> | ||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>${resumen.saldoPendienteTotal.toFixed(2)}</Typography> | ||||
|                     <Typography variant="caption" color="text.secondary">de ${resumen.importeTotal.toFixed(2)}</Typography> | ||||
|                     <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 {formatCurrency(resumen.importeTotal)}</Typography> | ||||
|                 </TableCell> | ||||
|                 <TableCell colSpan={5}></TableCell> | ||||
|                 <TableCell colSpan={6}></TableCell> | ||||
|             </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> | ||||
|                         <Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> | ||||
|                             <Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography> | ||||
|                             <Table size="small"> | ||||
|                                 <TableHead> | ||||
|                                     <TableRow> | ||||
|                                         <TableCell>Empresa</TableCell><TableCell align="right">Importe</TableCell> | ||||
|                                         <TableCell>Estado Pago</TableCell><TableCell>Estado Facturación</TableCell> | ||||
|                                         <TableCell>Nro. Factura</TableCell><TableCell align="right">Acciones</TableCell> | ||||
|                                         <TableCell>Empresa</TableCell> | ||||
|                                         <TableCell align="right">Importe Total</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> | ||||
|                                 </TableHead> | ||||
|                                 <TableBody> | ||||
|                                     {resumen.facturas.map((factura) => ( | ||||
|                                     {resumen.facturas.map((factura) => { | ||||
|                                         const saldo = factura.importeFinal - factura.totalPagado; | ||||
|                                         return ( | ||||
|                                             <TableRow key={factura.idFactura}> | ||||
|                                                 <TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell> | ||||
|                                             <TableCell align="right">${factura.importeFinal.toFixed(2)}</TableCell> | ||||
|                                             <TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default')} /></TableCell> | ||||
|                                                 <TableCell align="right">{formatCurrency(factura.importeFinal)}</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>{factura.numeroFactura || '-'}</TableCell> | ||||
|                                                 <TableCell align="right"> | ||||
|                                                 <IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> | ||||
|                                                     <MoreVertIcon /> | ||||
|                                                 </IconButton> | ||||
|                                                 <Tooltip title="Ver Historial de Envíos"> | ||||
|                                                     <IconButton onClick={() => handleOpenHistorial(factura)}> | ||||
|                                                         <MailOutlineIcon /> | ||||
|                                                     </IconButton> | ||||
|                                                 </Tooltip> | ||||
|                                                     <IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}><MoreVertIcon /></IconButton> | ||||
|                                                     <Tooltip title="Ver Historial de Envíos"><IconButton onClick={() => handleOpenHistorial(factura)}><MailOutlineIcon /></IconButton></Tooltip> | ||||
|                                                 </TableCell> | ||||
|                                             </TableRow> | ||||
|                                     ))} | ||||
|                                         ); | ||||
|                                     })} | ||||
|                                 </TableBody> | ||||
|                             </Table> | ||||
|                         </Box> | ||||
| @@ -102,6 +113,7 @@ const ConsultaFacturasPage: React.FC = () => { | ||||
|     const [filtroNombre, setFiltroNombre] = useState(''); | ||||
|     const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); | ||||
|     const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); | ||||
|     const [filtroTipoFactura, setFiltroTipoFactura] = useState(''); | ||||
|  | ||||
|     const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null); | ||||
|     const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||
| @@ -121,7 +133,8 @@ const ConsultaFacturasPage: React.FC = () => { | ||||
|                 selectedMes, | ||||
|                 filtroNombre || undefined, | ||||
|                 filtroEstadoPago || undefined, | ||||
|                 filtroEstadoFacturacion || undefined | ||||
|                 filtroEstadoFacturacion || undefined, | ||||
|                 filtroTipoFactura || undefined | ||||
|             ); | ||||
|             setResumenes(data); | ||||
|         } catch (err) { | ||||
| @@ -130,7 +143,7 @@ const ConsultaFacturasPage: React.FC = () => { | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]); | ||||
|     }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion, filtroTipoFactura]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         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' }} /> | ||||
|                     <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>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> | ||||
|             </Paper> | ||||
|  | ||||
| @@ -226,7 +250,7 @@ const ConsultaFacturasPage: React.FC = () => { | ||||
|  | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table aria-label="collapsible table"> | ||||
|                     <TableHead><TableRow><TableCell /><TableCell>Suscriptor</TableCell><TableCell align="right">Saldo Total / Importe Total</TableCell><TableCell colSpan={5}></TableCell></TableRow></TableHead> | ||||
|                     <TableHead><TableRow><TableCell /><TableCell>Suscriptor</TableCell><TableCell align="right">Saldo Total / Importe Total</TableCell><TableCell colSpan={6}></TableCell></TableRow></TableHead> | ||||
|                     <TableBody> | ||||
|                         {loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>) | ||||
|                             : resumenes.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>) | ||||
| @@ -252,22 +276,9 @@ const ConsultaFacturasPage: React.FC = () => { | ||||
|                 open={pagoModalOpen} | ||||
|                 onClose={handleClosePagoModal} | ||||
|                 onSubmit={handleSubmitPagoModal} | ||||
|                 factura={ | ||||
|                     selectedFactura ? { | ||||
|                         idFactura: selectedFactura.idFactura, | ||||
|                         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 | ||||
|                 factura={selectedFactura} | ||||
|                 nombreSuscriptor={ | ||||
|                     resumenes.find(r => r.idSuscriptor === selectedFactura?.idSuscriptor)?.nombreSuscriptor || '' | ||||
|                 } | ||||
|                 errorMessage={apiError} | ||||
|                 clearErrorMessage={() => setApiError(null)} | ||||
|   | ||||
| @@ -16,11 +16,12 @@ const SECCION_PERMISSIONS_PREFIX = "SS"; | ||||
|  | ||||
| const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { | ||||
|   if (codAcc === "SS001") return "Distribución";   | ||||
|   if (codAcc === "SS007") return "Suscripciones";  | ||||
|   if (codAcc === "SS002") return "Contables"; | ||||
|   if (codAcc === "SS003") return "Impresión"; | ||||
|   if (codAcc === "SS004") return "Reportes"; | ||||
|   if (codAcc === "SS005") return "Radios"; | ||||
|   if (codAcc === "SS006") return "Usuarios";  | ||||
|   if (codAcc === "SS005") return "Radios"; | ||||
|   return null; | ||||
| }; | ||||
|  | ||||
| @@ -38,6 +39,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | ||||
|     moduloLower.includes("salidas otros destinos")) { | ||||
|     return "Distribución"; | ||||
|   }   | ||||
|   if (moduloLower.includes("suscripciones")) { | ||||
|     return "Suscripciones"; | ||||
|   } | ||||
|   if (moduloLower.includes("cuentas pagos") || | ||||
|     moduloLower.includes("cuentas notas") || | ||||
|     moduloLower.includes("cuentas tipos pagos")) { | ||||
| @@ -50,9 +54,6 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | ||||
|     moduloLower.includes("tipos bobinas")) { | ||||
|     return "Impresión"; | ||||
|   } | ||||
|   if (moduloLower.includes("radios")) { | ||||
|     return "Radios"; | ||||
|   } | ||||
|   if (moduloLower.includes("usuarios") || | ||||
|     moduloLower.includes("perfiles")) { | ||||
|     return "Usuarios"; | ||||
| @@ -63,6 +64,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { | ||||
|   if (moduloLower.includes("permisos")) { | ||||
|     return "Permisos (Definición)"; | ||||
|   }   | ||||
|   if (moduloLower.includes("radios")) { | ||||
|     return "Radios"; | ||||
|   } | ||||
|   return permisoModulo; | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,9 @@ | ||||
| // src/routes/AppRoutes.tsx | ||||
| import React, { type JSX } from 'react'; | ||||
| import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; | ||||
| import LoginPage from '../pages/LoginPage'; | ||||
| import HomePage from '../pages/HomePage'; | ||||
| import { useAuth } from '../contexts/AuthContext'; | ||||
| import MainLayout from '../layouts/MainLayout'; | ||||
| import { Typography } from '@mui/material'; | ||||
| import SectionProtectedRoute from './SectionProtectedRoute'; | ||||
|  | ||||
| // Distribución | ||||
| @@ -267,7 +265,6 @@ const AppRoutes = () => { | ||||
|                 <ReportesIndexPage /> | ||||
|               </SectionProtectedRoute>} | ||||
|           > | ||||
|             <Route index element={<Typography sx={{ p: 2 }}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */} | ||||
|             <Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} /> | ||||
|             <Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} /> | ||||
|             <Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} /> | ||||
|   | ||||
| @@ -19,11 +19,16 @@ const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLot | ||||
|     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(); | ||||
|     if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor); | ||||
|     if (estadoPago) params.append('estadoPago', estadoPago); | ||||
|     if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion); | ||||
|     if (tipoFactura) params.append('tipoFactura', tipoFactura); | ||||
|  | ||||
|     const queryString = params.toString(); | ||||
|     const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`; | ||||
|   | ||||
| @@ -33,19 +33,18 @@ apiClient.interceptors.response.use( | ||||
|   (error) => { | ||||
|     // 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 (error.response.status === 401) { | ||||
|         // Token inválido o expirado | ||||
|         console.warn("Error 401: Token inválido o expirado. Deslogueando..."); | ||||
|       // Verificamos si la petición fallida NO fue al endpoint de login. | ||||
|       const isLoginAttempt = error.config?.url?.endsWith('/auth/login'); | ||||
|  | ||||
|       // 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('authUser'); // Asegurar limpiar también el usuario | ||||
|         // Forzar un hard refresh para que AuthContext se reinicialice y redirija | ||||
|         // Esto también limpiará cualquier estado de React. | ||||
|         // --- Mostrar mensaje antes de redirigir --- | ||||
|         localStorage.removeItem('authUser'); | ||||
|          | ||||
|         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 | ||||
|   | ||||
							
								
								
									
										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). | ||||
| - **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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user