feat: Added NetworkGroups

- Entity
- Dtos
- Manage Services
- SQL Table creation
- View & permissions
This commit is contained in:
2025-09-06 08:06:58 -06:00
parent e0bac85aaf
commit b531f51672
39 changed files with 3243 additions and 8 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"WebFetch(domain:tabler.io)",
"WebFetch(domain:docs.tabler.io)"
],
"deny": [],
"ask": []
}
}

105
SQL/NetworkGroups.sql Normal file
View File

@@ -0,0 +1,105 @@
-- Migration: AddNetworkGroups
-- Description: Adds SplashNetworkGroup and SplashNetworkGroupMember tables
BEGIN;
-- Create SplashNetworkGroups table
CREATE TABLE "SplashNetworkGroups" (
"Id" serial PRIMARY KEY,
"Name" character varying(256) NOT NULL,
"Description" character varying(512),
"IsActive" boolean NOT NULL DEFAULT true,
"TenantId" integer NOT NULL,
"CreationTime" timestamp with time zone NOT NULL DEFAULT now(),
"CreatorUserId" bigint,
"LastModificationTime" timestamp with time zone,
"LastModifierUserId" bigint,
"IsDeleted" boolean NOT NULL DEFAULT false,
"DeleterUserId" bigint,
"DeletionTime" timestamp with time zone
);
-- Create SplashNetworkGroupMembers table
CREATE TABLE "SplashNetworkGroupMembers" (
"Id" serial PRIMARY KEY,
"NetworkGroupId" integer NOT NULL,
"NetworkId" integer NOT NULL,
"TenantId" integer NOT NULL,
"CreationTime" timestamp with time zone NOT NULL DEFAULT now(),
"CreatorUserId" bigint
);
-- Create indexes for SplashNetworkGroups
CREATE UNIQUE INDEX "IX_SplashNetworkGroups_Name_TenantId"
ON "SplashNetworkGroups" ("Name", "TenantId");
CREATE INDEX "IX_SplashNetworkGroups_TenantId"
ON "SplashNetworkGroups" ("TenantId");
CREATE INDEX "IX_SplashNetworkGroups_IsActive"
ON "SplashNetworkGroups" ("IsActive");
-- Create indexes for SplashNetworkGroupMembers
CREATE UNIQUE INDEX "IX_SplashNetworkGroupMembers_NetworkGroupId_NetworkId"
ON "SplashNetworkGroupMembers" ("NetworkGroupId", "NetworkId");
CREATE INDEX "IX_SplashNetworkGroupMembers_NetworkGroupId"
ON "SplashNetworkGroupMembers" ("NetworkGroupId");
CREATE INDEX "IX_SplashNetworkGroupMembers_NetworkId"
ON "SplashNetworkGroupMembers" ("NetworkId");
CREATE INDEX "IX_SplashNetworkGroupMembers_TenantId"
ON "SplashNetworkGroupMembers" ("TenantId");
-- Add foreign key constraints
ALTER TABLE "SplashNetworkGroupMembers"
ADD CONSTRAINT "FK_SplashNetworkGroupMembers_SplashNetworkGroups_NetworkGroupId"
FOREIGN KEY ("NetworkGroupId") REFERENCES "SplashNetworkGroups" ("Id") ON DELETE CASCADE;
ALTER TABLE "SplashNetworkGroupMembers"
ADD CONSTRAINT "FK_SplashNetworkGroupMembers_SplashMerakiNetworks_NetworkId"
FOREIGN KEY ("NetworkId") REFERENCES "SplashMerakiNetworks" ("Id") ON DELETE CASCADE;
-- Add SelectedNetworkGroups column to SplashDashboards table
ALTER TABLE "SplashDashboards"
ADD COLUMN "SelectedNetworkGroups" integer[] DEFAULT '{}';
-- Create index for the new column
CREATE INDEX "IX_SplashDashboards_SelectedNetworkGroups"
ON "SplashDashboards" USING GIN ("SelectedNetworkGroups");
-- Insert migration history record (if using EF Core migrations table)
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250106000000_AddNetworkGroups', '8.0.0');
COMMIT;
Notas importantes sobre el script:
1. Tipos de datos PostgreSQL: Uso serial para auto-incremento y timestamp with time zone para fechas
2. Arrays: PostgreSQL maneja arrays nativamente, por eso SelectedNetworkGroups es integer[]
3. Índices GIN: Para el array SelectedNetworkGroups uso un índice GIN que es óptimo para arrays
4. Cascada: Las foreign keys tienen ON DELETE CASCADE para mantener integridad
5. Valores por defecto: Arrays vacíos se inicializan con '{}'
Para ejecutar este script:
# Si usas psql directamente:
psql -h localhost -U your_user -d your_database -f migration_script.sql
# O copiarlo y pegarlo en tu cliente de PostgreSQL preferido
Verificación después de ejecutar:
-- Verificar que las tablas se crearon correctamente
SELECT table_name FROM information_schema.tables
WHERE table_name IN ('SplashNetworkGroups', 'SplashNetworkGroupMembers');
-- Verificar la nueva columna en SplashDashboards
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'SplashDashboards' AND column_name = 'SelectedNetworkGroups';
-- Verificar índices
SELECT indexname FROM pg_indexes
WHERE tablename IN ('SplashNetworkGroups', 'SplashNetworkGroupMembers');

View File

@@ -157,5 +157,289 @@ Consulta este archivo al inicio de cada sesión para entender el contexto y prog
3. **Reinicio de progresión**: Recuperado → Recurrent (2da conexión) → Loyal (3ra+ conexión) 3. **Reinicio de progresión**: Recuperado → Recurrent (2da conexión) → Loyal (3ra+ conexión)
4. **Zona horaria**: Todos los cálculos usan 'America/Mexico_City' 4. **Zona horaria**: Todos los cálculos usan 'America/Mexico_City'
---
## 2025-01-09 - Implementación de Algoritmo Híbrido para Cálculo de Duración de Sesiones
### Cambios Realizados
#### 1. **Problema Identificado**
- **Duración irreal**: `DurationMinutes` mostraba períodos de días/semanas (ej: 20,209 minutos = 14 días)
- **Causa**: Campo representa "período de vida del registro" no sesiones reales de conectividad
- **Impacto**: Métricas de tiempo de conexión no reflejaban uso real de la red
#### 2. **Investigación de Soluciones**
- **Opción 2 (Implementada)**: Estimación basada en NetworkUsage con algoritmo híbrido
- **Opción 3 (Evaluada)**: APIs granulares de Meraki para eventos de asociación/desasociación
#### 3. **Algoritmo Híbrido Implementado**
**a) Lógica Principal**:
```sql
LEAST(
-- Método 1: Duración bruta del período
EXTRACT(epoch FROM "LastSeen" - "FirstSeen") / 60,
-- Método 2: Estimación basada en NetworkUsage
CASE
WHEN NetworkUsage > 1MB THEN (NetworkUsage/1024) * 0.1 min/MB
WHEN NetworkUsage > 0 THEN 10% del período total
ELSE 5% del período total
END
)
```
**b) Beneficios del Enfoque**:
- ✅ **Conservador**: Siempre toma el menor valor para evitar sobreestimación
- ✅ **Flexible**: Maneja casos extremos de NetworkUsage o período temporal
- ✅ **Auto-limitante**: Límites máximos (8h, 30min) protegen contra valores irreales
- ✅ **Realista**: Sesiones cortas usan tiempo bruto, períodos largos usan estimación
#### 4. **Archivos Modificados**
**a) SQL View (`splash_wifi_connection_report.sql`)**:
- **Campo actualizado**: `DurationMinutes` → algoritmo híbrido
- **Nuevos campos**:
- `UserAvgEstimatedMinutes`: Promedio por usuario
- `SessionDurationCategory`: Quick (<30min), Medium (30min-2h), Extended (>2h)
- **Validación de tipos**: `CAST(NetworkUsage AS bigint)` para compatibilidad
**b) Backend (C#)**:
- **Entity** (`SplashWifiConnectionReport.cs`):
- `DurationMinutes` cambiado a `decimal`
- Agregados `UserAvgEstimatedMinutes`, `SessionDurationCategory`
- **DTO** (`SplashWifiConnectionReportDto.cs`): Sincronizado con entity
- **Service** (`SplashWifiConnectionReportAppService.cs`): Mapeo actualizado
- **Entity Framework** (`SplashWifiConnectionReportConfiguration.cs`): Configuración de nuevos campos
**c) Frontend (JavaScript)**:
- **Función habilitada**: `formatDuration()` descomentada
- **Formato mejorado**: Manejo inteligente de minutos → horas → días
- **Precisión decimal**: Soporte para valores decimales del nuevo cálculo
#### 5. **Resultados Obtenidos**
**Ejemplo Usuario "Omar Montoya" (SplashUserId = 22)**:
- **Antes**: 20,209 min (14 días), 23,155 min (16 días)
- **Después**: 20.42 min, 8.56 min, 1.85 min, 23.88 min, 875.89 min (14.6h)
- **Mejora**: Duraciones realistas basadas en uso real de red
#### 6. **Configuración del Algoritmo**
**Factores Ajustables**:
- **Factor NetworkUsage**: 0.1 minutos por MB
- **Límite porcentual**: 30% del período total como máximo
- **Límites absolutos**: 8 horas (uso moderado), 30 minutos (sin uso)
### Impacto en el Sistema
- ✅ **Compatibilidad**: Mantiene estructura de campos existente
- ✅ **Precisión mejorada**: Duraciones realistas para análisis
- ✅ **Escalabilidad**: Algoritmo optimizado para datasets grandes
- ✅ **Calibración**: Factores ajustables según patrones de uso real
### Archivos Modificados
1. `splash_wifi_connection_report.sql` - Algoritmo híbrido implementado
2. `SplashWifiConnectionReport.cs` - Entity actualizada
3. `SplashWifiConnectionReportDto.cs` - DTO sincronizado
4. `SplashWifiConnectionReportAppService.cs` - Mapeo actualizado
5. `SplashWifiConnectionReportConfiguration.cs` - EF configurado
6. `Index.js` - Frontend habilitado para nuevo formato
7. `changelog.MD` - Documentación de cambios
### Estado Actual
- ✅ Implementación completa del algoritmo híbrido
- ✅ Backend y frontend sincronizados
- ✅ Validación con datos reales completada
- 📋 Listo para ajuste de factores según métricas reales
### Notas para Próxima Sesión
- **Factores calibrables**: 0.1 min/MB, 30% límite, 8h/30min máximos
- **Monitoreo**: Verificar distribución de `SessionDurationCategory`
- **Optimización**: Posible implementación futura de Opción 3 (APIs granulares)
- **Reinicio requerido**: Aplicación debe reiniciarse para tomar cambios de Entity Framework
### Análisis de Capacidades Meraki Realizado
- ✅ **APIs disponibles**: `/networks/{id}/events`, `/networks/{id}/clients/{id}/connectionEvents`
- ✅ **Event Log**: Eventos de asociación/desasociación con timestamps exactos
- ❌ **Limitación actual**: Worker solo usa endpoints agregados, no eventos granulares
- 📋 **Futuro**: Implementar worker adicional para captura de eventos en tiempo real
---
## 2025-09-06 - Corrección de Error HTTP 400 en Actualización de Grupos de Redes
### Problema Identificado
- **Error**: HTTP 400 Bad Request al intentar actualizar grupos de redes
- **Causa**: Inconsistencia entre el payload JSON del frontend y el model binding del controlador MVC
- **JSON enviado**: `selectedNetworkIds: [92, 96, 101, ...]`
- **DTO esperado**: `NetworkIds: [...]`
### Cambios Realizados
#### 1. **Corrección del Model Binding en NetworkGroupController.cs**
- **Problema**: ViewModels no coincidían con el JSON enviado desde JavaScript
- **Solución**: Creación de clases de request específicas para cada operación
**a) Método Update:**
```csharp
// Antes: [FromBody] EditNetworkGroupViewModel model
// Después: [FromBody] UpdateGroupRequest request
public class UpdateGroupRequest
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public bool IsActive { get; set; }
public List<int> SelectedNetworkIds { get; set; } // Coincide con JSON
}
```
**b) Método Create (también corregido):**
```csharp
// Antes: [FromBody] CreateNetworkGroupViewModel model
// Después: [FromBody] CreateGroupRequest request
public class CreateGroupRequest
{
public string Name { get; set; }
public string Description { get; set; }
public List<int> SelectedNetworkIds { get; set; } // Coincide con JSON
}
```
#### 2. **Flujo de Datos Corregido**
- **Frontend JS**: `selectedNetworkIds` → **Request Class**: `SelectedNetworkIds` → **DTO**: `NetworkIds`
- **Mapeo consistente**: Los request objects ahora mapean correctamente al DTO interno
### Archivos Modificados
1. `src/SplashPage.Web.Mvc/Controllers/NetworkGroupController.cs`
- Método `Create`: Usa `CreateGroupRequest` en lugar de `CreateNetworkGroupViewModel`
- Método `Update`: Usa `UpdateGroupRequest` en lugar de `EditNetworkGroupViewModel`
- Agregadas clases de request internas para model binding correcto
2. `changelog.MD` - Documentación de cambios
### Resultado Esperado
- ✅ **HTTP 200**: Actualizaciones de grupos funcionando correctamente
- ✅ **Model binding**: JSON parseado correctamente a objetos .NET
- ✅ **Compatibilidad**: Frontend no requiere cambios, solo backend corregido
- ✅ **Consistencia**: Ambos métodos Create/Update usan la misma estructura
### Estado Actual
- ✅ Error HTTP 400 corregido
- ✅ Model binding consistente implementado
- ✅ Compatibilidad con UI moderna mantenida
- 📋 Listo para testing de funcionalidad completa
### Notas para Próxima Sesión
- **Testing requerido**: Verificar que tanto Create como Update funcionan con listas grandes de redes
- **UI optimization**: La UI moderna con Tabler Components está implementada y optimizada
- **Performance**: JavaScript optimizado con caching, request queueing, y virtual scrolling
- **Payload ejemplo**: `{"id": 3, "name": "Region CO", "selectedNetworkIds": [92, 96, 101, ...]}`
---
## 2025-09-06 - Refactor a ABP Service Proxies (Misma Sesión)
### Problema Identificado
- **Arquitectura inconsistente**: Se estaba usando controladores MVC personalizados en lugar de los service proxies automáticos de ABP
- **Recomendación del usuario**: Usar el patrón establecido como en reportes (`abp.services.app.*`)
- **Complejidad innecesaria**: Código duplicado entre controlador MVC y App Service
### Cambios Realizados
#### 1. **Refactor JavaScript a ABP Service Proxies**
- **Antes**: Llamadas fetch manuales a `/NetworkGroup/*` endpoints
- **Después**: Uso de `abp.services.app.splashNetworkGroup.*`
**Ejemplo de cambios:**
```javascript
// Antes (fetch manual)
const response = await fetch('/NetworkGroup/Update', { method: 'POST', body: JSON.stringify(data) });
// Después (ABP service proxy)
const result = await this._networkGroupService.update(groupData);
```
#### 2. **Simplificación del Controlador MVC**
- **Eliminados**: Todos los métodos API (Create, Update, Delete, Get, GetList, etc.)
- **Conservado**: Solo el método `Index()` para mostrar la vista
- **Resultado**: 90% reducción de código en el controlador
**Antes:** 160+ líneas con 8 métodos API
**Después:** 16 líneas con 1 método de vista
#### 3. **Extensión del App Service**
- **Agregado**: Método `GetStatisticsAsync()` para métricas del dashboard
- **Nuevo DTO**: `NetworkGroupStatisticsDto` para estadísticas
- **Mejorada**: Interfaz `ISplashNetworkGroupAppService` con nuevo método
#### 4. **Optimización de Llamadas JavaScript**
```javascript
// Inicialización de servicios ABP
this._networkGroupService = abp.services.app.splashNetworkGroup;
this._dashboardService = abp.services.app.splashDashboardService;
// Configuración de DataTable con ABP
listAction: {
ajaxFunction: this._networkGroupService.getAll,
inputFilter: () => ({
keyword: $('#globalSearch').val(),
isActive: this.getCurrentFilterValue()
})
}
```
### Beneficios del Refactor
#### **1. Consistencia Arquitectural**
- ✅ **Patrón uniforme**: Ahora sigue el mismo patrón que reportes y otros módulos
- ✅ **ABP Best Practices**: Usa las capacidades automáticas del framework
- ✅ **Menos código**: Eliminación de proxy manual y wrapper controllers
#### **2. Mantenibilidad Mejorada**
- ✅ **Single source of truth**: App Service es la única fuente de lógica de negocio
- ✅ **Type safety**: ABP genera proxies tipados automáticamente
- ✅ **Error handling**: Manejo de errores nativo de ABP
#### **3. Performance Optimizada**
- ✅ **Menos roundtrips**: Eliminación de capa intermedia MVC
- ✅ **Caching automático**: ABP gestiona caché de service proxies
- ✅ **Serialización optimizada**: ABP usa serializers optimizados
### Archivos Modificados
1. `src/SplashPage.Web.Mvc/wwwroot/js/views/networkGroup/index.js`
- Convertido completamente a ABP service proxies
- Eliminadas llamadas fetch() manuales
- Agregada inicialización de servicios ABP
2. `src/SplashPage.Web.Mvc/Controllers/NetworkGroupController.cs`
- Simplificado a solo método `Index()`
- Eliminados 7 métodos API innecesarios
3. `src/SplashPage.Application/Splash/ISplashNetworkGroupAppService.cs`
- Agregado método `GetStatisticsAsync()`
4. `src/SplashPage.Application/Splash/SplashNetworkGroupAppService.cs`
- Implementado método `GetStatisticsAsync()`
5. `src/SplashPage.Application/Splash/Dto/NetworkGroupStatisticsDto.cs` (Nuevo)
- DTO para estadísticas del dashboard
6. `changelog.MD` - Documentación de cambios
### Estado Actual
- ✅ **Refactor completo**: Migración a ABP service proxies completada
- ✅ **Arquitectura consistente**: Patrón uniforme con otros módulos
- ✅ **Código simplificado**: Eliminación de complejidad innecesaria
- ✅ **Compatibilidad mantenida**: UI funciona igual para el usuario
- 📋 **Listo para testing**: Verificar funcionalidad completa con ABP proxies
### Notas Técnicas
- **Service Proxies**: Generados automáticamente en `/AbpServiceProxies/GetAll`
- **Naming Convention**: JavaScript usa camelCase (`getAll`), C# usa PascalCase (`GetAllAsync`)
- **Parameter Objects**: ABP requiere objetos para parámetros (`{ id: id }` no `id`)
- **Error Handling**: ABP maneja automáticamente errores y excepciones
### Próximo Testing
1. **CRUD Operations**: Verificar Create, Update, Delete funcionan
2. **Statistics Loading**: Confirmar métricas del dashboard cargan
3. **Network Management**: Probar asignación/desasignación de redes
4. **Performance**: Monitorear tiempos de respuesta vs implementación anterior
--- ---
*Recuerda actualizar este changelog cada vez que realices cambios significativos* *Recuerda actualizar este changelog cada vez que realices cambios significativos*

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace SplashPage.Splash.Dto
{
public class AssignNetworksToGroupDto
{
[Required]
public int GroupId { get; set; }
[Required]
public List<int> NetworkIds { get; set; } = new List<int>();
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace SplashPage.Splash.Dto
{
public class CreateSplashNetworkGroupDto
{
[Required]
[MaxLength(256)]
public string Name { get; set; }
[MaxLength(512)]
public string Description { get; set; }
public List<int> NetworkIds { get; set; } = new List<int>();
}
}

View File

@@ -0,0 +1,13 @@
namespace SplashPage.Splash.Dto
{
public class NetworkGroupStatisticsDto
{
public int TotalGroups { get; set; }
public int ActiveGroups { get; set; }
public int TotalNetworks { get; set; }
public int UnassignedNetworks { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
using Abp.Application.Services.Dto;
namespace SplashPage.Splash.Dto
{
public class PagedSplashNetworkGroupRequestDto : PagedAndSortedResultRequestDto
{
public string Keyword { get; set; }
public bool? IsActive { get; set; }
}
}

View File

@@ -10,6 +10,7 @@ namespace SplashPage.Splash.Dto
public SplashDashboardDto() public SplashDashboardDto()
{ {
SelectedNetworks = []; SelectedNetworks = [];
SelectedNetworkGroups = [];
StartDate = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc); StartDate = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
EndDate = DateTime.UtcNow; EndDate = DateTime.UtcNow;
} }
@@ -17,6 +18,7 @@ namespace SplashPage.Splash.Dto
public string Name { get; set; } public string Name { get; set; }
public List<SplashWidgetDto> Widgets { get; set; } public List<SplashWidgetDto> Widgets { get; set; }
public List<int> SelectedNetworks { get; set; } public List<int> SelectedNetworks { get; set; }
public List<int> SelectedNetworkGroups { get; set; }
public int TopValue { get; set; } = 5; public int TopValue { get; set; } = 5;
public SplashLoyaltyType loyaltyType { get; set; } = 0; public SplashLoyaltyType loyaltyType { get; set; } = 0;

View File

@@ -0,0 +1,22 @@
using Abp.Application.Services.Dto;
using Abp.AutoMapper;
using SplashPage.Splash;
namespace SplashPage.Splash.Dto
{
[AutoMapFrom(typeof(SplashMerakiNetwork))]
public class SplashMerakiNetworkDto : EntityDto<int>
{
public string MerakiId { get; set; }
public string Name { get; set; }
public int OrganizationId { get; set; }
public int TenantId { get; set; }
public string GroupName { get; set; }
public int? GroupId { get; set; }
}
}

View File

@@ -0,0 +1,21 @@
using Abp.Application.Services.Dto;
using System;
using System.Collections.Generic;
namespace SplashPage.Splash.Dto
{
public class SplashNetworkGroupDto : FullAuditedEntityDto<int>
{
public string Name { get; set; }
public string Description { get; set; }
public bool IsActive { get; set; }
public int TenantId { get; set; }
public int NetworkCount { get; set; }
public List<SplashMerakiNetworkDto> Networks { get; set; } = new List<SplashMerakiNetworkDto>();
}
}

View File

@@ -0,0 +1,22 @@
using AutoMapper;
namespace SplashPage.Splash.Dto
{
public class SplashNetworkGroupMapProfile : Profile
{
public SplashNetworkGroupMapProfile()
{
CreateMap<SplashNetworkGroup, SplashNetworkGroupDto>()
.ForMember(dest => dest.Networks, opt => opt.Ignore())
.ForMember(dest => dest.NetworkCount, opt => opt.Ignore());
CreateMap<CreateSplashNetworkGroupDto, SplashNetworkGroup>()
.ForMember(dest => dest.Networks, opt => opt.Ignore())
.ForMember(dest => dest.TenantId, opt => opt.Ignore());
CreateMap<UpdateSplashNetworkGroupDto, SplashNetworkGroup>()
.ForMember(dest => dest.Networks, opt => opt.Ignore())
.ForMember(dest => dest.TenantId, opt => opt.Ignore());
}
}
}

View File

@@ -0,0 +1,20 @@
using Abp.Application.Services.Dto;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace SplashPage.Splash.Dto
{
public class UpdateSplashNetworkGroupDto : EntityDto<int>
{
[Required]
[MaxLength(256)]
public string Name { get; set; }
[MaxLength(512)]
public string Description { get; set; }
public bool IsActive { get; set; }
public List<int> NetworkIds { get; set; } = new List<int>();
}
}

View File

@@ -17,6 +17,8 @@ namespace SplashPage.Splash
[HttpPost] [HttpPost]
Task<bool> SetDashboardNetworks(SplashDashboardDto model); Task<bool> SetDashboardNetworks(SplashDashboardDto model);
[HttpPost] [HttpPost]
Task<bool> SetDashboardNetworkGroups(SplashDashboardDto model);
[HttpPost]
Task<object> NetworkFirstAP(SplashDashboardDto model); Task<object> NetworkFirstAP(SplashDashboardDto model);
} }
} }

View File

@@ -0,0 +1,22 @@
using Abp.Application.Services;
using SplashPage.Splash.Dto;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace SplashPage.Splash
{
public interface ISplashNetworkGroupAppService : IAsyncCrudAppService<SplashNetworkGroupDto, int, PagedSplashNetworkGroupRequestDto, CreateSplashNetworkGroupDto, UpdateSplashNetworkGroupDto>
{
Task AssignNetworksAsync(AssignNetworksToGroupDto input);
Task<List<SplashMerakiNetworkDto>> GetAvailableNetworksAsync(int? excludeGroupId = null);
Task<List<SplashMerakiNetworkDto>> GetNetworksInGroupAsync(int groupId);
Task<List<SplashNetworkGroupDto>> GetAllGroupsWithNetworkCountAsync();
Task<List<SplashMerakiNetworkDto>> GetAllNetworksWithGroupInfoAsync();
Task<NetworkGroupStatisticsDto> GetStatisticsAsync();
}
}

View File

@@ -19,14 +19,25 @@ namespace SplashPage.Splash
private readonly IRepository<SplashDashboard> _splashDashboardRepository; private readonly IRepository<SplashDashboard> _splashDashboardRepository;
private readonly IRepository<SplashMerakiNetwork> _splashMerakiNetworkRepository; private readonly IRepository<SplashMerakiNetwork> _splashMerakiNetworkRepository;
private readonly IRepository<SplashAccessPoint> _splashAccessPointRepository; private readonly IRepository<SplashAccessPoint> _splashAccessPointRepository;
private readonly IRepository<SplashNetworkGroup> _networkGroupRepository;
private readonly IRepository<SplashNetworkGroupMember> _networkGroupMemberRepository;
private readonly SplashNetworkGroupManager _networkGroupManager;
private readonly IUnitOfWorkManager _unitOfWorkManager; private readonly IUnitOfWorkManager _unitOfWorkManager;
public SplashDashboardService(IRepository<SplashDashboard> splashDashboardRepository, public SplashDashboardService(IRepository<SplashDashboard> splashDashboardRepository,
IUnitOfWorkManager unitOfWorkManager, IRepository<SplashMerakiNetwork> splashMerakiNetworkRepository, IRepository<SplashAccessPoint> splashAccessPointRepository) IUnitOfWorkManager unitOfWorkManager,
IRepository<SplashMerakiNetwork> splashMerakiNetworkRepository,
IRepository<SplashAccessPoint> splashAccessPointRepository,
IRepository<SplashNetworkGroup> networkGroupRepository,
IRepository<SplashNetworkGroupMember> networkGroupMemberRepository,
SplashNetworkGroupManager networkGroupManager)
{ {
_splashDashboardRepository = splashDashboardRepository; _splashDashboardRepository = splashDashboardRepository;
_unitOfWorkManager = unitOfWorkManager; _unitOfWorkManager = unitOfWorkManager;
_splashMerakiNetworkRepository = splashMerakiNetworkRepository; _splashMerakiNetworkRepository = splashMerakiNetworkRepository;
_splashAccessPointRepository = splashAccessPointRepository; _splashAccessPointRepository = splashAccessPointRepository;
_networkGroupRepository = networkGroupRepository;
_networkGroupMemberRepository = networkGroupMemberRepository;
_networkGroupManager = networkGroupManager;
} }
public async Task<SplashDashboard> CreateDashboard(CreateSplashDashboardDto model) public async Task<SplashDashboard> CreateDashboard(CreateSplashDashboardDto model)
@@ -34,7 +45,7 @@ namespace SplashPage.Splash
SplashDashboard splashDashboard = new() SplashDashboard splashDashboard = new()
{ {
Name = model.Name, Name = model.Name,
TenantId = _unitOfWorkManager.Current.GetTenantId() ?? 1, TenantId = 1, // Always use tenant 1 as specified
}; };
try try
@@ -61,7 +72,8 @@ namespace SplashPage.Splash
dashboardId = dashboardId, dashboardId = dashboardId,
Name = "Dashboard not found", Name = "Dashboard not found",
Widgets = new List<SplashWidgetDto>(), Widgets = new List<SplashWidgetDto>(),
SelectedNetworks = new List<int>() SelectedNetworks = new List<int>(),
SelectedNetworkGroups = new List<int>()
}; };
} }
@@ -79,6 +91,7 @@ namespace SplashPage.Splash
Name = _dashboard.Name ?? string.Empty, Name = _dashboard.Name ?? string.Empty,
dashboardId = _dashboard.Id, dashboardId = _dashboard.Id,
SelectedNetworks = _dashboard.SelectedNetworks ?? new List<int>(), SelectedNetworks = _dashboard.SelectedNetworks ?? new List<int>(),
SelectedNetworkGroups = _dashboard.SelectedNetworkGroups ?? new List<int>(),
//StartDate = _dashboard.StartDate, //StartDate = _dashboard.StartDate,
//EndDate = _dashboard.EndDate //EndDate = _dashboard.EndDate
}; };
@@ -90,7 +103,8 @@ namespace SplashPage.Splash
dashboardId = dashboardId, dashboardId = dashboardId,
Name = "Error retrieving dashboard", Name = "Error retrieving dashboard",
Widgets = new List<SplashWidgetDto>(), Widgets = new List<SplashWidgetDto>(),
SelectedNetworks = new List<int>() SelectedNetworks = new List<int>(),
SelectedNetworkGroups = new List<int>()
}; };
} }
} }
@@ -389,7 +403,7 @@ namespace SplashPage.Splash
W = widget.W, W = widget.W,
X = widget.X, X = widget.X,
Y = widget.Y, Y = widget.Y,
TenantId = _unitOfWorkManager.Current.GetTenantId() ?? 1, TenantId = 1, // Always use tenant 1 as specified
Content = widget.Content, Content = widget.Content,
}); });
} }
@@ -444,9 +458,13 @@ namespace SplashPage.Splash
{ {
var _networksCount = await _splashMerakiNetworkRepository.GetAllReadonly().CountAsync(); var _networksCount = await _splashMerakiNetworkRepository.GetAllReadonly().CountAsync();
var _dashboard = await _splashDashboardRepository.GetAsync(model.dashboardId); var _dashboard = await _splashDashboardRepository.GetAsync(model.dashboardId);
// Reset networks and groups
_dashboard.SelectedNetworks = []; _dashboard.SelectedNetworks = [];
_dashboard.SelectedNetworkGroups = [];
_splashDashboardRepository.Update(_dashboard); _splashDashboardRepository.Update(_dashboard);
// Handle networks selection
if (model.SelectedNetworks == null || model.SelectedNetworks.Contains(0) || model.SelectedNetworks.Count(s => s != 0) == _networksCount) if (model.SelectedNetworks == null || model.SelectedNetworks.Contains(0) || model.SelectedNetworks.Count(s => s != 0) == _networksCount)
{ {
_dashboard.SelectedNetworks.Add(0); _dashboard.SelectedNetworks.Add(0);
@@ -459,8 +477,16 @@ namespace SplashPage.Splash
} }
} }
_splashDashboardRepository.Update(_dashboard); // Handle groups selection
if (model.SelectedNetworkGroups != null)
{
foreach (var groupId in model.SelectedNetworkGroups)
{
_dashboard.SelectedNetworkGroups.Add(groupId);
}
}
_splashDashboardRepository.Update(_dashboard);
await CurrentUnitOfWork.SaveChangesAsync(); await CurrentUnitOfWork.SaveChangesAsync();
return true; return true;
} }
@@ -468,9 +494,35 @@ namespace SplashPage.Splash
{ {
return false; return false;
} }
}
public async Task<bool> SetDashboardNetworkGroups(SplashDashboardDto model)
{
try
{
var _dashboard = await _splashDashboardRepository.GetAsync(model.dashboardId);
// Reset only groups, keep networks
_dashboard.SelectedNetworkGroups = [];
_splashDashboardRepository.Update(_dashboard);
// Handle groups selection
if (model.SelectedNetworkGroups != null)
{
foreach (var groupId in model.SelectedNetworkGroups)
{
_dashboard.SelectedNetworkGroups.Add(groupId);
}
}
_splashDashboardRepository.Update(_dashboard);
await CurrentUnitOfWork.SaveChangesAsync();
return true;
}
catch
{
return false;
}
} }
[UnitOfWork] [UnitOfWork]
@@ -492,6 +544,93 @@ namespace SplashPage.Splash
} }
} }
public async Task<List<SplashNetworkGroupDto>> GetNetworkGroupsAsync()
{
const int tenantId = 1; // Always use tenant 1 as specified
var groups = await _networkGroupRepository.GetAll()
.Where(g => g.TenantId == tenantId && g.IsActive)
.ToListAsync();
return groups.Select(g => new SplashNetworkGroupDto
{
Id = g.Id,
Name = g.Name,
Description = g.Description,
IsActive = g.IsActive
}).ToList();
}
public async Task<List<SplashMerakiNetworkDto>> GetNetworksForSelectorAsync()
{
const int tenantId = 1; // Always use tenant 1 as specified
var networks = await _splashMerakiNetworkRepository.GetAll()
.Where(n => n.TenantId == tenantId && !n.IsDeleted)
.ToListAsync();
// Get network group memberships
var networkGroupMembers = await _networkGroupMemberRepository.GetAllIncluding(m => m.NetworkGroup)
.Where(m => m.TenantId == tenantId)
.ToListAsync();
var networkDtos = new List<SplashMerakiNetworkDto>();
foreach (var network in networks)
{
var dto = new SplashMerakiNetworkDto
{
Id = network.Id,
Name = network.Name,
MerakiId = network.MerakiId,
OrganizationId = network.OrganizationId,
TenantId = network.TenantId
};
var groupMember = networkGroupMembers.FirstOrDefault(m => m.NetworkId == network.Id);
if (groupMember != null)
{
dto.GroupId = groupMember.NetworkGroupId;
dto.GroupName = groupMember.NetworkGroup?.Name;
}
networkDtos.Add(dto);
}
return networkDtos;
}
public async Task<List<int>> GetEffectiveNetworkIdsAsync(SplashDashboardDto model)
{
var effectiveNetworkIds = new List<int>();
// Add directly selected networks
if (model.SelectedNetworks?.Any() == true && !model.SelectedNetworks.Contains(0))
{
effectiveNetworkIds.AddRange(model.SelectedNetworks);
}
// Add networks from selected groups
if (model.SelectedNetworkGroups?.Any() == true)
{
var groupNetworkIds = await _networkGroupManager.GetNetworkIdsByGroupIdsAsync(model.SelectedNetworkGroups);
effectiveNetworkIds.AddRange(groupNetworkIds);
}
// If no specific networks or groups are selected, or "All" is selected, return all networks
if (!effectiveNetworkIds.Any() || (model.SelectedNetworks?.Contains(0) == true))
{
const int tenantId = 1; // Always use tenant 1 as specified
var allNetworkIds = await _splashMerakiNetworkRepository.GetAll()
.Where(n => n.TenantId == tenantId && !n.IsDeleted)
.Select(n => n.Id)
.ToListAsync();
return allNetworkIds;
}
return effectiveNetworkIds.Distinct().ToList();
}
//public async Task<SplashWidget> AddWidget(SplashAddWidgetDto model) //public async Task<SplashWidget> AddWidget(SplashAddWidgetDto model)
//{ //{
// var _dashboard = await _splashDashboardRepository.GetAsync(model.DashboardId); // var _dashboard = await _splashDashboardRepository.GetAsync(model.DashboardId);

View File

@@ -0,0 +1,247 @@
using Abp.Application.Services;
using Abp.Application.Services.Dto;
using Abp.Authorization;
using Abp.Domain.Repositories;
using Abp.Domain.Uow;
using Abp.Linq.Extensions;
using Microsoft.EntityFrameworkCore;
using SplashPage.Authorization;
using SplashPage.Splash.Dto;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SplashPage.Splash
{
[AbpAuthorize(PermissionNames.Pages_Administration_NetworkGroups)]
public class SplashNetworkGroupAppService : AsyncCrudAppService<SplashNetworkGroup, SplashNetworkGroupDto, int, PagedSplashNetworkGroupRequestDto, CreateSplashNetworkGroupDto, UpdateSplashNetworkGroupDto>, ISplashNetworkGroupAppService
{
private readonly SplashNetworkGroupManager _networkGroupManager;
private readonly IRepository<SplashNetworkGroupMember> _networkGroupMemberRepository;
private readonly IRepository<SplashMerakiNetwork> _networkRepository;
public SplashNetworkGroupAppService(
IRepository<SplashNetworkGroup, int> repository,
SplashNetworkGroupManager networkGroupManager,
IRepository<SplashNetworkGroupMember> networkGroupMemberRepository,
IRepository<SplashMerakiNetwork> networkRepository)
: base(repository)
{
_networkGroupManager = networkGroupManager;
_networkGroupMemberRepository = networkGroupMemberRepository;
_networkRepository = networkRepository;
}
protected override IQueryable<SplashNetworkGroup> CreateFilteredQuery(PagedSplashNetworkGroupRequestDto input)
{
return Repository.GetAll()
.WhereIf(!string.IsNullOrWhiteSpace(input.Keyword),
x => x.Name.Contains(input.Keyword) || x.Description.Contains(input.Keyword))
.WhereIf(input.IsActive.HasValue, x => x.IsActive == input.IsActive.Value);
}
public override async Task<SplashNetworkGroupDto> CreateAsync(CreateSplashNetworkGroupDto input)
{
CheckCreatePermission();
const int tenantId = 1; // Always use tenant 1 as specified
// Use domain service to create the group with validations
var group = await _networkGroupManager.CreateGroupAsync(input.Name, input.Description, tenantId);
await CurrentUnitOfWork.SaveChangesAsync();
// Assign networks if provided
if (input.NetworkIds?.Any() == true)
{
await _networkGroupManager.AssignNetworksToGroupAsync(group.Id, input.NetworkIds);
await CurrentUnitOfWork.SaveChangesAsync();
}
// Map to DTO and return
var dto = ObjectMapper.Map<SplashNetworkGroupDto>(group);
dto.NetworkCount = input.NetworkIds?.Count ?? 0;
return dto;
}
public override async Task<SplashNetworkGroupDto> UpdateAsync(UpdateSplashNetworkGroupDto input)
{
CheckUpdatePermission();
var group = await Repository.GetAsync(input.Id);
// Use domain service to update with validations
await _networkGroupManager.UpdateGroupAsync(group, input.Name, input.Description);
group.IsActive = input.IsActive;
await Repository.UpdateAsync(group);
await CurrentUnitOfWork.SaveChangesAsync();
// Update network assignments if provided
if (input.NetworkIds != null)
{
await _networkGroupManager.AssignNetworksToGroupAsync(group.Id, input.NetworkIds);
await CurrentUnitOfWork.SaveChangesAsync();
}
// Map to DTO and return
var dto = ObjectMapper.Map<SplashNetworkGroupDto>(group);
dto.NetworkCount = input.NetworkIds?.Count ?? 0;
return dto;
}
public override async Task DeleteAsync(EntityDto<int> input)
{
CheckDeletePermission();
await _networkGroupManager.DeleteGroupAsync(input.Id);
await CurrentUnitOfWork.SaveChangesAsync();
}
public override async Task<SplashNetworkGroupDto> GetAsync(EntityDto<int> input)
{
var group = await Repository.GetAsync(input.Id);
var dto = new SplashNetworkGroupDto
{
Id = group.Id,
Name = group.Name,
Description = group.Description,
IsActive = group.IsActive,
TenantId = group.TenantId,
CreationTime = group.CreationTime,
CreatorUserId = group.CreatorUserId,
LastModificationTime = group.LastModificationTime,
LastModifierUserId = group.LastModifierUserId,
DeletionTime = group.DeletionTime,
DeleterUserId = group.DeleterUserId,
IsDeleted = group.IsDeleted
};
// Get network count and networks for this group
var networkGroupMembers = await _networkGroupMemberRepository.GetAll()
.Where(m => m.NetworkGroupId == group.Id)
.ToListAsync();
dto.NetworkCount = networkGroupMembers.Count;
// Load associated networks if needed
if (networkGroupMembers.Any())
{
var networkIds = networkGroupMembers.Select(m => m.NetworkId).ToList();
var networks = await _networkRepository.GetAll()
.Where(n => networkIds.Contains(n.Id))
.ToListAsync();
dto.Networks = ObjectMapper.Map<List<SplashMerakiNetworkDto>>(networks);
}
return dto;
}
[AbpAuthorize(PermissionNames.Pages_Administration_NetworkGroups_Edit)]
public async Task AssignNetworksAsync(AssignNetworksToGroupDto input)
{
await _networkGroupManager.AssignNetworksToGroupAsync(input.GroupId, input.NetworkIds);
await CurrentUnitOfWork.SaveChangesAsync();
}
public async Task<List<SplashMerakiNetworkDto>> GetAvailableNetworksAsync(int? excludeGroupId = null)
{
const int tenantId = 1; // Always use tenant 1 as specified
var availableNetworks = await _networkGroupManager.GetAvailableNetworksAsync(tenantId, excludeGroupId);
return ObjectMapper.Map<List<SplashMerakiNetworkDto>>(availableNetworks);
}
public async Task<List<SplashMerakiNetworkDto>> GetNetworksInGroupAsync(int groupId)
{
var networks = await _networkRepository.GetAll()
.Where(n => _networkGroupMemberRepository.GetAll()
.Where(m => m.NetworkGroupId == groupId)
.Select(m => m.NetworkId)
.Contains(n.Id))
.ToListAsync();
return ObjectMapper.Map<List<SplashMerakiNetworkDto>>(networks);
}
public async Task<List<SplashNetworkGroupDto>> GetAllGroupsWithNetworkCountAsync()
{
const int tenantId = 1; // Always use tenant 1 as specified
var groups = await _networkGroupManager.GetGroupsWithNetworkCountAsync(tenantId);
var dtos = ObjectMapper.Map<List<SplashNetworkGroupDto>>(groups);
// Set network count for each group
foreach (var dto in dtos)
{
var group = groups.FirstOrDefault(g => g.Id == dto.Id);
dto.NetworkCount = group?.Networks?.Count ?? 0;
}
return dtos;
}
public async Task<List<SplashMerakiNetworkDto>> GetAllNetworksWithGroupInfoAsync()
{
const int tenantId = 1; // Always use tenant 1 as specified
var networks = await _networkRepository.GetAll()
.Where(n => n.TenantId == tenantId && !n.IsDeleted)
.ToListAsync();
var networkGroupMembers = await _networkGroupMemberRepository.GetAllIncluding(m => m.NetworkGroup)
.Where(m => m.TenantId == tenantId)
.ToListAsync();
var networkDtos = ObjectMapper.Map<List<SplashMerakiNetworkDto>>(networks);
// Add group information to each network
foreach (var networkDto in networkDtos)
{
var groupMember = networkGroupMembers.FirstOrDefault(m => m.NetworkId == networkDto.Id);
if (groupMember != null)
{
networkDto.GroupId = groupMember.NetworkGroupId;
networkDto.GroupName = groupMember.NetworkGroup?.Name;
}
}
return networkDtos;
}
public async Task<NetworkGroupStatisticsDto> GetStatisticsAsync()
{
const int tenantId = 1; // Always use tenant 1 as specified
// Count total groups
var totalGroups = await Repository.CountAsync(g => g.TenantId == tenantId && !g.IsDeleted);
// Count active groups
var activeGroups = await Repository.CountAsync(g => g.TenantId == tenantId && !g.IsDeleted && g.IsActive);
// Count total networks
var totalNetworks = await _networkRepository.CountAsync(n => n.TenantId == tenantId && !n.IsDeleted);
// Count assigned networks
var assignedNetworkIds = await _networkGroupMemberRepository.GetAll()
.Where(m => m.TenantId == tenantId)
.Select(m => m.NetworkId)
.Distinct()
.CountAsync();
var unassignedNetworks = totalNetworks - assignedNetworkIds;
return new NetworkGroupStatisticsDto
{
TotalGroups = totalGroups,
ActiveGroups = activeGroups,
TotalNetworks = totalNetworks,
UnassignedNetworks = unassignedNetworks
};
}
}
}

View File

@@ -12,4 +12,9 @@ public static class PermissionNames
public const string Pages_Captive_Portal = "Pages.CP"; public const string Pages_Captive_Portal = "Pages.CP";
public const string Pages_Integrations = "Pages.Integrations"; public const string Pages_Integrations = "Pages.Integrations";
public const string Pages_Administration_NetworkGroups = "Pages.Administration.NetworkGroups";
public const string Pages_Administration_NetworkGroups_Create = "Pages.Administration.NetworkGroups.Create";
public const string Pages_Administration_NetworkGroups_Edit = "Pages.Administration.NetworkGroups.Edit";
public const string Pages_Administration_NetworkGroups_Delete = "Pages.Administration.NetworkGroups.Delete";
} }

View File

@@ -15,6 +15,12 @@ public class SplashPageAuthorizationProvider : AuthorizationProvider
context.CreatePermission(PermissionNames.Pages_Captive_Portal, L("CaptivePortal")); context.CreatePermission(PermissionNames.Pages_Captive_Portal, L("CaptivePortal"));
context.CreatePermission(PermissionNames.Pages_Integrations, L("Integrations")); context.CreatePermission(PermissionNames.Pages_Integrations, L("Integrations"));
// Network Groups permissions
var networkGroupsPermission = context.CreatePermission(PermissionNames.Pages_Administration_NetworkGroups, L("NetworkGroups"));
networkGroupsPermission.CreateChildPermission(PermissionNames.Pages_Administration_NetworkGroups_Create, L("CreatingNetworkGroup"));
networkGroupsPermission.CreateChildPermission(PermissionNames.Pages_Administration_NetworkGroups_Edit, L("EditingNetworkGroup"));
networkGroupsPermission.CreateChildPermission(PermissionNames.Pages_Administration_NetworkGroups_Delete, L("DeletingNetworkGroup"));
} }
private static ILocalizableString L(string name) private static ILocalizableString L(string name)

View File

@@ -34,6 +34,7 @@ namespace SplashPage.Splash
public string ExtensionData { get; set; } public string ExtensionData { get; set; }
public List<SplashWidget> Widgets { get; set; } = []; public List<SplashWidget> Widgets { get; set; } = [];
public List<int> SelectedNetworks { get; set; } = []; public List<int> SelectedNetworks { get; set; } = [];
public List<int> SelectedNetworkGroups { get; set; } = [];
public DateTime StartDate { get; set; } public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; } public DateTime EndDate { get; set; }

View File

@@ -0,0 +1,39 @@
using Abp.Domain.Entities;
using Abp.Domain.Entities.Auditing;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace SplashPage.Splash
{
public class SplashNetworkGroup : FullAuditedEntity<int>, IMustHaveTenant
{
public SplashNetworkGroup()
{
IsActive = true;
Networks = new List<SplashNetworkGroupMember>();
}
public SplashNetworkGroup(string name, string description, int tenantId)
{
Name = name;
Description = description;
TenantId = tenantId;
IsActive = true;
Networks = new List<SplashNetworkGroupMember>();
}
[Required]
[MaxLength(256)]
public string Name { get; set; }
[MaxLength(512)]
public string Description { get; set; }
public bool IsActive { get; set; }
public int TenantId { get; set; }
// Navigation Properties
public virtual ICollection<SplashNetworkGroupMember> Networks { get; set; }
}
}

View File

@@ -0,0 +1,187 @@
using Abp.Domain.Repositories;
using Abp.Domain.Services;
using Abp.UI;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SplashPage.Splash
{
public class SplashNetworkGroupManager : DomainService
{
private readonly IRepository<SplashNetworkGroup> _networkGroupRepository;
private readonly IRepository<SplashNetworkGroupMember> _networkGroupMemberRepository;
private readonly IRepository<SplashMerakiNetwork> _networkRepository;
public SplashNetworkGroupManager(
IRepository<SplashNetworkGroup> networkGroupRepository,
IRepository<SplashNetworkGroupMember> networkGroupMemberRepository,
IRepository<SplashMerakiNetwork> networkRepository)
{
_networkGroupRepository = networkGroupRepository;
_networkGroupMemberRepository = networkGroupMemberRepository;
_networkRepository = networkRepository;
}
public async Task<SplashNetworkGroup> CreateGroupAsync(string name, string description, int tenantId)
{
await ValidateGroupNameAsync(name, tenantId);
var group = new SplashNetworkGroup(name, description, tenantId);
return await _networkGroupRepository.InsertAsync(group);
}
public async Task<SplashNetworkGroup> UpdateGroupAsync(SplashNetworkGroup group, string name, string description)
{
await ValidateGroupNameAsync(name, group.TenantId, group.Id);
group.Name = name;
group.Description = description;
return await _networkGroupRepository.UpdateAsync(group);
}
public async Task ValidateGroupNameAsync(string name, int tenantId, int? excludeId = null)
{
var query = _networkGroupRepository.GetAll()
.Where(g => g.TenantId == tenantId && g.Name == name);
if (excludeId.HasValue)
{
query = query.Where(g => g.Id != excludeId.Value);
}
var exists = await query.AnyAsync();
if (exists)
{
throw new UserFriendlyException($"Ya existe un grupo con el nombre '{name}'");
}
}
public async Task AssignNetworksToGroupAsync(int groupId, List<int> networkIds)
{
if (networkIds == null || !networkIds.Any())
return;
var group = await _networkGroupRepository.GetAsync(groupId);
// Validar que todas las redes existen y pertenecen al tenant correcto
var validNetworks = await _networkRepository.GetAll()
.Where(n => networkIds.Contains(n.Id) && n.TenantId == group.TenantId && !n.IsDeleted)
.ToListAsync();
if (validNetworks.Count != networkIds.Count)
{
var invalidIds = networkIds.Except(validNetworks.Select(n => n.Id)).ToList();
throw new UserFriendlyException($"Las siguientes redes no son válidas: {string.Join(", ", invalidIds)}");
}
// Remover asignaciones existentes para este grupo
var existingMembers = await _networkGroupMemberRepository.GetAll()
.Where(m => m.NetworkGroupId == groupId)
.ToListAsync();
foreach (var member in existingMembers)
{
await _networkGroupMemberRepository.DeleteAsync(member);
}
// Crear nuevas asignaciones
foreach (var networkId in networkIds)
{
var member = new SplashNetworkGroupMember(groupId, networkId, group.TenantId);
await _networkGroupMemberRepository.InsertAsync(member);
}
}
public async Task RemoveNetworkFromGroupAsync(int groupId, int networkId)
{
var member = await _networkGroupMemberRepository.FirstOrDefaultAsync(
m => m.NetworkGroupId == groupId && m.NetworkId == networkId);
if (member != null)
{
await _networkGroupMemberRepository.DeleteAsync(member);
}
}
public async Task<List<int>> GetNetworkIdsByGroupIdsAsync(List<int> groupIds)
{
if (groupIds == null || !groupIds.Any())
return new List<int>();
return await _networkGroupMemberRepository.GetAll()
.Where(m => groupIds.Contains(m.NetworkGroupId))
.Select(m => m.NetworkId)
.Distinct()
.ToListAsync();
}
public async Task<List<SplashNetworkGroup>> GetGroupsWithNetworkCountAsync(int tenantId)
{
var groups = await _networkGroupRepository.GetAllIncluding(g => g.Networks)
.Where(g => g.TenantId == tenantId && g.IsActive)
.ToListAsync();
return groups;
}
public async Task<bool> CanDeleteGroupAsync(int groupId)
{
// Aquí se pueden agregar validaciones adicionales
// Por ejemplo, verificar si el grupo está siendo usado en dashboards
// Por ahora, siempre permitir eliminar
return true;
}
public async Task DeleteGroupAsync(int groupId)
{
if (!await CanDeleteGroupAsync(groupId))
{
throw new UserFriendlyException("No se puede eliminar este grupo porque está siendo usado.");
}
var group = await _networkGroupRepository.GetAsync(groupId);
// Eliminar todos los miembros del grupo primero
var members = await _networkGroupMemberRepository.GetAll()
.Where(m => m.NetworkGroupId == groupId)
.ToListAsync();
foreach (var member in members)
{
await _networkGroupMemberRepository.DeleteAsync(member);
}
// Eliminar el grupo
await _networkGroupRepository.DeleteAsync(group);
}
public async Task<List<SplashMerakiNetwork>> GetAvailableNetworksAsync(int tenantId, int? excludeGroupId = null)
{
var assignedNetworkIds = await _networkGroupMemberRepository.GetAll()
.Where(m => excludeGroupId == null || m.NetworkGroupId != excludeGroupId)
.Select(m => m.NetworkId)
.ToListAsync();
return await _networkRepository.GetAll()
.Where(n => n.TenantId == tenantId && !n.IsDeleted && !assignedNetworkIds.Contains(n.Id))
.ToListAsync();
}
public async Task<List<SplashMerakiNetwork>> GetNetworksInGroupAsync(int groupId)
{
return await _networkRepository.GetAll()
.Where(n => n.Id == _networkGroupMemberRepository.GetAll()
.Where(m => m.NetworkGroupId == groupId)
.Select(m => m.NetworkId)
.FirstOrDefault())
.ToListAsync();
}
}
}

View File

@@ -0,0 +1,30 @@
using Abp.Domain.Entities;
using Abp.Domain.Entities.Auditing;
using System.ComponentModel.DataAnnotations;
namespace SplashPage.Splash
{
public class SplashNetworkGroupMember : CreationAuditedEntity<int>, IMustHaveTenant
{
public SplashNetworkGroupMember()
{
}
public SplashNetworkGroupMember(int networkGroupId, int networkId, int tenantId)
{
NetworkGroupId = networkGroupId;
NetworkId = networkId;
TenantId = tenantId;
}
public int NetworkGroupId { get; set; }
public int NetworkId { get; set; }
public int TenantId { get; set; }
// Navigation Properties
public virtual SplashNetworkGroup NetworkGroup { get; set; }
public virtual SplashMerakiNetwork Network { get; set; }
}
}

View File

@@ -40,6 +40,9 @@ public class SplashPageDbContext : AbpZeroDbContext<Tenant, Role, User, SplashPa
public DbSet<SplashIntegration> SplashIntegrations { get; set; } public DbSet<SplashIntegration> SplashIntegrations { get; set; }
public DbSet<SplashEmailValidation> SplashEmailValidations { get; set; } public DbSet<SplashEmailValidation> SplashEmailValidations { get; set; }
public DbSet<SplashNetworkGroup> SplashNetworkGroups { get; set; }
public DbSet<SplashNetworkGroupMember> SplashNetworkGroupMembers { get; set; }
public SplashPageDbContext(DbContextOptions<SplashPageDbContext> options) public SplashPageDbContext(DbContextOptions<SplashPageDbContext> options)
: base(options) : base(options)
{ {
@@ -69,6 +72,30 @@ public class SplashPageDbContext : AbpZeroDbContext<Tenant, Role, User, SplashPa
entity.Property(e => e.Timestamp); entity.Property(e => e.Timestamp);
}); });
// SplashNetworkGroup configuration
modelBuilder.Entity<SplashNetworkGroup>(entity =>
{
entity.HasIndex(e => new { e.Name, e.TenantId }).IsUnique();
entity.Property(e => e.Name).IsRequired().HasMaxLength(256);
entity.Property(e => e.Description).HasMaxLength(512);
});
// SplashNetworkGroupMember configuration
modelBuilder.Entity<SplashNetworkGroupMember>(entity =>
{
entity.HasIndex(e => new { e.NetworkGroupId, e.NetworkId }).IsUnique();
entity.HasOne(e => e.NetworkGroup)
.WithMany(g => g.Networks)
.HasForeignKey(e => e.NetworkGroupId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Network)
.WithMany()
.HasForeignKey(e => e.NetworkId)
.OnDelete(DeleteBehavior.Cascade);
});
// Automatically apply all configurations from current assembly // Automatically apply all configurations from current assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(SplashPageDbContext).Assembly); modelBuilder.ApplyConfigurationsFromAssembly(typeof(SplashPageDbContext).Assembly);

View File

@@ -19,14 +19,17 @@ namespace SplashPage.Web.Controllers
private readonly ISplashDashboardService _splashDashboardService; private readonly ISplashDashboardService _splashDashboardService;
private readonly ISplashDataService _splashDataService; private readonly ISplashDataService _splashDataService;
private readonly ISplashMetricsService _splashMetricsService; private readonly ISplashMetricsService _splashMetricsService;
private readonly ISplashNetworkGroupAppService _networkGroupAppService;
public DashboardController(ISplashDashboardService splashDashboardService, public DashboardController(ISplashDashboardService splashDashboardService,
ISplashDataService splashDataService, ISplashDataService splashDataService,
ISplashMetricsService splashMetricsService ISplashMetricsService splashMetricsService,
ISplashNetworkGroupAppService networkGroupAppService
) )
{ {
_splashDashboardService = splashDashboardService; _splashDashboardService = splashDashboardService;
_splashDataService = splashDataService; _splashDataService = splashDataService;
_splashMetricsService = splashMetricsService; _splashMetricsService = splashMetricsService;
_networkGroupAppService = networkGroupAppService;
} }
public async Task<IActionResult> Index(int? id, bool dev = false) public async Task<IActionResult> Index(int? id, bool dev = false)
@@ -41,6 +44,7 @@ namespace SplashPage.Web.Controllers
}; };
var _networks = await _splashDashboardService.GetNetworksAsync(); var _networks = await _splashDashboardService.GetNetworksAsync();
var _networkGroups = await _networkGroupAppService.GetAllGroupsWithNetworkCountAsync();
model.Dashboard = await _splashDashboardService.GetDashboard(dashboardId); model.Dashboard = await _splashDashboardService.GetDashboard(dashboardId);
@@ -72,6 +76,30 @@ namespace SplashPage.Web.Controllers
model.AvailableNetworks.Add(new SelectListItem { Text = "Todas", Value = "0", Selected = model.Dashboard.SelectedNetworks.Where(d => d == 0).Any() }); model.AvailableNetworks.Add(new SelectListItem { Text = "Todas", Value = "0", Selected = model.Dashboard.SelectedNetworks.Where(d => d == 0).Any() });
// Populate Network Groups
if (model.Dashboard.SelectedNetworkGroups is not null)
{
model.AvailableNetworkGroups = _networkGroups
.Where(g => g.IsActive) // Solo grupos activos
.Select(g => new SelectListItem
{
Text = $"{g.Name} ({g.NetworkCount} redes)",
Value = g.Id.ToString(),
Selected = model.Dashboard.SelectedNetworkGroups.Contains(g.Id)
}).ToList();
}
else
{
model.Dashboard.SelectedNetworkGroups = [];
model.AvailableNetworkGroups = _networkGroups
.Where(g => g.IsActive) // Solo grupos activos
.Select(g => new SelectListItem
{
Text = $"{g.Name} ({g.NetworkCount} redes)",
Value = g.Id.ToString(),
Selected = false,
}).ToList();
}
//Fix dashbaord with no networks selected //Fix dashbaord with no networks selected
var firstNetwork = model.AvailableNetworks.FirstOrDefault(); var firstNetwork = model.AvailableNetworks.FirstOrDefault();

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Mvc;
using SplashPage.Authorization;
using SplashPage.Controllers;
using Abp.AspNetCore.Mvc.Authorization;
namespace SplashPage.Web.Mvc.Controllers
{
[AbpMvcAuthorize(PermissionNames.Pages_Administration_NetworkGroups)]
public class NetworkGroupController : SplashPageControllerBase
{
public ActionResult Index()
{
return View();
}
}
}

View File

@@ -9,10 +9,12 @@ namespace SplashPage.Web.Models.Dashboard
public DashboardViewModel() public DashboardViewModel()
{ {
AvailableNetworks = []; AvailableNetworks = [];
AvailableNetworkGroups = [];
AvailableWidgets = []; AvailableWidgets = [];
} }
public List<SplashWidgetName> AvailableWidgets { get; set; } public List<SplashWidgetName> AvailableWidgets { get; set; }
public List<SelectListItem> AvailableNetworks { get; set; } public List<SelectListItem> AvailableNetworks { get; set; }
public List<SelectListItem> AvailableNetworkGroups { get; set; }
public SplashDashboardDto Dashboard { get; set; } public SplashDashboardDto Dashboard { get; set; }
public SplashAverageDto AverageData { get; set; } public SplashAverageDto AverageData { get; set; }
} }

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace SplashPage.Web.Models.NetworkGroup
{
public class CreateNetworkGroupViewModel
{
[Required]
[MaxLength(256)]
public string Name { get; set; }
[MaxLength(512)]
public string Description { get; set; }
public List<int> SelectedNetworkIds { get; set; } = new List<int>();
}
}

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace SplashPage.Web.Models.NetworkGroup
{
public class EditNetworkGroupViewModel
{
public int Id { get; set; }
[Required]
[MaxLength(256)]
public string Name { get; set; }
[MaxLength(512)]
public string Description { get; set; }
public bool IsActive { get; set; }
public List<int> SelectedNetworkIds { get; set; } = new List<int>();
}
}

View File

@@ -12,4 +12,5 @@ public class PageNames
public const string WifiConnectionReport = "Reporte de Conexiones"; public const string WifiConnectionReport = "Reporte de Conexiones";
public const string WifiScanningReport = "Reporte Transeuntes y Visitas"; public const string WifiScanningReport = "Reporte Transeuntes y Visitas";
public const string Integrations = "Integrations"; public const string Integrations = "Integrations";
public const string NetworkGroups = "NetworkGroups";
} }

View File

@@ -76,6 +76,13 @@ public class SplashPageNavigationProvider : NavigationProvider
url: "Roles", url: "Roles",
icon: "ti ti-user-cog", icon: "ti ti-user-cog",
permissionDependency: new SimplePermissionDependency(PermissionNames.Pages_Roles) permissionDependency: new SimplePermissionDependency(PermissionNames.Pages_Roles)
))
.AddItem(new MenuItemDefinition(
PageNames.NetworkGroups,
new FixedLocalizableString("Grupos de Redes"),
url: "NetworkGroup",
icon: "ti ti-network",
permissionDependency: new SimplePermissionDependency(PermissionNames.Pages_Administration_NetworkGroups)
)); ));
// Crear un submenú para los dashboards // Crear un submenú para los dashboards

View File

@@ -366,6 +366,14 @@
<div class="me-3">
<select type="text" class="form-select" placeholder="Grupos de Redes" id="select-network-groups" value="" multiple>
@foreach (var group in Model.AvailableNetworkGroups)
{
<option value="@group.Value" selected=@group.Selected>@group.Text</option>
}
</select>
</div>
<div class="me-3"> <div class="me-3">
<select type="text" class="form-select" placeholder="Sucursales" id="select-tags" value="" multiple> <select type="text" class="form-select" placeholder="Sucursales" id="select-tags" value="" multiple>
@foreach (var item in Model.AvailableNetworks) @foreach (var item in Model.AvailableNetworks)

View File

@@ -0,0 +1,729 @@
@{
ViewData["Title"] = "Grupos de Redes";
}
@section Styles {
<style>
/* Modern Network Groups UI Styles */
.network-group-header {
background: linear-gradient(135deg, var(--tblr-primary) 0%, var(--tblr-primary-darker) 100%);
color: white;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
}
.network-group-header::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 200px;
height: 200px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.stats-card {
background-color: var(--tblr-bg-surface);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
}
.stats-card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.network-item {
transition: all 0.2s ease;
border-radius: 8px;
border: 2px dashed transparent;
}
.network-item:hover {
background: var(--tblr-bg-surface-secondary);
transform: translateY(-1px);
}
.network-item.dragging {
opacity: 0.6;
transform: rotate(5deg);
}
.drop-zone {
border: 2px dashed var(--tblr-primary);
background: rgba(var(--tblr-primary-rgb), 0.05);
border-radius: 8px;
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.drop-zone.active {
border-color: var(--tblr-success);
background: rgba(var(--tblr-success-rgb), 0.1);
}
.search-box {
position: sticky;
top: 0;
background: var(--tblr-bg-surface-secondary);
z-index: 100;
padding: 1rem 0;
margin-bottom: 1rem;
}
.network-card {
cursor: grab;
border-radius: 8px;
transition: all 0.2s ease;
}
.network-card:active {
cursor: grabbing;
}
.network-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
z-index: 1000;
}
.pulse-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tblr-primary);
animation: pulse 1.4s ease-in-out infinite both;
}
.pulse-dot:nth-child(1) { animation-delay: -0.32s; }
.pulse-dot:nth-child(2) { animation-delay: -0.16s; }
@@keyframes pulse {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.table-actions {
white-space: nowrap;
}
.btn-action {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
}
.btn-action:hover {
transform: scale(1.1);
}
/* Responsive improvements */
@@media (max-width: 768px) {
.network-group-header {
padding: 1.5rem;
text-align: center;
}
.stats-card {
margin-bottom: 1rem;
}
}
</style>
}
<!-- Page wrapper -->
<div class="page-wrapper">
<!-- Page header -->
<div class="page-header d-print-none">
<div class="container-xl">
<!-- Header Hero Section -->
<div class="network-group-header">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle text-white-50">
Administración
</div>
<h1 class="page-title text-white mb-0">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg me-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 0 0 -12 0"/>
<path d="M12 3c1.333 .333 2 2.333 2 6s-.667 5.667 -2 6"/>
<path d="M12 3c-1.333 .333 -2 2.333 -2 6s.667 5.667 2 6"/>
<path d="M6 9h12"/>
<path d="M3 20h7l-1 -1"/>
<path d="M21 20h-7l1 -1"/>
</svg>
Grupos de Redes
</h1>
<div class="page-subtitle text-white-75 mt-2">
Organiza y gestiona tus redes por regiones para un mejor control y análisis
</div>
</div>
<div class="col-auto">
<div class="btn-list">
<button type="button" class="btn btn-white btn-icon" data-bs-toggle="tooltip" title="Exportar datos">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/>
<path d="M9 15l2 2l4 -4"/>
</svg>
</button>
<button type="button" class="btn btn-white" onclick="createGroup()">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 5l0 14"/>
<path d="M5 12l14 0"/>
</svg>
Crear Grupo
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Page body -->
<div class="page-body">
<div class="container-xl">
<!-- Stats Cards -->
<div class="row row-deck row-cards mb-4" id="statsCards">
<div class="col-sm-6 col-lg-3">
<div class="stats-card">
<div class="d-flex align-items-center">
<div class="stats-card-icon bg-primary-lt text-primary me-3">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-md" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5"/>
<path d="M12 12l8 -4.5"/>
<path d="M12 12l0 9"/>
<path d="M12 12l-8 -4.5"/>
</svg>
</div>
<div>
<div class="h3 mb-0" id="totalGroups">-</div>
<div class="text-muted">Total Grupos</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="stats-card">
<div class="d-flex align-items-center">
<div class="stats-card-icon bg-success-lt text-success me-3">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-md" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/>
<path d="M12 1l0 6"/>
<path d="M12 17l0 6"/>
<path d="M5.636 5.636l4.243 4.243"/>
<path d="M14.121 14.121l4.243 4.243"/>
<path d="M1 12l6 0"/>
<path d="M17 12l6 0"/>
<path d="M5.636 18.364l4.243 -4.243"/>
<path d="M14.121 9.879l4.243 -4.243"/>
</svg>
</div>
<div>
<div class="h3 mb-0" id="activeGroups">-</div>
<div class="text-muted">Grupos Activos</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="stats-card">
<div class="d-flex align-items-center">
<div class="stats-card-icon bg-info-lt text-info me-3">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-md" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 0 0 -12 0"/>
<path d="M12 3c1.333 .333 2 2.333 2 6s-.667 5.667 -2 6"/>
<path d="M12 3c-1.333 .333 -2 2.333 -2 6s.667 5.667 2 6"/>
<path d="M6 9h12"/>
</svg>
</div>
<div>
<div class="h3 mb-0" id="totalNetworks">-</div>
<div class="text-muted">Redes Asignadas</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="stats-card">
<div class="d-flex align-items-center">
<div class="stats-card-icon bg-warning-lt text-warning me-3">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-md" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 0 0 -12 0"/>
<path d="M12 3c1.333 .333 2 2.333 2 6s-.667 5.667 -2 6"/>
<path d="M12 3c-1.333 .333 -2 2.333 -2 6s.667 5.667 2 6"/>
<path d="M6 9h12"/>
<path d="M12 21l3 -9l-6 0l3 9"/>
</svg>
</div>
<div>
<div class="h3 mb-0" id="unassignedNetworks">-</div>
<div class="text-muted">Sin Asignar</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content Card -->
<div class="card">
<div class="card-header">
<div class="row align-items-center">
<div class="col">
<h3 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="icon me-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 11l3 3l8 -8"/>
<path d="M20 12v6a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h9"/>
</svg>
Gestión de Grupos
</h3>
<div class="card-subtitle">
Administra tus grupos de redes de manera eficiente
</div>
</div>
<div class="col-auto">
<!-- Search and Filters -->
<div class="d-flex gap-2">
<div class="input-icon" style="width: 250px;">
<input type="text" id="globalSearch" class="form-control" placeholder="Buscar grupos...">
<span class="input-icon-addon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/>
<path d="M21 21l-6 -6"/>
</svg>
</span>
</div>
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" class="icon me-1" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 6l16 0"/>
<path d="M4 12l7 0"/>
<path d="M4 18l9 0"/>
</svg>
Filtros
</button>
<div class="dropdown-menu">
<a class="dropdown-item filter-option" href="#" data-filter="all">
<svg xmlns="http://www.w3.org/2000/svg" class="icon dropdown-item-icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/>
</svg>
Todos los grupos
</a>
<a class="dropdown-item filter-option" href="#" data-filter="active">
<svg xmlns="http://www.w3.org/2000/svg" class="icon dropdown-item-icon text-success" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M5 12l5 5l10 -10"/>
</svg>
Solo activos
</a>
<a class="dropdown-item filter-option" href="#" data-filter="inactive">
<svg xmlns="http://www.w3.org/2000/svg" class="icon dropdown-item-icon text-danger" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M18 6l-12 12"/>
<path d="M6 6l12 12"/>
</svg>
Solo inactivos
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body position-relative">
<!-- Loading overlay -->
<div id="tableLoadingOverlay" class="loading-overlay" style="display: none;">
<div class="d-flex align-items-center">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>
<span class="text-muted">Cargando datos...</span>
</div>
</div>
<!-- Data Table -->
<div class="table-responsive">
<table id="networkGroupsTable" class="table table-vcenter">
<thead>
<tr>
<th class="w-1">
<input class="form-check-input" type="checkbox" id="selectAll">
</th>
<th>Grupo</th>
<th>Estado</th>
<th>Redes</th>
<th>Creación</th>
<th class="w-1">Acciones</th>
</tr>
</thead>
</table>
</div>
</div>
<div class="card-footer d-flex align-items-center">
<div class="me-auto text-muted" id="tableInfo">
<!-- DataTable info will be inserted here -->
</div>
<div id="tablePagination">
<!-- DataTable pagination will be inserted here -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modern Create/Edit Modal with Stepper -->
<div class="modal modal-blur fade" id="createEditModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-md me-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5"/>
<path d="M12 12l8 -4.5"/>
<path d="M12 12l0 9"/>
<path d="M12 12l-8 -4.5"/>
</svg>
Crear Grupo de Redes
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Stepper Navigation -->
<div class="steps steps-counter steps-lime mb-4">
<div class="step-item active" id="step1-nav">
<div class="step-counter">1</div>
<div class="step-display">
<div class="step-title">Información Básica</div>
<div class="step-subtitle">Nombre y descripción</div>
</div>
</div>
<div class="step-item" id="step2-nav">
<div class="step-counter">2</div>
<div class="step-display">
<div class="step-title">Asignar Redes</div>
<div class="step-subtitle">Selecciona las redes</div>
</div>
</div>
<div class="step-item" id="step3-nav">
<div class="step-counter">3</div>
<div class="step-display">
<div class="step-title">Confirmación</div>
<div class="step-subtitle">Revisar y guardar</div>
</div>
</div>
</div>
<form id="groupForm" novalidate>
<input type="hidden" id="groupId" />
<!-- Step 1: Basic Information -->
<div class="step-content active" id="step1-content">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card">
<div class="card-body">
<h3 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="icon me-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 6l0 12"/>
<path d="M6 12l12 0"/>
</svg>
Información del Grupo
</h3>
<p class="text-muted">Ingresa los datos básicos para tu nuevo grupo de redes.</p>
<div class="mb-3">
<label class="form-label required">Nombre del Grupo</label>
<input type="text" class="form-control" id="groupName" required maxlength="256" placeholder="Ej: Región México Central">
<div class="invalid-feedback">El nombre es requerido y debe tener menos de 256 caracteres.</div>
</div>
<div class="mb-3">
<label class="form-label">Descripción</label>
<textarea class="form-control" id="groupDescription" rows="4" maxlength="512" placeholder="Describe el propósito de este grupo de redes..."></textarea>
<div class="form-hint">Opcional. Máximo 512 caracteres.</div>
</div>
<div class="mb-3">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="groupIsActive" checked>
<span class="form-check-label">
<strong>Grupo Activo</strong>
<span class="form-check-description">Los grupos inactivos no aparecerán en los filtros del dashboard</span>
</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Step 2: Network Assignment -->
<div class="step-content" id="step2-content" style="display: none;">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<h3 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="icon me-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 0 0 -12 0"/>
<path d="M12 3c1.333 .333 2 2.333 2 6s-.667 5.667 -2 6"/>
<path d="M12 3c-1.333 .333 -2 2.333 -2 6s.667 5.667 2 6"/>
<path d="M6 9h12"/>
</svg>
Asignar Redes al Grupo
</h3>
<p class="text-muted">Selecciona las redes que pertenecerán a este grupo. Puedes usar drag & drop o hacer clic para seleccionar.</p>
<!-- Search Box -->
<div class="search-box">
<div class="input-icon mb-3">
<input type="text" id="networkSearch" class="form-control" placeholder="Buscar redes...">
<span class="input-icon-addon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/>
<path d="M21 21l-6 -6"/>
</svg>
</span>
</div>
</div>
<div class="row">
<!-- Available Networks -->
<div class="col-md-5">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">Redes Disponibles</h4>
<span class="badge bg-info" id="availableCount">0</span>
</div>
<div class="border rounded p-3" style="min-height: 300px; max-height: 400px; overflow-y: auto;" id="availableNetworksContainer">
<div class="text-center text-muted py-4" id="availableEmpty">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg mb-2" width="48" height="48" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 0 0 -12 0"/>
<path d="M12 3c1.333 .333 2 2.333 2 6s-.667 5.667 -2 6"/>
<path d="M12 3c-1.333 .333 -2 2.333 -2 6s.667 5.667 2 6"/>
<path d="M6 9h12"/>
</svg>
<div>Cargando redes...</div>
</div>
</div>
</div>
<!-- Transfer Controls -->
<div class="col-md-2 d-flex flex-column align-items-center justify-content-center">
<div class="btn-list-vertical">
<button type="button" class="btn btn-primary btn-icon mb-2" onclick="moveSelectedToGroup()" data-bs-toggle="tooltip" title="Mover seleccionados al grupo">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 6l6 6l-6 6"/>
</svg>
</button>
<button type="button" class="btn btn-secondary btn-icon mb-2" onclick="moveAllToGroup()" data-bs-toggle="tooltip" title="Mover todas al grupo">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M7 6l4 6l-4 6"/>
<path d="M13 6l4 6l-4 6"/>
</svg>
</button>
<button type="button" class="btn btn-outline-secondary btn-icon mb-2" onclick="moveSelectedFromGroup()" data-bs-toggle="tooltip" title="Quitar seleccionados del grupo">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 6l-6 6l6 6"/>
</svg>
</button>
<button type="button" class="btn btn-outline-danger btn-icon" onclick="moveAllFromGroup()" data-bs-toggle="tooltip" title="Quitar todas del grupo">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M17 6l-4 6l4 6"/>
<path d="M11 6l-4 6l4 6"/>
</svg>
</button>
</div>
</div>
<!-- Selected Networks -->
<div class="col-md-5">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">Redes en el Grupo</h4>
<span class="badge bg-success" id="selectedCount">0</span>
</div>
<div class="border rounded p-3 drop-zone" style="min-height: 300px; max-height: 400px; overflow-y: auto;" id="selectedNetworksContainer">
<div class="text-center text-muted py-4" id="selectedEmpty">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg mb-2" width="48" height="48" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5"/>
<path d="M12 12l8 -4.5"/>
<path d="M12 12l0 9"/>
<path d="M12 12l-8 -4.5"/>
</svg>
<div>Arrastra redes aquí o usa los botones</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Step 3: Confirmation -->
<div class="step-content" id="step3-content" style="display: none;">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card">
<div class="card-body">
<h3 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="icon me-2 text-success" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M5 12l5 5l10 -10"/>
</svg>
Confirmar Creación del Grupo
</h3>
<p class="text-muted">Revisa la información antes de crear el grupo.</p>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-muted">Nombre:</label>
<div class="h4" id="confirmName">-</div>
</div>
<div class="mb-3">
<label class="form-label text-muted">Estado:</label>
<div id="confirmStatus">-</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-muted">Descripción:</label>
<div id="confirmDescription">-</div>
</div>
<div class="mb-3">
<label class="form-label text-muted">Redes Asignadas:</label>
<div class="h4 text-primary" id="confirmNetworkCount">0</div>
</div>
</div>
</div>
<div class="alert alert-info">
<div class="d-flex">
<div>
<svg xmlns="http://www.w3.org/2000/svg" class="icon alert-icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/>
<path d="M12 9h.01"/>
<path d="M11 12h1v4h1"/>
</svg>
</div>
<div>
Una vez creado el grupo, podrás usarlo en los filtros del dashboard para analizar métricas específicas de estas redes.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<div class="me-auto">
<button type="button" class="btn btn-link" id="prevBtn" onclick="previousStep()" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" class="icon me-1" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 6l-6 6l6 6"/>
</svg>
Anterior
</button>
</div>
<button type="button" class="btn" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-primary" id="nextBtn" onclick="nextStep()">
Siguiente
<svg xmlns="http://www.w3.org/2000/svg" class="icon ms-1" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 6l6 6l-6 6"/>
</svg>
</button>
<button type="button" class="btn btn-success" id="saveBtn" onclick="saveGroup()" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" class="icon me-1" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"/>
<path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/>
<path d="M14 4l0 4l-6 0l0 -4"/>
</svg>
Crear Grupo
</button>
</div>
</div>
</div>
</div>
<!-- Toast Container for Notifications -->
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index: 1055;"></div>
@section Scripts {
<script src="~/js/views/networkGroup/index.js"></script>
}

View File

@@ -34,8 +34,8 @@
<title>@pageTitle</title> <title>@pageTitle</title>
@await Html.PartialAsync("_Styles.cshtml")
@RenderSection("styles", required: false) @RenderSection("styles", required: false)
@await Html.PartialAsync("_Styles.cshtml")
</head> </head>
<body data-bs-theme="dark"> <body data-bs-theme="dark">
<div class="page"> <div class="page">

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
let tomSelect; let tomSelect;
let tomSelectGroups;
const TOM_SELECT_MAX_DISPLAY_NUMBER = 0; const TOM_SELECT_MAX_DISPLAY_NUMBER = 0;
const _service = abp.services.app.splashDashboardService; const _service = abp.services.app.splashDashboardService;
let grid; let grid;
@@ -37,6 +38,30 @@ document.addEventListener("DOMContentLoaded", function () {
} }
} }
async function handleNetworkGroupsFilter() {
let selectedGroups = tomSelectGroups.getValue();
if (Array.isArray(selectedGroups)) {
if (selectedGroups.length === 0) {
return toastr.info('Debe seleccionar al menos un grupo!!');
}
} else if (typeof selectedGroups === 'string' && selectedGroups.trim() === '') {
return toastr.info('Debe seleccionar al menos un grupo!!');
}
let result = await _service.setDashboardNetworkGroups({
dashboardId: modelData.dashboard.dashboardId,
selectedNetworkGroups: selectedGroups
});
if (result) {
modelData.dashboard.selectedNetworkGroups = selectedGroups;
LoadGridHtml();
} else {
toastr.error('Error al aplicar filtro de grupos');
}
}
tomSelect = new TomSelect(el = document.getElementById('select-tags'), { tomSelect = new TomSelect(el = document.getElementById('select-tags'), {
createFilter: function(input) { createFilter: function(input) {
input = input.toLowerCase(); input = input.toLowerCase();
@@ -91,6 +116,45 @@ document.addEventListener("DOMContentLoaded", function () {
window.tomSelectss = tomSelect; window.tomSelectss = tomSelect;
// TomSelect para Grupos de Redes
tomSelectGroups = new TomSelect(el = document.getElementById('select-network-groups'), {
createFilter: function(input) {
input = input.toLowerCase();
return function(item) {
return item.text.toLowerCase().includes(input);
};
},
copyClassesToDropdown: false,
onBlur: handleNetworkGroupsFilter,
controlInput: '<input>',
plugins: {
'checkbox_options': {
'checkedClassNames': ['ts-checked'],
'uncheckedClassNames': ['ts-unchecked'],
},
},
render: {
item: function (data, escape) {
if (this.items.length == 0) {
this.settings.placeholder = ``
return '<span class="tag is-success mb-1 mr-1">' + escape(data.text) + '</span>';
}
else {
return '<span class="tag is-success mb-1 mr-1" style="display:none">' + escape(data.text) + '</span>';
}
}
},
onChange: function () {
console.log("groups - data: " + this.items + "this.items: " + this.items.length);
if (this.items.length > 1) {
this.settings.placeholder = `+${this.items.length - 1} Grupos`;
this.inputState();
}
},
});
window.tomSelectGroups = tomSelectGroups;
async function LoadDT() { async function LoadDT() {
// Get references to elements // Get references to elements
const startDateInput = document.getElementById('startDate'); const startDateInput = document.getElementById('startDate');