diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs index 0ae176c..6e6a174 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs @@ -244,5 +244,16 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones return Enumerable.Empty(); } } + + public async Task> GetByIdsAsync(IEnumerable ids) + { + if (ids == null || !ids.Any()) + { + return Enumerable.Empty(); + } + const string sql = "SELECT * FROM dbo.susc_Facturas WHERE IdFactura IN @Ids;"; + using var connection = _connectionFactory.CreateConnection(); + return await connection.QueryAsync(sql, new { Ids = ids }); + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs index 3c8c35c..53328c3 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs @@ -6,6 +6,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones public interface IFacturaRepository { Task GetByIdAsync(int idFactura); + Task> GetByIdsAsync(IEnumerable ids); Task> GetByPeriodoAsync(string periodo); Task GetBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo, IDbTransaction transaction); Task> GetListBySuscriptorYPeriodoAsync(int idSuscriptor, string periodo); diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs index a44c8d9..5e13542 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs @@ -12,6 +12,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones public string Motivo { get; set; } = string.Empty; public string Estado { get; set; } = string.Empty; public int? IdFacturaAplicado { get; set; } + public string? NumeroFacturaAplicado { get; set; } public string FechaAlta { get; set; } = string.Empty; public string NombreUsuarioAlta { get; set; } = string.Empty; } diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs index f4f4edd..4f2bd37 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs @@ -14,6 +14,7 @@ namespace GestionIntegral.Api.Services.Suscripciones private readonly ISuscriptorRepository _suscriptorRepository; private readonly IUsuarioRepository _usuarioRepository; private readonly IEmpresaRepository _empresaRepository; + private readonly IFacturaRepository _facturaRepository; private readonly DbConnectionFactory _connectionFactory; private readonly ILogger _logger; @@ -22,6 +23,7 @@ namespace GestionIntegral.Api.Services.Suscripciones ISuscriptorRepository suscriptorRepository, IUsuarioRepository usuarioRepository, IEmpresaRepository empresaRepository, + IFacturaRepository facturaRepository, DbConnectionFactory connectionFactory, ILogger logger) { @@ -29,6 +31,7 @@ namespace GestionIntegral.Api.Services.Suscripciones _suscriptorRepository = suscriptorRepository; _usuarioRepository = usuarioRepository; _empresaRepository = empresaRepository; + _facturaRepository = facturaRepository; _connectionFactory = connectionFactory; _logger = logger; } @@ -63,27 +66,34 @@ namespace GestionIntegral.Api.Services.Suscripciones return Enumerable.Empty(); } - // 1. Recolectar IDs únicos de usuarios Y empresas de la lista de ajustes + // 1. Recolectar IDs de usuarios, empresas Y FACTURAS var idsUsuarios = ajustes.Select(a => a.IdUsuarioAlta).Distinct().ToList(); var idsEmpresas = ajustes.Select(a => a.IdEmpresa).Distinct().ToList(); + var idsFacturas = ajustes.Where(a => a.IdFacturaAplicado.HasValue) + .Select(a => a.IdFacturaAplicado!.Value) + .Distinct().ToList(); - // 2. Obtener todos los usuarios y empresas necesarios en dos consultas masivas. + // 2. Obtener todos los datos necesarios en consultas masivas var usuariosTask = _usuarioRepository.GetByIdsAsync(idsUsuarios); - var empresasTask = _empresaRepository.GetAllAsync(null, null); // Asumiendo que GetAllAsync es suficiente o crear un GetByIds. + var empresasTask = _empresaRepository.GetAllAsync(null, null); + var facturasTask = _facturaRepository.GetByIdsAsync(idsFacturas); - // Esperamos a que ambas consultas terminen - await Task.WhenAll(usuariosTask, empresasTask); + await Task.WhenAll(usuariosTask, empresasTask, facturasTask); - // Convertimos los resultados a diccionarios para búsqueda rápida + // 3. Convertir a diccionarios para búsqueda rápida var usuariosDict = (await usuariosTask).ToDictionary(u => u.Id); var empresasDict = (await empresasTask).ToDictionary(e => e.IdEmpresa); + var facturasDict = (await facturasTask).ToDictionary(f => f.IdFactura); - // 3. Mapear en memoria, ahora con toda la información disponible. + // 4. Mapear en memoria, ahora con la información de la factura disponible var dtos = ajustes.Select(ajuste => { usuariosDict.TryGetValue(ajuste.IdUsuarioAlta, out var usuario); empresasDict.TryGetValue(ajuste.IdEmpresa, out var empresa); + // Buscar la factura en el diccionario si el ajuste está aplicado + facturasDict.TryGetValue(ajuste.IdFacturaAplicado ?? 0, out var factura); + return new AjusteDto { IdAjuste = ajuste.IdAjuste, @@ -96,6 +106,7 @@ namespace GestionIntegral.Api.Services.Suscripciones Motivo = ajuste.Motivo, Estado = ajuste.Estado, IdFacturaAplicado = ajuste.IdFacturaAplicado, + NumeroFacturaAplicado = factura?.NumeroFactura, FechaAlta = ajuste.FechaAlta.ToString("yyyy-MM-dd HH:mm"), NombreUsuarioAlta = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A" }; @@ -116,7 +127,7 @@ namespace GestionIntegral.Api.Services.Suscripciones { return (null, "La empresa especificada no existe."); } - + var nuevoAjuste = new Ajuste { IdSuscriptor = createDto.IdSuscriptor, @@ -186,7 +197,7 @@ namespace GestionIntegral.Api.Services.Suscripciones var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste); if (ajuste == null) return (false, "Ajuste no encontrado."); if (ajuste.Estado != "Pendiente") return (false, $"No se puede modificar un ajuste en estado '{ajuste.Estado}'."); - + var empresa = await _empresaRepository.GetByIdAsync(updateDto.IdEmpresa); if (empresa == null) return (false, "La empresa especificada no existe."); diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs index f76b8de..66de84b 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs @@ -310,85 +310,80 @@ namespace GestionIntegral.Api.Services.Suscripciones } public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor) + { + var periodo = $"{anio}-{mes:D2}"; + try { - var periodo = $"{anio}-{mes:D2}"; - try + var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo); + if (!facturasConEmpresa.Any()) return (false, "No se encontraron facturas para este suscriptor en el período."); + + var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); + if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email."); + + var resumenHtml = new StringBuilder(); + var adjuntos = new List<(byte[] content, string name)>(); + + foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada")) { - // 1. Reemplazamos la llamada original por la nueva, que ya trae toda la información necesaria. - var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo); - if (!facturasConEmpresa.Any()) return (false, "No se encontraron facturas para este suscriptor en el período."); + var factura = item.Factura; + var nombreEmpresa = item.NombreEmpresa; - var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); - if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email."); + var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); - var resumenHtml = new StringBuilder(); - var adjuntos = new List<(byte[] content, string name)>(); - - // 2. Iteramos sobre la nueva lista de tuplas. - foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada")) + resumenHtml.Append($"

Resumen para {nombreEmpresa}

"); + resumenHtml.Append(""); + + foreach (var detalle in detalles) { - var factura = item.Factura; - var nombreEmpresa = item.NombreEmpresa; - - // 3. Eliminamos la lógica compleja y propensa a errores para obtener la empresa. - // La llamada a GetDetallesPorFacturaIdAsync sigue siendo necesaria para el cuerpo del email. - var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); - - // Título mejorado para claridad - resumenHtml.Append($"

Resumen para {nombreEmpresa}

"); - resumenHtml.Append("
"); - - // 1. Mostrar Detalles de Suscripciones - foreach (var detalle in detalles) + resumenHtml.Append($""); + } + + var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura); + if (ajustes.Any()) + { + foreach (var ajuste in ajustes) { - resumenHtml.Append($""); - } - var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura); - if (ajustes.Any()) - { - foreach (var ajuste in ajustes) - { - bool esCredito = ajuste.TipoAjuste == "Credito"; - string colorMonto = esCredito ? "#d9534f" : "#5cb85c"; - string signo = esCredito ? "-" : "+"; - resumenHtml.Append($""); - } - } - resumenHtml.Append($""); - resumenHtml.Append("
{detalle.Descripcion}${detalle.ImporteNeto:N2}
{detalle.Descripcion}${detalle.ImporteNeto:N2}
Ajuste: {ajuste.Motivo}{signo} ${ajuste.Monto:N2}
Subtotal${factura.ImporteFinal:N2}
"); - - if (!string.IsNullOrEmpty(factura.NumeroFactura)) - { - var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf"); - if (File.Exists(rutaCompleta)) - { - byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta); - // Usamos el nombre de la empresa para un nombre de archivo más claro - string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf"; - adjuntos.Add((pdfBytes, pdfFileName)); - _logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName); - } - else - { - _logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta); - } + bool esCredito = ajuste.TipoAjuste == "Credito"; + string colorMonto = esCredito ? "#5cb85c" : "#d9534f"; + string signo = esCredito ? "-" : "+"; + resumenHtml.Append($"Ajuste: {ajuste.Motivo}{signo} ${ajuste.Monto:N2}"); } } + + resumenHtml.Append($"Subtotal${factura.ImporteFinal:N2}"); + resumenHtml.Append(""); - var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal); - string asunto = $"Resumen de Cuenta - Diario El Día - Período {periodo}"; - string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral); + if (!string.IsNullOrEmpty(factura.NumeroFactura)) + { + var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf"); + if (File.Exists(rutaCompleta)) + { + byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta); + string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf"; + adjuntos.Add((pdfBytes, pdfFileName)); + _logger.LogInformation("PDF adjuntado: {FileName}", pdfFileName); + } + else + { + _logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta); + } + } + } - await _emailService.EnviarEmailConsolidadoAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, adjuntos); - _logger.LogInformation("Email consolidado para Suscriptor ID {IdSuscriptor} enviado para el período {Periodo}.", idSuscriptor, periodo); - return (true, null); - } - catch (Exception ex) - { - _logger.LogError(ex, "Falló el envío de email consolidado para el suscriptor ID {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo); - return (false, "Ocurrió un error al intentar enviar el email consolidado."); - } + var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal); + string asunto = $"Resumen de Cuenta - Diario El Día - Período {periodo}"; + string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral); + + await _emailService.EnviarEmailConsolidadoAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, adjuntos); + _logger.LogInformation("Email consolidado para Suscriptor ID {IdSuscriptor} enviado para el período {Periodo}.", idSuscriptor, periodo); + return (true, null); } + catch (Exception ex) + { + _logger.LogError(ex, "Falló el envío de email consolidado para el suscriptor ID {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo); + return (false, "Ocurrió un error al intentar enviar el email consolidado."); + } + } private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral) { diff --git a/Frontend/src/models/dtos/Suscripciones/AjusteDto.ts b/Frontend/src/models/dtos/Suscripciones/AjusteDto.ts index d0e116a..d6246e7 100644 --- a/Frontend/src/models/dtos/Suscripciones/AjusteDto.ts +++ b/Frontend/src/models/dtos/Suscripciones/AjusteDto.ts @@ -9,6 +9,7 @@ export interface AjusteDto { motivo: string; estado: 'Pendiente' | 'Aplicado' | 'Anulado'; idFacturaAplicado?: number | null; + numeroFacturaAplicado?: string | null; fechaAlta: string; // "yyyy-MM-dd HH:mm" nombreUsuarioAlta: string; } \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorPage.tsx b/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorPage.tsx index 60607f0..3f6bf0a 100644 --- a/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorPage.tsx +++ b/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorPage.tsx @@ -1,6 +1,9 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, Tooltip, IconButton, TextField } from '@mui/material'; +import { + Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, + TableRow, TableCell, TableBody, Chip, Tooltip, IconButton, TextField, + FormControl, InputLabel, Select, MenuItem } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import EditIcon from '@mui/icons-material/Edit'; @@ -44,26 +47,32 @@ const CuentaCorrienteSuscriptorPage: React.FC = () => { const [filtroFechaDesde, setFiltroFechaDesde] = useState(getInitialDateRange().fechaDesde); const [filtroFechaHasta, setFiltroFechaHasta] = useState(getInitialDateRange().fechaHasta); + const [filtroEstado, setFiltroEstado] = useState('Todos'); // 'Todos', 'Pendiente', etc. + const [filtroTipo, setFiltroTipo] = useState('Todos'); // 'Todos', 'Credito', 'Debito' + const { tienePermiso, isSuperAdmin } = usePermissions(); const puedeGestionar = isSuperAdmin || tienePermiso("SU011"); const cargarDatos = useCallback(async () => { + const params = new URLSearchParams(); + if (filtroFechaDesde) params.append('fechaDesde', filtroFechaDesde); + if (filtroFechaHasta) params.append('fechaHasta', filtroFechaHasta); + // NOTA: El filtrado por estado y tipo se hará en el frontend por simplicidad, + // pero si la cantidad de ajustes es muy grande, se deberían pasar estos filtros al backend. if (isNaN(idSuscriptor)) { setError("ID de Suscriptor inválido."); setLoading(false); return; } setLoading(true); setApiErrorMessage(null); setError(null); try { - // Usamos Promise.all para cargar todo en paralelo y mejorar el rendimiento const [suscriptorData, ajustesData, empresasData] = await Promise.all([ suscriptorService.getSuscriptorById(idSuscriptor), ajusteService.getAjustesPorSuscriptor(idSuscriptor, filtroFechaDesde || undefined, filtroFechaHasta || undefined), empresaService.getEmpresasDropdown() ]); - + setSuscriptor(suscriptorData); setAjustes(ajustesData); setEmpresas(empresasData); - } catch (err) { setError("Error al cargar los datos."); } finally { @@ -73,6 +82,26 @@ const CuentaCorrienteSuscriptorPage: React.FC = () => { useEffect(() => { cargarDatos(); }, [cargarDatos]); + // Lógica de filtrado en el cliente usando useMemo para eficiencia + const ajustesFiltrados = useMemo(() => { + return ajustes.filter(a => { + const estadoMatch = filtroEstado === 'Todos' || a.estado === filtroEstado; + const tipoMatch = filtroTipo === 'Todos' || a.tipoAjuste === filtroTipo; + return estadoMatch && tipoMatch; + }); + }, [ajustes, filtroEstado, filtroTipo]); + + // Función para renderizar la celda de Estado de forma inteligente + const renderEstadoCell = (ajuste: AjusteDto) => { + if (ajuste.estado !== 'Aplicado' || !ajuste.idFacturaAplicado) { + return ajuste.estado; + } + if (ajuste.numeroFacturaAplicado) { + return `Aplicado (Fact. ${ajuste.numeroFacturaAplicado})`; + } + return `Aplicado (ID Interno #${ajuste.idFacturaAplicado})`; + }; + // --- INICIO DE LA LÓGICA DE SINCRONIZACIÓN DE FECHAS --- const handleFechaDesdeChange = (e: React.ChangeEvent) => { const nuevaFechaDesde = e.target.value; @@ -152,27 +181,48 @@ const CuentaCorrienteSuscriptorPage: React.FC = () => { {suscriptor?.nombreCompleto || ''} - - - - + {/* Panel de filtros reorganizado */} + + + Filtros + + + + + Estado + + + + Tipo + + + {puedeGestionar && ( - )} @@ -198,10 +248,10 @@ const CuentaCorrienteSuscriptorPage: React.FC = () => { {loading ? ( - ) : ajustes.length === 0 ? ( + ) : ajustesFiltrados.length === 0 ? ( No se encontraron ajustes para los filtros seleccionados. ) : ( - ajustes.map(a => ( + ajustesFiltrados.map(a => ( {formatDisplayDate(a.fechaAjuste)} {a.nombreEmpresa || 'N/A'} @@ -210,7 +260,7 @@ const CuentaCorrienteSuscriptorPage: React.FC = () => { {a.motivo} ${a.monto.toFixed(2)} - {a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''} + {renderEstadoCell(a)} {a.nombreUsuarioAlta} {a.estado === 'Pendiente' && puedeGestionar && (