Compare commits

..

5 Commits

Author SHA1 Message Date
9f8d577265 Limpieza de Comantarios
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 7m59s
2025-08-11 15:44:16 -03:00
b594a48fde Feat: Se modifican visual de menú reportes
- Se limita la visual del menú de reportes a los usuarios según los permisos de acceso.
- Se soluciona bug en mensaje al ingresar usuario y/o clave inválidos.
2025-08-11 15:42:23 -03:00
2e7d1e36be Feat(suscripciones): Implementa manejo de pagos parciales en facturas
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 8m3s
Se introduce una refactorización completa del sistema de registro de pagos para manejar correctamente los abonos parciales, asegurando que el estado de la factura y el saldo pendiente se reflejen con precisión tanto en el backend como en la interfaz de usuario.

### 🐛 Problema Solucionado

- Anteriormente, el sistema no reconocía los pagos parciales. Una factura permanecía en estado "Pendiente" hasta que el monto total era cubierto, y la interfaz de usuario siempre mostraba el 100% del saldo como pendiente, lo cual era incorrecto y confuso.

###  Nuevas Características y Mejoras

- **Nuevo Estado de Factura "Pagada Parcialmente":**
    - Se introduce un nuevo estado para las facturas que han recibido uno o más pagos pero cuyo saldo aún no es cero.
    - El `PagoService` ahora actualiza el estado de la factura a "Pagada Parcialmente" cuando recibe un abono que no cubre el total.

- **Mejoras en la Interfaz de Usuario (`ConsultaFacturasPage`):**
    - **Nuevas Columnas:** Se han añadido las columnas "Pagado" y "Saldo" a la tabla de detalle de facturas, mostrando explícitamente el monto abonado y el restante.
    - **Visualización de Estado:** El `Chip` de estado ahora muestra "Pagada Parcialmente" con un color distintivo (azul/primary) para una rápida identificación visual.
    - **Cálculo de Saldo Correcto:** El saldo pendiente total por suscriptor y el saldo para el modal de pago manual ahora se calculan correctamente, restando el `totalPagado` del `importeFinal`.

### 🔄 Cambios en el Backend

- **`PagoService`:** Se actualizó la lógica para establecer el estado de la factura (`Pendiente`, `Pagada Parcialmente`, `Pagada`) basado en el `nuevoTotalPagado` después de registrar un pago.
- **`FacturacionService`:** El método `ObtenerResumenesDeCuentaPorPeriodo` ahora calcula correctamente el `SaldoPendienteTotal` y pasa la propiedad `TotalPagado` al DTO del frontend.
- **DTOs:** Se actualizó `FacturaConsolidadaDto` para incluir la propiedad `TotalPagado`.
2025-08-11 15:15:08 -03:00
dd2277fce2 Fix: Mail Host
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 9m57s
2025-08-11 14:35:02 -03:00
9412556fa8 Feat: Se añade seccion de permisos para Suscripciones
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 7m51s
- Se añade la lista de asignación de permisos de Suscripciones a la UI
- Se añade el permiso de acceso a los reportes de suscripciones
2025-08-11 12:36:51 -03:00
10 changed files with 177 additions and 169 deletions

View File

@@ -4,7 +4,7 @@
# El separador de doble guion bajo (__) se usa para mapear la jerarquía del JSON. # El separador de doble guion bajo (__) se usa para mapear la jerarquía del JSON.
# MailSettings:SmtpHost se convierte en MailSettings__SmtpHost # MailSettings:SmtpHost se convierte en MailSettings__SmtpHost
MailSettings__SmtpHost="mail.eldia.com" MailSettings__SmtpHost="192.168.5.201"
MailSettings__SmtpPort=587 MailSettings__SmtpPort=587
MailSettings__SenderName="Club - Diario El Día" MailSettings__SenderName="Club - Diario El Día"
MailSettings__SenderEmail="alertas@eldia.com" MailSettings__SenderEmail="alertas@eldia.com"

View File

@@ -8,6 +8,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
public string EstadoPago { get; set; } = string.Empty; public string EstadoPago { get; set; } = string.Empty;
public string EstadoFacturacion { get; set; } = string.Empty; public string EstadoFacturacion { get; set; } = string.Empty;
public string? NumeroFactura { get; set; } public string? NumeroFactura { get; set; }
public decimal TotalPagado { get; set; }
public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
} }
} }

View File

@@ -282,7 +282,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
var periodo = $"{anio}-{mes:D2}"; var periodo = $"{anio}-{mes:D2}";
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion); var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion);
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo);
var empresas = await _empresaRepository.GetAllAsync(null, null); var empresas = await _empresaRepository.GetAllAsync(null, null);
var resumenes = facturasData var resumenes = facturasData
@@ -301,6 +301,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
EstadoPago = itemFactura.Factura.EstadoPago, EstadoPago = itemFactura.Factura.EstadoPago,
EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
NumeroFactura = itemFactura.Factura.NumeroFactura, NumeroFactura = itemFactura.Factura.NumeroFactura,
TotalPagado = itemFactura.TotalPagado,
Detalles = detallesData Detalles = detallesData
.Where(d => d.IdFactura == itemFactura.Factura.IdFactura) .Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
.Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto }) .Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto })
@@ -314,7 +315,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
NombreSuscriptor = primerItem.NombreSuscriptor, NombreSuscriptor = primerItem.NombreSuscriptor,
Facturas = facturasConsolidadas, Facturas = facturasConsolidadas,
ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal), ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.EstadoPago == "Pagada" ? 0 : f.ImporteFinal) SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal - f.TotalPagado)
}; };
}); });

View File

@@ -70,14 +70,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura); var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura);
if (factura == null) return (null, "La factura especificada no existe."); if (factura == null) return (null, "La factura especificada no existe.");
// Usar EstadoPago para la validación
if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada."); if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago); var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago);
if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida."); if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida.");
// Obtenemos la suma de pagos ANTERIORES
var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction); var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction);
var nuevoPago = new Pago var nuevoPago = new Pago
@@ -96,37 +93,31 @@ namespace GestionIntegral.Api.Services.Suscripciones
var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction);
if (pagoCreado == null) throw new DataException("No se pudo registrar el pago."); if (pagoCreado == null) throw new DataException("No se pudo registrar el pago.");
// Calculamos el nuevo total EN MEMORIA
var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto; var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto;
// Comparamos y actualizamos el estado si es necesario // Nueva lógica para manejar todos los estados de pago
// CORRECCIÓN: Usar EstadoPago y el método correcto del repositorio string nuevoEstadoPago = factura.EstadoPago;
if (factura.EstadoPago != "Pagada" && nuevoTotalPagado >= factura.ImporteFinal) if (nuevoTotalPagado >= factura.ImporteFinal)
{ {
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, "Pagada", transaction); nuevoEstadoPago = "Pagada";
if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'."); }
else if (nuevoTotalPagado > 0)
{
nuevoEstadoPago = "Pagada Parcialmente";
}
// Si nuevoTotalPagado es 0, el estado no cambia.
// Solo actualizamos si el estado calculado es diferente al actual.
if (nuevoEstadoPago != factura.EstadoPago)
{
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, nuevoEstadoPago, transaction);
if (!actualizado) throw new DataException($"No se pudo actualizar el estado de la factura a '{nuevoEstadoPago}'.");
} }
transaction.Commit(); transaction.Commit();
_logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario); _logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario);
// Construimos el DTO de respuesta SIN volver a consultar la base de datos var dto = await MapToDto(pagoCreado); // MapToDto ahora es más simple
var usuario = await _usuarioRepository.GetByIdAsync(idUsuario);
var dto = new PagoDto
{
IdPago = pagoCreado.IdPago,
IdFactura = pagoCreado.IdFactura,
FechaPago = pagoCreado.FechaPago.ToString("yyyy-MM-dd"),
IdFormaPago = pagoCreado.IdFormaPago,
NombreFormaPago = formaPago.Nombre,
Monto = pagoCreado.Monto,
Estado = pagoCreado.Estado,
Referencia = pagoCreado.Referencia,
Observaciones = pagoCreado.Observaciones,
IdUsuarioRegistro = pagoCreado.IdUsuarioRegistro,
NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
};
return (dto, null); return (dto, null);
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -14,18 +14,18 @@ const SECCION_PERMISSIONS_PREFIX = "SS";
// Mapeo de codAcc de sección a su módulo conceptual // Mapeo de codAcc de sección a su módulo conceptual
const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { const getModuloFromSeccionCodAcc = (codAcc: string): string | null => {
if (codAcc === "SS001") return "Distribución"; if (codAcc === "SS001") return "Distribución";
if (codAcc === "SS007") return "Suscripciones";
if (codAcc === "SS002") return "Contables"; if (codAcc === "SS002") return "Contables";
if (codAcc === "SS003") return "Impresión"; if (codAcc === "SS003") return "Impresión";
if (codAcc === "SS004") return "Reportes"; if (codAcc === "SS004") return "Reportes";
if (codAcc === "SS005") return "Radios";
if (codAcc === "SS006") return "Usuarios"; if (codAcc === "SS006") return "Usuarios";
if (codAcc === "SS005") return "Radios";
return null; return null;
}; };
// Función para determinar el módulo conceptual de un permiso individual // Función para determinar el módulo conceptual de un permiso individual
const getModuloConceptualDelPermiso = (permisoModulo: string): string => { const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
const moduloLower = permisoModulo.toLowerCase(); const moduloLower = permisoModulo.toLowerCase();
if (moduloLower.includes("distribuidores") || if (moduloLower.includes("distribuidores") ||
moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas" moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas"
moduloLower.includes("publicaciones distribución") || moduloLower.includes("publicaciones distribución") ||
@@ -36,6 +36,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
moduloLower.includes("ctrl. devoluciones")) { moduloLower.includes("ctrl. devoluciones")) {
return "Distribución"; return "Distribución";
} }
if (moduloLower.includes("suscripciones")) {
return "Suscripciones";
}
if (moduloLower.includes("cuentas pagos") || if (moduloLower.includes("cuentas pagos") ||
moduloLower.includes("cuentas notas") || moduloLower.includes("cuentas notas") ||
moduloLower.includes("cuentas tipos pagos")) { moduloLower.includes("cuentas tipos pagos")) {
@@ -89,7 +92,7 @@ const PermisosChecklist: React.FC<PermisosChecklistProps> = ({
return acc; return acc;
}, {} as Record<string, PermisoAsignadoDto[]>); }, {} as Record<string, PermisoAsignadoDto[]>);
const ordenModulosPrincipales = ["Distribución", "Contables", "Impresión", "Radios", "Usuarios", "Reportes", "Permisos (Definición)"]; const ordenModulosPrincipales = ["Distribución", "Suscripciones", "Contables", "Impresión", "Usuarios", "Reportes", "Radios","Permisos (Definición)"];
// Añadir módulos que solo tienen permiso de sección (como Radios) pero no hijos (aún) // Añadir módulos que solo tienen permiso de sección (como Radios) pero no hijos (aún)
permisosDeSeccion.forEach(ps => { permisosDeSeccion.forEach(ps => {
const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc); const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc);

View File

@@ -12,6 +12,7 @@ export interface FacturaConsolidadaDto {
estadoPago: string; estadoPago: string;
estadoFacturacion: string; estadoFacturacion: string;
numeroFactura?: string | null; numeroFactura?: string | null;
totalPagado: number;
detalles: FacturaDetalleDto[]; detalles: FacturaDetalleDto[];
// Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers
idSuscriptor: number; idSuscriptor: number;

View File

@@ -3,92 +3,77 @@ import { Box, Paper, Typography, List, ListItemButton, ListItemText, Collapse, C
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore'; import ExpandMore from '@mui/icons-material/ExpandMore';
import { usePermissions } from '../../hooks/usePermissions';
// Definición de los módulos de reporte con sus categorías, etiquetas y rutas const allReportModules: { category: string; label: string; path: string; requiredPermission: string; }[] = [
const allReportModules: { category: string; label: string; path: string }[] = [ { category: 'Existencia Papel', label: 'Existencia de Papel', path: 'existencia-papel', requiredPermission: 'RR005' },
{ category: 'Existencia Papel', label: 'Existencia de Papel', path: 'existencia-papel' }, { category: 'Movimientos Bobinas', label: 'Movimiento de Bobinas', path: 'movimiento-bobinas', requiredPermission: 'RR006' },
{ category: 'Movimientos Bobinas', label: 'Movimiento de Bobinas', path: 'movimiento-bobinas' }, { category: 'Movimientos Bobinas', label: 'Mov. Bobinas por Estado', path: 'movimiento-bobinas-estado', requiredPermission: 'RR006' },
{ category: 'Movimientos Bobinas', label: 'Mov. Bobinas por Estado', path: 'movimiento-bobinas-estado' }, { category: 'Listados Distribución', label: 'Distribución Distribuidores', path: 'listado-distribucion-distribuidores', requiredPermission: 'RR002' },
{ category: 'Listados Distribución', label: 'Distribución Distribuidores', path: 'listado-distribucion-distribuidores' }, { category: 'Listados Distribución', label: 'Distribución Canillas', path: 'listado-distribucion-canillas', requiredPermission: 'RR002' },
{ category: 'Listados Distribución', label: 'Distribución Canillas', path: 'listado-distribucion-canillas' }, { category: 'Listados Distribución', label: 'Distribución General', path: 'listado-distribucion-general', requiredPermission: 'RR002' },
{ category: 'Listados Distribución', label: 'Distribución General', path: 'listado-distribucion-general' }, { category: 'Listados Distribución', label: 'Distrib. Canillas (Importe)', path: 'listado-distribucion-canillas-importe', requiredPermission: 'RR002' },
{ category: 'Listados Distribución', label: 'Distrib. Canillas (Importe)', path: 'listado-distribucion-canillas-importe' }, { category: 'Listados Distribución', label: 'Detalle Distribución Canillas', path: 'detalle-distribucion-canillas', requiredPermission: 'MC005' },
{ category: 'Listados Distribución', label: 'Detalle Distribución Canillas', path: 'detalle-distribucion-canillas' }, { category: 'Secretaría', label: 'Venta Mensual Secretaría', path: 'venta-mensual-secretaria', requiredPermission: 'RR002' },
{ category: 'Secretaría', label: 'Venta Mensual Secretaría', path: 'venta-mensual-secretaria' }, { category: 'Tiradas por Publicación', label: 'Tiradas Publicación/Sección', path: 'tiradas-publicaciones-secciones', requiredPermission: 'RR008' },
{ category: 'Tiradas por Publicación', label: 'Tiradas Publicación/Sección', path: 'tiradas-publicaciones-secciones' }, { category: 'Consumos Bobinas', label: 'Consumo Bobinas/Sección', path: 'consumo-bobinas-seccion', requiredPermission: 'RR007' },
{ category: 'Consumos Bobinas', label: 'Consumo Bobinas/Sección', path: 'consumo-bobinas-seccion' }, { category: 'Consumos Bobinas', label: 'Consumo Bobinas/Publicación', path: 'consumo-bobinas-publicacion', requiredPermission: 'RR007' },
{ category: 'Consumos Bobinas', label: 'Consumo Bobinas/PubPublicación', path: 'consumo-bobinas-publicacion' }, { category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas', requiredPermission: 'RR007' },
{ category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas' }, { category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores', requiredPermission: 'RR001' },
{ category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores' }, { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones', requiredPermission: 'RR003' },
{ category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' }, { category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas', requiredPermission: 'RR004' },
{ category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' }, { category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual', requiredPermission: 'RR009' },
{ category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' }, { category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad', requiredPermission: 'RR010' },
{ category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' }, { category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion', requiredPermission: 'RR011' },
{ category: 'Suscripciones', label: 'Distribución de Suscripciones', path: 'suscripciones-distribucion' },
]; ];
const predefinedCategoryOrder = [ const predefinedCategoryOrder = [
'Balance de Cuentas', 'Balance de Cuentas', 'Listados Distribución', 'Ctrl. Devoluciones',
'Listados Distribución', 'Novedades de Canillitas', 'Suscripciones', 'Existencia Papel',
'Ctrl. Devoluciones', 'Movimientos Bobinas', 'Consumos Bobinas', 'Tiradas por Publicación', 'Secretaría',
'Novedades de Canillitas',
'Suscripciones',
'Existencia Papel',
'Movimientos Bobinas',
'Consumos Bobinas',
'Tiradas por Publicación',
'Secretaría',
]; ];
const ReportesIndexPage: React.FC = () => { const ReportesIndexPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [expandedCategory, setExpandedCategory] = useState<string | false>(false); const [expandedCategory, setExpandedCategory] = useState<string | false>(false);
const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true); const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true);
const { tienePermiso, isSuperAdmin } = usePermissions();
const uniqueCategories = useMemo(() => predefinedCategoryOrder, []); // 1. Creamos una lista memoizada de reportes a los que el usuario SÍ tiene acceso.
const accessibleReportModules = useMemo(() => {
return allReportModules.filter(module =>
isSuperAdmin || tienePermiso(module.requiredPermission)
);
}, [isSuperAdmin, tienePermiso]);
// 2. Creamos una lista de categorías que SÍ tienen al menos un reporte accesible.
const accessibleCategories = useMemo(() => {
const categoriesWithAccess = new Set(accessibleReportModules.map(r => r.category));
return predefinedCategoryOrder.filter(category => categoriesWithAccess.has(category));
}, [accessibleReportModules]);
useEffect(() => { useEffect(() => {
const currentBasePath = '/reportes'; const currentBasePath = '/reportes';
const pathParts = location.pathname.substring(currentBasePath.length + 1).split('/'); const pathParts = location.pathname.substring(currentBasePath.length + 1).split('/');
const subPathSegment = pathParts[0]; const subPathSegment = pathParts[0];
let activeReportFoundInEffect = false; if (subPathSegment) {
const activeReport = accessibleReportModules.find(module => module.path === subPathSegment);
if (subPathSegment && subPathSegment !== "") { // Asegurarse que subPathSegment no esté vacío
const activeReport = allReportModules.find(module => module.path === subPathSegment);
if (activeReport) { if (activeReport) {
setExpandedCategory(activeReport.category); setExpandedCategory(activeReport.category);
activeReportFoundInEffect = true;
} else {
setExpandedCategory(false);
} }
} else {
setExpandedCategory(false);
} }
if (location.pathname === currentBasePath && allReportModules.length > 0 && isLoadingInitialNavigation) { // 4. Navegamos al PRIMER REPORTE ACCESIBLE si estamos en la ruta base.
let firstReportToNavigate: { category: string; label: string; path: string } | null = null; if (location.pathname === currentBasePath && accessibleReportModules.length > 0 && isLoadingInitialNavigation) {
for (const category of uniqueCategories) { const firstReportToNavigate = accessibleReportModules[0];
const reportsInCat = allReportModules.filter(r => r.category === category);
if (reportsInCat.length > 0) {
firstReportToNavigate = reportsInCat[0];
break;
}
}
if (firstReportToNavigate) {
navigate(firstReportToNavigate.path, { replace: true }); navigate(firstReportToNavigate.path, { replace: true });
activeReportFoundInEffect = true;
}
}
// Solo se establece a false si no estamos en el proceso de navegación inicial O si no se encontró reporte
if (!activeReportFoundInEffect || location.pathname !== currentBasePath) {
setIsLoadingInitialNavigation(false);
} }
}, [location.pathname, navigate, uniqueCategories, isLoadingInitialNavigation]); setIsLoadingInitialNavigation(false);
}, [location.pathname, navigate, accessibleReportModules, isLoadingInitialNavigation]);
const handleCategoryClick = (categoryName: string) => { const handleCategoryClick = (categoryName: string) => {
setExpandedCategory(prev => (prev === categoryName ? false : categoryName)); setExpandedCategory(prev => (prev === categoryName ? false : categoryName));
@@ -146,11 +131,15 @@ const ReportesIndexPage: React.FC = () => {
</Typography> </Typography>
</Box> </Box>
{/* Lista de Categorías y Reportes */} {/* 5. Renderizamos el menú usando la lista de categorías ACCESIBLES. */}
{uniqueCategories.length > 0 ? ( {accessibleCategories.length > 0 ? (
<List component="nav" dense sx={{ pt: 0 }} /* Quitar padding superior de la lista si el título ya lo tiene */ > <List component="nav" dense sx={{ pt: 0 }}>
{uniqueCategories.map((category) => { {accessibleCategories.map((category) => {
const reportsInCategory = allReportModules.filter(r => r.category === category); // 6. Obtenemos los reportes de esta categoría de la lista ACCESIBLE.
const reportsInCategory = accessibleReportModules.filter(r => r.category === category);
// Ya no es necesario el `if (reportsInCategory.length === 0) return null;` porque `accessibleCategories` ya está filtrado.
const isExpanded = expandedCategory === category; const isExpanded = expandedCategory === category;
return ( return (
@@ -167,16 +156,9 @@ const ReportesIndexPage: React.FC = () => {
} }
}} }}
> >
<ListItemText <ListItemText primary={category} primaryTypographyProps={{ fontWeight: isExpanded ? 'bold' : 'normal' }}/>
primary={category} {isExpanded ? <ExpandLess /> : <ExpandMore />}
primaryTypographyProps={{
fontWeight: isExpanded ? 'bold' : 'normal',
// color: isExpanded ? 'primary.main' : 'text.primary'
}}
/>
{reportsInCategory.length > 0 && (isExpanded ? <ExpandLess /> : <ExpandMore />)}
</ListItemButton> </ListItemButton>
{reportsInCategory.length > 0 && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit> <Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding dense> <List component="div" disablePadding dense>
{reportsInCategory.map((report) => ( {reportsInCategory.map((report) => (
@@ -205,19 +187,12 @@ const ReportesIndexPage: React.FC = () => {
))} ))}
</List> </List>
</Collapse> </Collapse>
)}
{reportsInCategory.length === 0 && isExpanded && (
<ListItemText
primary="No hay reportes en esta categoría."
sx={{ pl: 3.5, fontStyle: 'italic', color: 'text.secondary', py:1, typography: 'body2' }}
/>
)}
</React.Fragment> </React.Fragment>
); );
})} })}
</List> </List>
) : ( ) : (
<Typography sx={{p:2, fontStyle: 'italic'}}>No hay categorías configuradas.</Typography> <Typography sx={{p:2, fontStyle: 'italic'}}>No tiene acceso a ningún reporte.</Typography>
)} )}
</Paper> </Paper>

View File

@@ -24,7 +24,7 @@ const meses = [
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
]; ];
const estadosPago = ['Pendiente', 'Pagada', 'Rechazada', 'Anulada']; const estadosPago = ['Pendiente', 'Pagada', 'Pagada Parcialmente', 'Rechazada', 'Anulada'];
const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; const estadosFacturacion = ['Pendiente de Facturar', 'Facturado'];
const SuscriptorRow: React.FC<{ const SuscriptorRow: React.FC<{
@@ -33,36 +33,68 @@ const SuscriptorRow: React.FC<{
handleOpenHistorial: (factura: FacturaConsolidadaDto) => void; handleOpenHistorial: (factura: FacturaConsolidadaDto) => void;
}> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => { }> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Función para formatear moneda
const formatCurrency = (value: number) => `$${value.toFixed(2)}`;
return ( return (
<React.Fragment> <React.Fragment>
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover> <TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover>
<TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell> <TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
<TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell> <TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell>
<TableCell align="right"> <TableCell align="right">
<Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>${resumen.saldoPendienteTotal.toFixed(2)}</Typography> <Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>
<Typography variant="caption" color="text.secondary">de ${resumen.importeTotal.toFixed(2)}</Typography> {formatCurrency(resumen.saldoPendienteTotal)}
</Typography>
<Typography variant="caption" color="text.secondary">de {formatCurrency(resumen.importeTotal)}</Typography>
</TableCell> </TableCell>
<TableCell colSpan={5}></TableCell> <TableCell colSpan={7}></TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}> <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={10}>
<Collapse in={open} timeout="auto" unmountOnExit> <Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> <Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}>
<Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography> <Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>
Facturas del Período para {resumen.nombreSuscriptor}
</Typography>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Empresa</TableCell><TableCell align="right">Importe</TableCell> <TableCell>Empresa</TableCell>
<TableCell>Estado Pago</TableCell><TableCell>Estado Facturación</TableCell> <TableCell align="right">Importe Total</TableCell>
<TableCell>Nro. Factura</TableCell><TableCell align="right">Acciones</TableCell> <TableCell align="right">Pagado</TableCell>
<TableCell align="right">Saldo</TableCell>
<TableCell>Estado Pago</TableCell>
<TableCell>Estado Facturación</TableCell>
<TableCell>Nro. Factura</TableCell>
<TableCell align="right">Acciones</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{resumen.facturas.map((factura) => ( {resumen.facturas.map((factura) => {
const saldo = factura.importeFinal - factura.totalPagado;
return (
<TableRow key={factura.idFactura}> <TableRow key={factura.idFactura}>
<TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell> <TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell>
<TableCell align="right">${factura.importeFinal.toFixed(2)}</TableCell> <TableCell align="right">{formatCurrency(factura.importeFinal)}</TableCell>
<TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default')} /></TableCell> <TableCell align="right" sx={{ color: 'success.dark' }}>
{formatCurrency(factura.totalPagado)}
</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold', color: saldo > 0 ? 'error.main' : 'inherit' }}>
{formatCurrency(saldo)}
</TableCell>
<TableCell>
<Chip
label={factura.estadoPago}
size="small"
color={
factura.estadoPago === 'Pagada' ? 'success' :
factura.estadoPago === 'Pagada Parcialmente' ? 'primary' :
factura.estadoPago === 'Rechazada' ? 'error' :
'default'
}
/>
</TableCell>
<TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell> <TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell>
<TableCell>{factura.numeroFactura || '-'}</TableCell> <TableCell>{factura.numeroFactura || '-'}</TableCell>
<TableCell align="right"> <TableCell align="right">
@@ -76,7 +108,8 @@ const SuscriptorRow: React.FC<{
</Tooltip> </Tooltip>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})}
</TableBody> </TableBody>
</Table> </Table>
</Box> </Box>
@@ -257,12 +290,12 @@ const ConsultaFacturasPage: React.FC = () => {
idFactura: selectedFactura.idFactura, idFactura: selectedFactura.idFactura,
nombreSuscriptor: resumenes.find(r => r.idSuscriptor === resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor)?.nombreSuscriptor || '', nombreSuscriptor: resumenes.find(r => r.idSuscriptor === resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor)?.nombreSuscriptor || '',
importeFinal: selectedFactura.importeFinal, importeFinal: selectedFactura.importeFinal,
saldoPendiente: selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal, saldoPendiente: selectedFactura.importeFinal - selectedFactura.totalPagado,
totalPagado: selectedFactura.totalPagado,
idSuscriptor: resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor || 0, idSuscriptor: resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor || 0,
periodo: '', periodo: '',
fechaEmision: '', fechaEmision: '',
fechaVencimiento: '', fechaVencimiento: '',
totalPagado: selectedFactura.importeFinal - (selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal),
estadoPago: selectedFactura.estadoPago, estadoPago: selectedFactura.estadoPago,
estadoFacturacion: selectedFactura.estadoFacturacion, estadoFacturacion: selectedFactura.estadoFacturacion,
numeroFactura: selectedFactura.numeroFactura, numeroFactura: selectedFactura.numeroFactura,

View File

@@ -16,11 +16,12 @@ const SECCION_PERMISSIONS_PREFIX = "SS";
const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { const getModuloFromSeccionCodAcc = (codAcc: string): string | null => {
if (codAcc === "SS001") return "Distribución"; if (codAcc === "SS001") return "Distribución";
if (codAcc === "SS007") return "Suscripciones";
if (codAcc === "SS002") return "Contables"; if (codAcc === "SS002") return "Contables";
if (codAcc === "SS003") return "Impresión"; if (codAcc === "SS003") return "Impresión";
if (codAcc === "SS004") return "Reportes"; if (codAcc === "SS004") return "Reportes";
if (codAcc === "SS005") return "Radios";
if (codAcc === "SS006") return "Usuarios"; if (codAcc === "SS006") return "Usuarios";
if (codAcc === "SS005") return "Radios";
return null; return null;
}; };
@@ -38,6 +39,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
moduloLower.includes("salidas otros destinos")) { moduloLower.includes("salidas otros destinos")) {
return "Distribución"; return "Distribución";
} }
if (moduloLower.includes("suscripciones")) {
return "Suscripciones";
}
if (moduloLower.includes("cuentas pagos") || if (moduloLower.includes("cuentas pagos") ||
moduloLower.includes("cuentas notas") || moduloLower.includes("cuentas notas") ||
moduloLower.includes("cuentas tipos pagos")) { moduloLower.includes("cuentas tipos pagos")) {
@@ -50,9 +54,6 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
moduloLower.includes("tipos bobinas")) { moduloLower.includes("tipos bobinas")) {
return "Impresión"; return "Impresión";
} }
if (moduloLower.includes("radios")) {
return "Radios";
}
if (moduloLower.includes("usuarios") || if (moduloLower.includes("usuarios") ||
moduloLower.includes("perfiles")) { moduloLower.includes("perfiles")) {
return "Usuarios"; return "Usuarios";
@@ -63,6 +64,9 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
if (moduloLower.includes("permisos")) { if (moduloLower.includes("permisos")) {
return "Permisos (Definición)"; return "Permisos (Definición)";
} }
if (moduloLower.includes("radios")) {
return "Radios";
}
return permisoModulo; return permisoModulo;
}; };

View File

@@ -33,19 +33,18 @@ apiClient.interceptors.response.use(
(error) => { (error) => {
// Cualquier código de estado que este fuera del rango de 2xx causa la ejecución de esta función // Cualquier código de estado que este fuera del rango de 2xx causa la ejecución de esta función
if (axios.isAxiosError(error) && error.response) { if (axios.isAxiosError(error) && error.response) {
if (error.response.status === 401) { // Verificamos si la petición fallida NO fue al endpoint de login.
// Token inválido o expirado const isLoginAttempt = error.config?.url?.endsWith('/auth/login');
console.warn("Error 401: Token inválido o expirado. Deslogueando...");
// Solo activamos el deslogueo automático si el error 401 NO es de un intento de login.
if (error.response.status === 401 && !isLoginAttempt) {
console.warn("Error 401 (Token inválido o expirado) detectado. Deslogueando...");
// Limpiar localStorage y recargar la página.
// AuthContext se encargará de redirigir a /login al recargar porque no encontrará token.
localStorage.removeItem('authToken'); localStorage.removeItem('authToken');
localStorage.removeItem('authUser'); // Asegurar limpiar también el usuario localStorage.removeItem('authUser');
// Forzar un hard refresh para que AuthContext se reinicialice y redirija
// Esto también limpiará cualquier estado de React.
// --- Mostrar mensaje antes de redirigir ---
alert("Tu sesión ha expirado o no es válida. Serás redirigido a la página de inicio de sesión."); alert("Tu sesión ha expirado o no es válida. Serás redirigido a la página de inicio de sesión.");
window.location.href = '/login'; // Redirección más directa window.location.href = '/login';
} }
} }
// Es importante devolver el error para que el componente que hizo la llamada pueda manejarlo también si es necesario // Es importante devolver el error para que el componente que hizo la llamada pueda manejarlo también si es necesario