Compare commits
5 Commits
Suscripcio
...
9f8d577265
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f8d577265 | |||
| b594a48fde | |||
| 2e7d1e36be | |||
| dd2277fce2 | |||
| 9412556fa8 |
@@ -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"
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
navigate(firstReportToNavigate.path, { replace: true });
|
||||||
if (reportsInCat.length > 0) {
|
|
||||||
firstReportToNavigate = reportsInCat[0];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (firstReportToNavigate) {
|
|
||||||
navigate(firstReportToNavigate.path, { replace: true });
|
|
||||||
activeReportFoundInEffect = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Solo se establece a false si no estamos en el proceso de navegación inicial O si no se encontró reporte
|
|
||||||
if (!activeReportFoundInEffect || location.pathname !== currentBasePath) {
|
|
||||||
setIsLoadingInitialNavigation(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [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,17 +156,10 @@ 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) => (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
@@ -204,20 +186,13 @@ const ReportesIndexPage: React.FC = () => {
|
|||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
)}
|
|
||||||
{reportsInCategory.length === 0 && isExpanded && (
|
|
||||||
<ListItemText
|
|
||||||
primary="No hay reportes en esta categoría."
|
|
||||||
sx={{ pl: 3.5, fontStyle: 'italic', color: 'text.secondary', py:1, typography: 'body2' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
) : (
|
) : (
|
||||||
<Typography sx={{p:2, fontStyle: 'italic'}}>No hay categorías configuradas.</Typography>
|
<Typography sx={{p:2, fontStyle: 'italic'}}>No tiene acceso a ningún reporte.</Typography>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -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,50 +33,83 @@ 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) => {
|
||||||
<TableRow key={factura.idFactura}>
|
const saldo = factura.importeFinal - factura.totalPagado;
|
||||||
<TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell>
|
return (
|
||||||
<TableCell align="right">${factura.importeFinal.toFixed(2)}</TableCell>
|
<TableRow key={factura.idFactura}>
|
||||||
<TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default')} /></TableCell>
|
<TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell>
|
||||||
<TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell>
|
<TableCell align="right">{formatCurrency(factura.importeFinal)}</TableCell>
|
||||||
<TableCell>{factura.numeroFactura || '-'}</TableCell>
|
<TableCell align="right" sx={{ color: 'success.dark' }}>
|
||||||
<TableCell align="right">
|
{formatCurrency(factura.totalPagado)}
|
||||||
<IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}>
|
</TableCell>
|
||||||
<MoreVertIcon />
|
<TableCell align="right" sx={{ fontWeight: 'bold', color: saldo > 0 ? 'error.main' : 'inherit' }}>
|
||||||
</IconButton>
|
{formatCurrency(saldo)}
|
||||||
<Tooltip title="Ver Historial de Envíos">
|
</TableCell>
|
||||||
<IconButton onClick={() => handleOpenHistorial(factura)}>
|
<TableCell>
|
||||||
<MailOutlineIcon />
|
<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>
|
</IconButton>
|
||||||
</Tooltip>
|
<Tooltip title="Ver Historial de Envíos">
|
||||||
</TableCell>
|
<IconButton onClick={() => handleOpenHistorial(factura)}>
|
||||||
</TableRow>
|
<MailOutlineIcon />
|
||||||
))}
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</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,
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user