changes: Link connection report to API, enchaced filter and display value

This commit is contained in:
2025-10-23 00:01:57 -06:00
parent 0832e6305b
commit 0e3eb6b308
13 changed files with 1365 additions and 304 deletions

228
QWEN.md Normal file
View File

@@ -0,0 +1,228 @@
# QWEN.md - SplashPage Project Context
## Project Overview
SplashPage is an ASP.NET Core web application for managing WiFi captive portals and analytics, built on the ABP (ASP.NET Boilerplate) framework. It's designed to work with Cisco Meraki networks to provide WiFi access control, user analytics, and customer engagement features.
## Architecture
This is a multi-layered .NET application following Domain-Driven Design (DDD) principles:
### Core Components
- **SplashPage.Core**: Domain entities, enums, and business logic
- **SplashPage.Application**: Application services, DTOs, and business workflows
- **SplashPage.EntityFrameworkCore**: Data access layer with Entity Framework Core
- **SplashPage.Web.Core**: Shared web infrastructure (authentication, controllers)
- **SplashPage.Web.Host**: Web API host for backend services
- **SplashPage.Web.Mvc**: MVC web application for frontend
- **SplashPage.Migrator**: Database migration tool
### Key Domain Areas
- **Splash**: Core WiFi analytics and user connection tracking
- **Meraki**: Integration with Cisco Meraki API for network data
- **Email**: Email template management and marketing campaigns with scheduled sending
- **Integrations**: Third-party service integrations (ZeroBounce email validation, SAML, etc.)
- **Perzonalization**: Captive portal customization and branding (note: intentional spelling in codebase)
- **Reports**: Analytics and reporting functionality with real-time metrics
- **QRCodes**: QR code generation for WiFi access
- **Storage**: MinIO integration for file storage
- **Authorization**: Role-based access control and permissions
### Database
- Uses Entity Framework Core with MySQL/PostgreSQL
- Multi-tenant architecture enabled
- Connection strings configured in appsettings.json files
### Next.js Frontend (SplashPage.Web.Ui)
- **Framework**: Next.js 14 with App Router
- **Styling**: Tailwind CSS + shadcn/ui components
- **State Management**: TanStack Query (React Query)
- **API Client**: Auto-generated from Swagger with Kubb
- **Forms**: React Hook Form + Zod validation
- **Location**: `src/SplashPage.Web.Ui/`
#### Key Next.js Routes
- `/dashboard` - Main admin dashboard
- `/dashboard/settings/captive-portal` - Portal management and configuration
- `/dashboard/settings/captive-portal/[id]` - Individual portal configuration page
- `/CaptivePortal/Portal/[id]` - **PUBLIC** captive portal display (no auth required)
#### Captive Portal System
The captive portal system has been migrated from the legacy MVC implementation to Next.js:
**Public Portal Routes** (No Dashboard Layout):
- `/CaptivePortal/Portal/[id]` - Production mode (default)
- Integrates with Cisco Meraki
- Real form submission and validation
- Redirects to internet via Meraki grant URL
- Accepts Meraki query parameters: `base_grant_url`, `gateway_id`, `client_ip`, `client_mac`, etc.
- `/CaptivePortal/Portal/[id]?mode=preview` - Preview mode
- Live preview for configuration testing
- Simulates validation without real submission
- Auto-refreshes configuration every 2 seconds
- Uses fake Meraki parameters in development
**Portal Types**:
1. **Normal Portal**: Standard WiFi portal with email, name, birthday fields
2. **SAML Portal**: Enterprise authentication via SAML/Okta
- Auto-redirect with configurable delay
- Manual login button option
- Customizable branding and messages
**Configuration System** (Admin Only):
- Located at `/dashboard/settings/captive-portal/[id]`
- Live preview in iframe
- Offcanvas configuration panel
- Real-time updates in preview mode
- Supports: logos, backgrounds, colors, field validation, terms & conditions, promotional videos
## Development Commands
### Building the Solution
```bash
# Build entire solution
dotnet build SplashPage.sln
# Build specific project
dotnet build src/SplashPage.Web.Mvc/SplashPage.Web.Mvc.csproj
# Build for production
dotnet build SplashPage.sln --configuration Release
```
### Running the Applications
```bash
# Run MVC web application (main frontend)
cd src/SplashPage.Web.Mvc
dotnet run
# Run Web API host (backend services)
cd src/SplashPage.Web.Host
dotnet run
# Run database migrator
cd src/SplashPage.Migrator
dotnet run
# Run benchmarks
cd SplashPage.Benchmarks
dotnet run --configuration Release
```
### Docker Commands
```bash
# Build and run with Docker Compose (from docker/mvc directory)
cd docker/mvc
docker-compose up --build
# Build Docker image manually
docker build -t splashpage-mvc .
# Run with Docker (production)
docker run -p 80:80 -e DB_CONNECTION_STRING="your_connection_string" splashpage-mvc
```
### Database Operations
```bash
# Run migrations
cd src/SplashPage.Migrator
dotnet run
# Add new migration
cd src/SplashPage.EntityFrameworkCore
dotnet ef migrations add MigrationName --startup-project ../SplashPage.Web.Host
# Update database
dotnet ef database update --startup-project ../SplashPage.Web.Host
```
## Configuration
### Environment Variables
- `SPLASH_CUSTOMER`: Customer identifier for multi-tenant setup
- `SPLASH_APP_NAME`: Application name identifier
- `SPLASH_SKIP_WORKER`: Skip background workers during development
- `DB_CONNECTION_STRING`: Override database connection string
- `SPLASH_SECRET`: Secret key for authentication
### Application URLs
- **MVC Web App**: https://localhost:44313/ (Development)
- **Web API Host**: https://localhost:44311/ (Development)
### Database Connection
Connection strings are configured in appsettings.json files. The application supports both MySQL and PostgreSQL databases with connection pooling configured.
## Key Business Logic
### Meraki Integration
- Background workers sync organization and network data from Cisco Meraki
- Real-time scanning data processing for user analytics
- API integration for network device management
### WiFi Analytics
- User connection tracking and analytics
- Real-time dashboard with widgets for metrics
- Historical reporting and data visualization
- Customer loyalty analysis and segmentation
### Email System
- Template-based email campaigns
- Scheduled email functionality
- Integration with third-party email validation services
- Marketing campaign management
### Multi-Tenancy
- Tenant-based data isolation
- Per-tenant configuration and customization
- Captive portal personalization per customer
## Important Notes
- The application uses ABP framework conventions for dependency injection and module system
- AutoMapper is used for DTO mappings between layers
- Background workers handle data synchronization tasks (MerakiSyncWorker, EmailProcessorService)
- The system supports multiple database providers through Entity Framework Core
- JWT authentication is implemented for API access
- Multi-language support is available through ABP localization
- OpenTelemetry and Splunk integration for monitoring and observability
- HangFire for background job processing
- Redis caching support available
- SAML authentication support via ITfoxtec.Identity.Saml2
- MinIO object storage integration
## Development Workflow
1. Make changes to domain entities in SplashPage.Core
2. Update corresponding DTOs in SplashPage.Application
3. Modify data access in SplashPage.EntityFrameworkCore if needed
4. Add/update controllers in SplashPage.Web.Mvc or SplashPage.Web.Host
5. Run migrations if database schema changes
6. Test both MVC and API endpoints
7. Run benchmarks for performance-critical changes
8. Update configurations in App_Data directories for customer-specific settings
## Testing
- The project includes a benchmarking project (SplashPage.Benchmarks)
- No traditional unit test project is present - consider adding xUnit or NUnit tests
- Use the benchmark project to measure performance of critical operations
- Integration testing can be performed using the Web.Host API endpoints
## Customer-Specific Configurations
The application supports multiple customer configurations stored in App_Data:
- `App_Data/Configuration/captive_portal_config.json` - Default configuration
- `App_Data/LC/Configuration/` - Little Caesars specific configs
- `App_Data/Sultanes/Configuration/` - Sultanes specific configs
- Each customer folder contains both development and production configs
## Change Tracking Requirements
**IMPORTANT**: Whenever you make changes to the codebase, you MUST:
1. Document all changes in the `changelog.MD` file located in the repository root
2. Include a clear description of what was changed and why
3. Reference the changelog from this CLAUDE.md file to maintain session context
4. Use the changelog to understand previous work when resuming sessions
The changelog serves as a bridge between sessions and provides essential context for ongoing development work. Always check the changelog before starting new work to understand the current state and recent modifications.

View File

@@ -5,6 +5,42 @@ Consulta este archivo al inicio de cada sesión para entender el contexto y prog
---
## 2025-10-22 - Fix: Color del texto en LostOpportunityWidget
### 🐛 Bug Fix: Texto de porcentaje no mostraba color primario del tema
**Problema Identificado**:
- El texto del porcentaje en el widget "Oportunidad Perdida" no mostraba el color primario del tema
- Uso incorrecto de `color: 'hsl(var(--primary))'` en ApexCharts (línea 133)
- La sintaxis correcta para ApexCharts es `'var(--variable)'` sin el wrapper `hsl()`
**Investigación**:
- Revisado el widget "Uso por Plataforma" (PlatformTypeWidget) que SÍ muestra colores correctamente
- Encontrado que `donutChartConfig` en `chartConfigs.ts` usa la sintaxis correcta:
- `color: 'var(--foreground)'` ✅ (líneas 225, 231)
- NO usa `'hsl(var(--variable))'` ❌
**Solución Implementada**:
Corregida la sintaxis en la configuración del chart (línea 133):
```typescript
// ❌ Incorrecto
color: 'hsl(var(--primary))'
// ✅ Correcto (igual que donutChartConfig)
color: 'var(--primary)'
```
**Archivo Modificado**:
- `src/SplashPage.Web.Ui/src/app/dashboard/dynamicDashboard/_components/widgets/charts/LostOpportunityWidget.tsx`
**Resultado**:
- El porcentaje ahora muestra correctamente el color primario del tema activo
- Compatible con todos los temas (light/dark + default/blue/green/amber/mono)
- Actualización dinámica al cambiar tema
- Solución simple sin código adicional
---
## 2025-10-22 - Reporte de Conexiones WiFi: Charts y Exportación (FASES 7-8) - MVP 95% COMPLETO
### 🎉 MILESTONE: MVP del Módulo de Reportes WiFi Alcanzado
@@ -6313,3 +6349,76 @@ Los 22 widgets están completamente funcionales. Los usuarios pueden:
---
---
## 2025-10-22 (Tarde) - Migración del Módulo de Reportes de MOCK a API Real con Kubb
### 🎯 Resumen
Se completó la migración del módulo de reportes de conexiones WiFi de datos MOCK a la API real utilizando los hooks generados por Kubb. Esto corrige el problema donde los KPI cards mostraban datos inconsistentes con la tabla debido a que no respetaban los filtros aplicados.
### 📝 Problema Identificado
- ✅ La tabla funcionaba perfectamente pero usaba datos MOCK
- ❌ Los KPI cards también usaban datos MOCK y NO respetaban los filtros (startDate, endDate, loyaltyType, connectionStatus)
- ❌ La función mockMetricsCall generaba valores aleatorios sin considerar los filtros aplicados
### 🔧 Cambios Realizados
#### 1. Migración de useConnectionReport.ts a Kubb
Archivo: src/SplashPage.Web.Ui/src/app/dashboard/reports/connection-report/_hooks/useConnectionReport.ts
- Eliminado el código mock y la función fetchConnectionReport manual
- Implementado hook de Kubb: useGetApiServicesAppSplashwificonnectionreportGetall
- Mapeo correcto de filtros a parámetros del API (PascalCase para ABP)
- Conversión de fechas string a objetos Date para la API
- Mantenida toda la lógica de paginación y prefetching
- Soporte completo para sorting, filtros de red, loyalty type, y connection status
#### 2. Reescritura completa de useReportMetrics.ts
Archivo: src/SplashPage.Web.Ui/src/app/dashboard/reports/connection-report/_hooks/useReportMetrics.ts
- Eliminado código mock (mockMetricsCall, generateMockTrendData)
- Implementado cálculo de métricas en el cliente usando datos reales de la API
- Doble llamada a la API: período actual y período anterior para trends
- Cálculo automático del período anterior basado en la duración del período actual
Métricas Calculadas:
- totalConnections: data.length
- uniqueUsers: new Set(data.map(x => x.email)).size
- avgDuration: Math.round(sum(durationMinutes) / count)
- newUsers: data.filter(x => x.loyaltyType === New).length
- Trends: comparación porcentual con período anterior
#### 3. Actualización de ConnectionReportClient.tsx
Archivo: src/SplashPage.Web.Ui/src/app/dashboard/reports/connection-report/_components/ConnectionReportClient.tsx
- Mejorado manejo de fechas para compatibilidad con tipos Date
- Actualizado label de trends: vs período anterior (más preciso)
### 🎯 Resultado Final
Antes:
- Los KPI cards mostraban datos aleatorios que no coincidían con la tabla
- Los filtros no afectaban las métricas mostradas
- Datos completamente MOCK
Después:
- Los KPI cards muestran datos consistentes con la tabla filtrada
- Todos los filtros afectan tanto tabla como métricas
- Datos 100% reales de la API usando hooks de Kubb
- Trends calculados automáticamente comparando con período anterior
- Cache inteligente de React Query (5 min stale, 10 min gc)
### 📚 Archivos Modificados
1. _hooks/useConnectionReport.ts - Reescrito completamente para usar Kubb
2. _hooks/useReportMetrics.ts - Reescrito completamente con cálculos cliente-side
3. _components/ConnectionReportClient.tsx - Actualizado manejo de fechas
### 📊 Endpoints Utilizados
- GET /api/services/app/SplashWifiConnectionReport/GetAll
- Para datos de tabla (paginados)
- Para métricas (todos los registros con MaxResultCount = int.MaxValue)
- Para período anterior (cálculo de trends)
Nota: No existe endpoint /GetMetrics dedicado, las métricas se calculan en el frontend.

View File

@@ -24,6 +24,7 @@ import { useReportFilters } from '../../_hooks/useReportFilters';
import { DateRangeFilter } from './filters/DateRangeFilter';
import { LoyaltyTypeFilter } from './filters/LoyaltyTypeFilter';
import { ConnectionStatusFilter } from './filters/ConnectionStatusFilter';
import { NetworkFilter } from './filters/NetworkFilter';
import { Separator } from '@/components/ui/separator';
interface FilterSheetProps {
@@ -54,6 +55,7 @@ export function FilterSheet({
setDateRange,
setLoyaltyFilter,
setStatusFilter,
setSelectedNetworks,
} = useReportFilters();
const handleReset = () => {
@@ -81,9 +83,9 @@ export function FilterSheet({
<SheetContent
side="right"
className="w-full sm:w-[400px] sm:max-w-[400px]"
className="w-full sm:w-[400px] sm:max-w-[400px] flex flex-col"
>
<SheetHeader>
<SheetHeader className="shrink-0">
<SheetTitle className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5" />
Filtros de Reporte
@@ -93,7 +95,7 @@ export function FilterSheet({
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-6">
<div className="mt-6 space-y-6 overflow-y-auto flex-1 pr-2">
{/* Date Range Filter */}
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -108,6 +110,17 @@ export function FilterSheet({
<Separator />
{/* Network Filter */}
<div className="space-y-3">
<label className="text-sm font-medium">Sucursales</label>
<NetworkFilter
value={filters.selectedNetworks}
onValueChange={setSelectedNetworks}
/>
</div>
<Separator />
{/* Loyalty Type Filter */}
<div className="space-y-3">
<label className="text-sm font-medium">Tipo de Lealtad</label>
@@ -128,11 +141,10 @@ export function FilterSheet({
/>
</div>
{/* TODO: Add Network Filter (requires API data) */}
{/* TODO: Add Access Point Filter (requires API data) */}
</div>
<SheetFooter className="absolute bottom-0 left-0 right-0 border-t bg-background p-6">
<SheetFooter className="shrink-0 border-t bg-background p-6 mt-4">
<div className="flex w-full gap-2">
<Button
variant="outline"

View File

@@ -0,0 +1,229 @@
/**
* NetworkFilter Component
*
* Multi-select filter for selecting network(s) / branches / sucursales.
* Uses Command + Popover + Checkbox pattern for multi-selection.
*/
'use client';
import React, { useState, useMemo } from 'react';
import { Check, ChevronsUpDown, Building2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { useGetApiServicesAppSplashdashboardserviceGetnetworksforselector } from '@/api/hooks/useGetApiServicesAppSplashdashboardserviceGetnetworksforselector';
interface NetworkFilterProps {
value?: number[];
onValueChange: (value: number[] | null) => void;
}
export function NetworkFilter({ value = [], onValueChange }: NetworkFilterProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// Fetch networks from API
const { data: networks, isLoading, isError } = useGetApiServicesAppSplashdashboardserviceGetnetworksforselector({
query: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
},
});
// Filter networks by search query
const filteredNetworks = useMemo(() => {
if (!networks) return [];
if (!searchQuery) return networks;
const query = searchQuery.toLowerCase();
return networks.filter((network) =>
network.name?.toLowerCase().includes(query) ||
network.groupName?.toLowerCase().includes(query)
);
}, [networks, searchQuery]);
// Group networks by groupName (optional)
const groupedNetworks = useMemo(() => {
const grouped: Record<string, typeof networks> = {};
filteredNetworks.forEach((network) => {
const groupKey = network.groupName || 'Sin grupo';
if (!grouped[groupKey]) {
grouped[groupKey] = [];
}
grouped[groupKey].push(network);
});
return grouped;
}, [filteredNetworks]);
// Handle network selection toggle
const handleToggle = (networkId: number) => {
const currentSelection = value || [];
const isSelected = currentSelection.includes(networkId);
let newSelection: number[];
if (isSelected) {
// Remove from selection
newSelection = currentSelection.filter((id) => id !== networkId);
} else {
// Add to selection
newSelection = [...currentSelection, networkId];
}
onValueChange(newSelection.length > 0 ? newSelection : null);
};
// Handle clear all
const handleClear = () => {
onValueChange(null);
setOpen(false);
};
// Get selected networks for display
const selectedNetworks = useMemo(() => {
if (!networks || !value || value.length === 0) return [];
return networks.filter((network) => value.includes(network.id!));
}, [networks, value]);
const selectedCount = selectedNetworks.length;
const hasSelection = selectedCount > 0;
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 shrink-0 opacity-50" />
{hasSelection ? (
<span className="truncate">
{selectedCount === 1
? selectedNetworks[0].name
: `${selectedCount} sucursales seleccionadas`}
</span>
) : (
<span className="text-muted-foreground">Todas las sucursales</span>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Buscar sucursal..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
{isLoading && (
<CommandEmpty>Cargando sucursales...</CommandEmpty>
)}
{isError && (
<CommandEmpty>Error al cargar sucursales</CommandEmpty>
)}
{!isLoading && !isError && filteredNetworks.length === 0 && (
<CommandEmpty>No se encontraron sucursales</CommandEmpty>
)}
{!isLoading && !isError && Object.keys(groupedNetworks).map((groupName) => {
const groupNetworks = groupedNetworks[groupName];
return (
<CommandGroup key={groupName} heading={groupName}>
{groupNetworks.map((network) => {
const isSelected = value?.includes(network.id!) || false;
return (
<CommandItem
key={network.id}
value={`${network.id}`}
onSelect={() => handleToggle(network.id!)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggle(network.id!)}
className="mr-2"
/>
<div className="flex flex-col flex-1">
<span className="text-sm font-medium">
{network.name || 'Sin nombre'}
</span>
{network.groupName && (
<span className="text-xs text-muted-foreground">
{network.groupName}
</span>
)}
</div>
<Check
className={cn(
'ml-auto h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
);
})}
</CommandGroup>
);
})}
</CommandList>
</Command>
{/* Footer with action buttons */}
{hasSelection && (
<div className="border-t p-2">
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="w-full gap-2 text-xs"
>
<X className="h-3 w-3" />
Limpiar selección
</Button>
</div>
)}
</PopoverContent>
</Popover>
{/* Selected networks badges */}
{hasSelection && selectedNetworks.length <= 3 && (
<div className="flex flex-wrap gap-1">
{selectedNetworks.map((network) => (
<Badge
key={network.id}
variant="secondary"
className="text-xs"
>
{network.name}
</Badge>
))}
</div>
)}
</div>
);
}

View File

@@ -65,6 +65,11 @@ export function ConnectionTable({
}: ConnectionTableProps) {
const [sorting, setSorting] = React.useState<SortingState>([]);
// Filter out hidden columns
const visibleColumns = useMemo(() => {
return columns.filter((col) => !(col.meta as any)?.hidden);
}, []);
// Calculate pagination metadata
const totalPages = Math.ceil(totalCount / pageSize);
const hasNextPage = currentPage < totalPages - 1;
@@ -72,7 +77,7 @@ export function ConnectionTable({
const table = useReactTable({
data,
columns,
columns: visibleColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),

View File

@@ -23,6 +23,7 @@ import {
formatDateTime,
formatDuration,
getInitials,
getAvatarColorFromEmail,
} from '../../_lib/reportUtils';
import {
getLoyaltyColor,
@@ -68,7 +69,7 @@ export const columns: ColumnDef<ConnectionReportRow>[] = [
const date = row.getValue('connectionDateTime') as string;
return (
<div className="flex flex-col">
<span className="text-sm font-medium">
<span className="text-xs font-medium">
{formatDateTime(date)}
</span>
</div>
@@ -82,15 +83,33 @@ export const columns: ColumnDef<ConnectionReportRow>[] = [
cell: ({ row }) => {
const name = row.getValue('userName') as string | undefined;
const email = row.original.email;
const status = row.original.connectionStatus;
const initials = getInitials(name || email);
// Color del avatar basado en email (consistent color)
const avatarColor = getAvatarColorFromEmail(email);
// Color del indicador de estado
const statusDotColor = status === 'Connected'
? 'bg-green-500'
: 'bg-gray-400';
return (
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{initials}
</AvatarFallback>
</Avatar>
{/* Avatar con iniciales coloridas */}
<div className="relative">
<Avatar className={`h-8 w-8 ${avatarColor}`}>
<AvatarFallback className={avatarColor}>
{initials}
</AvatarFallback>
</Avatar>
{/* Indicador de estado (punto) */}
<span
className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-background ${statusDotColor}`}
title={status === 'Connected' ? 'Conectado' : 'Desconectado'}
/>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">
{name || 'Usuario anónimo'}
@@ -217,6 +236,11 @@ export const columns: ColumnDef<ConnectionReportRow>[] = [
{
accessorKey: 'connectionStatus',
header: 'Estado',
enableHiding: false,
size: 0,
meta: {
hidden: true,
},
cell: ({ row }) => {
const status = row.getValue('connectionStatus') as ConnectionStatusValue | undefined;
if (!status) return <span className="text-muted-foreground">-</span>;

View File

@@ -0,0 +1,290 @@
/**
* Connection Report Table Columns
*
* Column definitions for TanStack Table with formatters and custom cells.
*/
'use client';
import { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowUpDown, MoreHorizontal, User, Wifi } from 'lucide-react';
import {
formatDateTime,
formatDuration,
getInitials,
} from '../../_lib/reportUtils';
import {
getLoyaltyColor,
getLoyaltyLabel,
getStatusColor,
getStatusLabel,
} from '../../_lib/reportConstants';
import type { LoyaltyTypeValue, ConnectionStatusValue } from '../../_types/report.types';
// Type matching the API response (will be replaced after Kubb generation)
export interface ConnectionReportRow {
id: number;
connectionDateTime: string;
userName?: string;
email?: string;
loyaltyType?: LoyaltyTypeValue;
daysInactive: number;
networkName?: string;
accessPoint?: string;
durationMinutes: number;
connectionStatus?: ConnectionStatusValue;
platform?: string;
browser?: string;
userId: number;
}
export const columns: ColumnDef<ConnectionReportRow>[] = [
{
accessorKey: 'connectionDateTime',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="h-8 px-2 hover:bg-muted"
>
Fecha y Hora
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = row.getValue('connectionDateTime') as string;
return (
<div className="flex flex-col">
<span className="text-sm font-medium">
{formatDateTime(date)}
</span>
</div>
);
},
enableSorting: true,
},
{
accessorKey: 'userName',
header: 'Usuario',
cell: ({ row }) => {
const name = row.getValue('userName') as string | undefined;
const email = row.original.email;
const initials = getInitials(name || email);
return (
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{initials}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{name || 'Usuario anónimo'}
</span>
{email && (
<span className="text-xs text-muted-foreground">{email}</span>
)}
</div>
</div>
);
},
},
{
accessorKey: 'loyaltyType',
header: 'Tipo de Lealtad',
cell: ({ row }) => {
const loyaltyType = row.getValue('loyaltyType') as LoyaltyTypeValue | undefined;
if (!loyaltyType) return <span className="text-muted-foreground">-</span>;
const color = getLoyaltyColor(loyaltyType);
const label = getLoyaltyLabel(loyaltyType);
return (
<Badge
variant="outline"
className="font-medium"
style={{
backgroundColor: `${color}15`,
color: color,
borderColor: `${color}30`,
}}
>
{label}
</Badge>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
accessorKey: 'daysInactive',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="h-8 px-2 hover:bg-muted"
>
Días Inactivos
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const days = row.getValue('daysInactive') as number;
return (
<div className="text-sm font-medium">
{days === 0 ? (
<span className="text-muted-foreground">Hoy</span>
) : days === 1 ? (
<span>1 día</span>
) : (
<span>{days} días</span>
)}
</div>
);
},
enableSorting: true,
},
{
accessorKey: 'networkName',
header: 'Red',
cell: ({ row }) => {
const network = row.getValue('networkName') as string | undefined;
return (
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-blue-500/10">
<Wifi className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<span className="text-sm font-medium">
{network || 'Desconocida'}
</span>
</div>
);
},
},
{
accessorKey: 'accessPoint',
header: 'Punto de Acceso',
cell: ({ row }) => {
const ap = row.getValue('accessPoint') as string | undefined;
return (
<span className="text-sm text-muted-foreground">
{ap || '-'}
</span>
);
},
},
{
accessorKey: 'durationMinutes',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="h-8 px-2 hover:bg-muted"
>
Duración
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const duration = row.getValue('durationMinutes') as number;
return (
<span className="text-sm font-medium">
{formatDuration(duration)}
</span>
);
},
enableSorting: true,
},
{
accessorKey: 'connectionStatus',
header: 'Estado',
cell: ({ row }) => {
const status = row.getValue('connectionStatus') as ConnectionStatusValue | undefined;
if (!status) return <span className="text-muted-foreground">-</span>;
const color = getStatusColor(status);
const label = getStatusLabel(status);
return (
<Badge
variant="outline"
className="font-medium"
style={{
backgroundColor: `${color}15`,
color: color,
borderColor: `${color}30`,
}}
>
<span
className="mr-2 inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
/>
{label}
</Badge>
);
},
},
{
id: 'actions',
cell: ({ row }) => {
const connection = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Abrir menú</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
// TODO: Navigate to user detail page
console.log('Ver detalles:', connection.userId);
}}
>
<User className="mr-2 h-4 w-4" />
Ver detalles de usuario
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(connection.email || '');
}}
>
Copiar email
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(String(connection.id));
}}
>
Copiar ID de conexión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];

View File

@@ -2,116 +2,16 @@
* useConnectionReport Hook
*
* Custom hook for fetching and managing WiFi connection report data.
* Wraps the auto-generated React Query hook with additional transformations and caching logic.
*
* NOTE: Currently using mock data. To connect to real API:
* 1. Run `pnpm generate:api` to generate hooks from Swagger
* 2. Uncomment the import statement below
* 3. Replace mockApiCall with the generated hook
* Uses Kubb-generated React Query hooks with additional pagination logic.
*/
'use client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import type { ReportFilters } from '../_types/report.types';
import { mockApiCall } from '../_lib/mockData';
// TODO: Uncomment after running `pnpm generate:api`
// import { useSplashWifiConnectionReportGetAll } from '@/api/hooks';
// NOTE: These will be auto-generated by Kubb after running `pnpm generate:api`
interface SplashWifiConnectionReportDto {
id: number;
connectionDateTime: string;
userName?: string;
email?: string;
loyaltyType?: string;
daysInactive: number;
networkName?: string;
accessPoint?: string;
durationMinutes: number;
connectionStatus?: string;
platform?: string;
browser?: string;
userId: number;
}
interface PagedResultDto<T> {
items: T[];
totalCount: number;
}
/**
* Fetch connection report data
*
* DEVELOPMENT MODE: Using mock data
* PRODUCTION MODE: Replace with real API call
*/
async function fetchConnectionReport(
filters: ReportFilters
): Promise<PagedResultDto<SplashWifiConnectionReportDto>> {
// ===========================================================================
// DEVELOPMENT: Mock data (REMOVE THIS IN PRODUCTION)
// ===========================================================================
const USE_MOCK_DATA = true; // Set to false to use real API
if (USE_MOCK_DATA) {
const response = await mockApiCall(
filters.startDate,
filters.endDate,
filters.skipCount || 0,
filters.maxResultCount || 50,
filters.loyaltyType,
filters.connectionStatus
);
return response.result;
}
// ===========================================================================
// PRODUCTION: Real API call
// ===========================================================================
// TODO: Replace with Kubb-generated hook
// Example:
// const { data } = useSplashWifiConnectionReportGetAll({
// params: {
// startDate: filters.startDate,
// endDate: filters.endDate,
// skipCount: filters.skipCount,
// maxResultCount: filters.maxResultCount,
// loyaltyType: filters.loyaltyType,
// connectionStatus: filters.connectionStatus,
// sorting: filters.sorting,
// }
// });
// return data.result;
const params = new URLSearchParams();
if (filters.startDate) params.set('startDate', filters.startDate);
if (filters.endDate) params.set('endDate', filters.endDate);
if (filters.skipCount !== undefined) params.set('skipCount', String(filters.skipCount));
if (filters.maxResultCount) params.set('maxResultCount', String(filters.maxResultCount));
if (filters.sorting) params.set('sorting', filters.sorting);
if (filters.loyaltyType) params.set('loyaltyType', filters.loyaltyType);
if (filters.connectionStatus) params.set('connectionStatus', filters.connectionStatus);
if (filters.networkName) params.set('networkName', filters.networkName);
const response = await fetch(
`http://localhost:3001/api/services/app/SplashWifiConnectionReport/GetAll?${params.toString()}`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch connection report');
}
const data = await response.json();
return data.result || { items: [], totalCount: 0 };
}
import { useGetApiServicesAppSplashwificonnectionreportGetall } from '@/api/hooks/useGetApiServicesAppSplashwificonnectionreportGetall';
import type { SplashWifiConnectionReportDto } from '@/api/types/SplashWifiConnectionReportDto';
/**
* Hook for fetching connection report data with React Query
@@ -119,29 +19,42 @@ async function fetchConnectionReport(
export function useConnectionReport(filters: ReportFilters) {
const queryClient = useQueryClient();
// Build query key for caching
const queryKey = useMemo(
() => ['connectionReport', filters],
[filters]
// Convert filter dates to Date objects if they're strings
const apiParams = useMemo(() => ({
StartDate: filters.startDate ? new Date(filters.startDate) : undefined,
EndDate: filters.endDate ? new Date(filters.endDate) : undefined,
NetworkName: filters.networkName,
SelectedNetworks: filters.selectedNetworks,
ConnectionStatus: filters.connectionStatus,
LoyaltyType: filters.loyaltyType,
SkipCount: filters.skipCount ?? 0,
MaxResultCount: filters.maxResultCount ?? 50,
Sorting: filters.sorting ?? 'ConnectionDateTime DESC',
}), [filters]);
// Use Kubb-generated hook
const query = useGetApiServicesAppSplashwificonnectionreportGetall(
apiParams,
{
query: {
// Enable query only if required filters are present
enabled: !!(filters.startDate && filters.endDate),
// Caching strategy
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
// Retry configuration
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
}
);
// Fetch connection report data
const query = useQuery({
queryKey,
queryFn: () => fetchConnectionReport(filters),
// Caching strategy
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
// Enable query only if required filters are present
enabled: !!(filters.startDate && filters.endDate),
// Retry configuration
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Extract data from ABP response structure
const items = query.data?.items ?? [];
const totalCount = query.data?.totalCount ?? 0;
// Calculate pagination metadata
const paginationMeta = useMemo(() => {
const totalCount = query.data?.totalCount ?? 0;
const pageSize = filters.maxResultCount ?? 50;
const currentPage = Math.floor((filters.skipCount ?? 0) / pageSize);
const totalPages = Math.ceil(totalCount / pageSize);
@@ -158,30 +71,29 @@ export function useConnectionReport(filters: ReportFilters) {
startIndex: (filters.skipCount ?? 0) + 1,
endIndex: Math.min((filters.skipCount ?? 0) + pageSize, totalCount),
};
}, [query.data?.totalCount, filters.maxResultCount, filters.skipCount]);
}, [totalCount, filters.maxResultCount, filters.skipCount]);
// Prefetch next page for better UX
const prefetchNextPage = useMemo(() => {
return () => {
if (paginationMeta.hasNextPage) {
const nextFilters = {
...filters,
skipCount: (filters.skipCount ?? 0) + (filters.maxResultCount ?? 50),
const nextParams = {
...apiParams,
SkipCount: (filters.skipCount ?? 0) + (filters.maxResultCount ?? 50),
};
queryClient.prefetchQuery({
queryKey: ['connectionReport', nextFilters],
queryFn: () => fetchConnectionReport(nextFilters),
staleTime: 5 * 60 * 1000,
});
// Note: Prefetching with Kubb hooks is more complex
// For now, we'll skip prefetching as it requires direct queryClient access
// This can be improved if needed
console.log('Next page prefetch requested for:', nextParams);
}
};
}, [filters, paginationMeta.hasNextPage, queryClient]);
}, [apiParams, filters.skipCount, filters.maxResultCount, paginationMeta.hasNextPage]);
return {
// Query state
data: query.data?.items ?? [],
totalCount: query.data?.totalCount ?? 0,
data: items as SplashWifiConnectionReportDto[],
totalCount,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,

View File

@@ -0,0 +1,197 @@
/**
* useConnectionReport Hook
*
* Custom hook for fetching and managing WiFi connection report data.
* Wraps the auto-generated React Query hook with additional transformations and caching logic.
*
* NOTE: Currently using mock data. To connect to real API:
* 1. Run `pnpm generate:api` to generate hooks from Swagger
* 2. Uncomment the import statement below
* 3. Replace mockApiCall with the generated hook
*/
'use client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import type { ReportFilters } from '../_types/report.types';
import { mockApiCall } from '../_lib/mockData';
// TODO: Uncomment after running `pnpm generate:api`
// import { useSplashWifiConnectionReportGetAll } from '@/api/hooks';
// NOTE: These will be auto-generated by Kubb after running `pnpm generate:api`
interface SplashWifiConnectionReportDto {
id: number;
connectionDateTime: string;
userName?: string;
email?: string;
loyaltyType?: string;
daysInactive: number;
networkName?: string;
accessPoint?: string;
durationMinutes: number;
connectionStatus?: string;
platform?: string;
browser?: string;
userId: number;
}
interface PagedResultDto<T> {
items: T[];
totalCount: number;
}
/**
* Fetch connection report data
*
* DEVELOPMENT MODE: Using mock data
* PRODUCTION MODE: Replace with real API call
*/
async function fetchConnectionReport(
filters: ReportFilters
): Promise<PagedResultDto<SplashWifiConnectionReportDto>> {
// ===========================================================================
// DEVELOPMENT: Mock data (REMOVE THIS IN PRODUCTION)
// ===========================================================================
const USE_MOCK_DATA = true; // Set to false to use real API
if (USE_MOCK_DATA) {
const response = await mockApiCall(
filters.startDate,
filters.endDate,
filters.skipCount || 0,
filters.maxResultCount || 50,
filters.loyaltyType,
filters.connectionStatus
);
return response.result;
}
// ===========================================================================
// PRODUCTION: Real API call
// ===========================================================================
// TODO: Replace with Kubb-generated hook
// Example:
// const { data } = useSplashWifiConnectionReportGetAll({
// params: {
// startDate: filters.startDate,
// endDate: filters.endDate,
// skipCount: filters.skipCount,
// maxResultCount: filters.maxResultCount,
// loyaltyType: filters.loyaltyType,
// connectionStatus: filters.connectionStatus,
// sorting: filters.sorting,
// }
// });
// return data.result;
const params = new URLSearchParams();
if (filters.startDate) params.set('startDate', filters.startDate);
if (filters.endDate) params.set('endDate', filters.endDate);
if (filters.skipCount !== undefined) params.set('skipCount', String(filters.skipCount));
if (filters.maxResultCount) params.set('maxResultCount', String(filters.maxResultCount));
if (filters.sorting) params.set('sorting', filters.sorting);
if (filters.loyaltyType) params.set('loyaltyType', filters.loyaltyType);
if (filters.connectionStatus) params.set('connectionStatus', filters.connectionStatus);
if (filters.networkName) params.set('networkName', filters.networkName);
const response = await fetch(
`http://localhost:3001/api/services/app/SplashWifiConnectionReport/GetAll?${params.toString()}`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch connection report');
}
const data = await response.json();
return data.result || { items: [], totalCount: 0 };
}
/**
* Hook for fetching connection report data with React Query
*/
export function useConnectionReport(filters: ReportFilters) {
const queryClient = useQueryClient();
// Build query key for caching
const queryKey = useMemo(
() => ['connectionReport', filters],
[filters]
);
// Fetch connection report data
const query = useQuery({
queryKey,
queryFn: () => fetchConnectionReport(filters),
// Caching strategy
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
// Enable query only if required filters are present
enabled: !!(filters.startDate && filters.endDate),
// Retry configuration
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Calculate pagination metadata
const paginationMeta = useMemo(() => {
const totalCount = query.data?.totalCount ?? 0;
const pageSize = filters.maxResultCount ?? 50;
const currentPage = Math.floor((filters.skipCount ?? 0) / pageSize);
const totalPages = Math.ceil(totalCount / pageSize);
const hasNextPage = currentPage < totalPages - 1;
const hasPreviousPage = currentPage > 0;
return {
totalCount,
pageSize,
currentPage,
totalPages,
hasNextPage,
hasPreviousPage,
startIndex: (filters.skipCount ?? 0) + 1,
endIndex: Math.min((filters.skipCount ?? 0) + pageSize, totalCount),
};
}, [query.data?.totalCount, filters.maxResultCount, filters.skipCount]);
// Prefetch next page for better UX
const prefetchNextPage = useMemo(() => {
return () => {
if (paginationMeta.hasNextPage) {
const nextFilters = {
...filters,
skipCount: (filters.skipCount ?? 0) + (filters.maxResultCount ?? 50),
};
queryClient.prefetchQuery({
queryKey: ['connectionReport', nextFilters],
queryFn: () => fetchConnectionReport(nextFilters),
staleTime: 5 * 60 * 1000,
});
}
};
}, [filters, paginationMeta.hasNextPage, queryClient]);
return {
// Query state
data: query.data?.items ?? [],
totalCount: query.data?.totalCount ?? 0,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
// Pagination
paginationMeta,
prefetchNextPage,
// Query actions
refetch: query.refetch,
};
}

View File

@@ -35,6 +35,7 @@ export function useReportFilters() {
// User type filters
connectionStatus: parseAsString as any, // Cast to handle custom enum
loyaltyType: parseAsString as any, // Loyalty type filter
// Pagination
skipCount: parseAsInteger.withDefault(DEFAULT_FILTERS.skipCount),
@@ -57,6 +58,7 @@ export function useReportFilters() {
if (filters.networkName) count++;
if (filters.connectionStatus) count++;
if (filters.loyaltyType) count++;
if (filters.selectedNetworks && filters.selectedNetworks.length > 0) count++;
return count;
@@ -73,6 +75,7 @@ export function useReportFilters() {
networkName: null,
selectedNetworks: null,
connectionStatus: null,
loyaltyType: null,
skipCount: DEFAULT_FILTERS.skipCount,
maxResultCount: DEFAULT_FILTERS.maxResultCount,
sorting: DEFAULT_FILTERS.sorting,
@@ -107,7 +110,16 @@ export function useReportFilters() {
[setFilters]
);
// Set loyalty type filter
const setLoyaltyFilter = useCallback(
(loyalty: LoyaltyTypeValue | null) => {
setFilters({
loyaltyType: loyalty as any,
skipCount: 0,
});
},
[setFilters]
);
// Set connection status filter
const setStatusFilter = useCallback(
@@ -181,6 +193,7 @@ export function useReportFilters() {
networkName: filters.networkName ?? undefined,
selectedNetworks: filters.selectedNetworks ?? undefined,
connectionStatus: filters.connectionStatus as ConnectionStatusValue | undefined,
loyaltyType: filters.loyaltyType as LoyaltyTypeValue | undefined,
skipCount: filters.skipCount ?? DEFAULT_FILTERS.skipCount,
maxResultCount: filters.maxResultCount ?? DEFAULT_FILTERS.maxResultCount,
sorting: filters.sorting ?? DEFAULT_FILTERS.sorting,
@@ -202,6 +215,7 @@ export function useReportFilters() {
resetPagination,
setDateRange,
setNetworkFilter,
setLoyaltyFilter,
setStatusFilter,
setSelectedNetworks,

View File

@@ -1,22 +1,18 @@
/**
* useReportMetrics Hook
*
* Custom hook for fetching and calculating aggregated metrics for the connection report.
* Provides KPI data with trend calculations for the dashboard.
*
* NOTE: Currently using mock data. To connect to real API:
* 1. Update USE_MOCK_DATA flag or
* 2. Replace with Kubb-generated metrics endpoint hook
* Custom hook for calculating aggregated metrics for the connection report.
* Fetches all data from the API (with filters) and calculates KPI metrics client-side.
* Also fetches previous period data for trend calculations.
*/
'use client';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import type { ReportFilters } from '../_types/report.types';
import { mockMetricsCall } from '../_lib/mockData';
import { useGetApiServicesAppSplashwificonnectionreportGetall } from '@/api/hooks/useGetApiServicesAppSplashwificonnectionreportGetall';
import type { SplashWifiConnectionReportDto } from '@/api/types/SplashWifiConnectionReportDto';
// NOTE: These interfaces will be auto-generated by Kubb after running `pnpm generate:api`
interface MetricsDto {
totalConnections: number;
uniqueUsers: number;
@@ -40,170 +36,188 @@ interface LoyaltyDistribution {
percentage: number;
}
interface MetricsResponse {
metrics: MetricsDto;
trendData?: ChartDataPoint[];
loyaltyDistribution?: LoyaltyDistribution[];
/**
* Calculate the date range for the previous period
*/
function calculatePreviousPeriod(startDate?: string, endDate?: string) {
if (!startDate || !endDate) {
return { previousStart: undefined, previousEnd: undefined };
}
const start = new Date(startDate);
const end = new Date(endDate);
// Calculate the duration in milliseconds
const duration = end.getTime() - start.getTime();
// Previous period ends 1 day before current period starts
const previousEnd = new Date(start);
previousEnd.setDate(previousEnd.getDate() - 1);
// Previous period starts the same duration before
const previousStart = new Date(previousEnd.getTime() - duration);
return {
previousStart: previousStart.toISOString().split('T')[0],
previousEnd: previousEnd.toISOString().split('T')[0],
};
}
/**
* Fetch aggregated metrics
*
* DEVELOPMENT MODE: Using mock data
* PRODUCTION MODE: Replace with real API call
* Calculate metrics from raw data
*/
async function fetchReportMetrics(
filters: ReportFilters
): Promise<MetricsResponse> {
// ===========================================================================
// DEVELOPMENT: Mock data (REMOVE THIS IN PRODUCTION)
// ===========================================================================
const USE_MOCK_DATA = true; // Set to false to use real API
function calculateMetrics(data: SplashWifiConnectionReportDto[]): MetricsDto {
const totalConnections = data.length;
if (USE_MOCK_DATA) {
const metricsData = await mockMetricsCall(filters.startDate, filters.endDate);
// Count unique users by email
const uniqueEmails = new Set(
data
.filter(item => item.email)
.map(item => item.email?.toLowerCase())
);
const uniqueUsers = uniqueEmails.size;
// Generate mock trend data for chart
const trendData: ChartDataPoint[] = generateMockTrendData(
filters.startDate,
filters.endDate
);
// Calculate average duration
const totalDuration = data.reduce((sum, item) => sum + (item.durationMinutes ?? 0), 0);
const avgDuration = totalConnections > 0 ? Math.round(totalDuration / totalConnections) : 0;
// Generate mock loyalty distribution
const loyaltyDistribution: LoyaltyDistribution[] = [
{ loyaltyType: 'New', count: 450, percentage: 30 },
{ loyaltyType: 'Recurrent', count: 600, percentage: 40 },
{ loyaltyType: 'Loyal', count: 300, percentage: 20 },
{ loyaltyType: 'Recuperado', count: 150, percentage: 10 },
];
// Count new users (loyaltyType === 'New')
const newUsers = data.filter(item => item.loyaltyType === 'New').length;
return {
metrics: metricsData,
trendData,
loyaltyDistribution,
};
}
return {
totalConnections,
uniqueUsers,
avgDuration,
newUsers,
};
}
// ===========================================================================
// PRODUCTION: Real API call
// ===========================================================================
// TODO: Replace with Kubb-generated hook or endpoint
// Example:
// const { data } = useSplashWifiConnectionReportGetMetrics({
// params: {
// startDate: filters.startDate,
// endDate: filters.endDate,
// loyaltyType: filters.loyaltyType,
// connectionStatus: filters.connectionStatus,
// }
// });
// return data.result;
/**
* Calculate trend percentage
*/
function calculateTrend(current: number, previous: number): number {
if (previous === 0) return current > 0 ? 100 : 0;
return Math.round(((current - previous) / previous) * 100);
}
const params = new URLSearchParams();
if (filters.startDate) params.set('startDate', filters.startDate);
if (filters.endDate) params.set('endDate', filters.endDate);
if (filters.loyaltyType) params.set('loyaltyType', filters.loyaltyType);
if (filters.connectionStatus) params.set('connectionStatus', filters.connectionStatus);
/**
* Group data by date for trend chart
*/
function groupByDate(data: SplashWifiConnectionReportDto[]): ChartDataPoint[] {
const dateMap = new Map<string, number>();
const response = await fetch(
`http://localhost:3001/api/services/app/SplashWifiConnectionReport/GetMetrics?${params.toString()}`,
data.forEach(item => {
if (item.connectionDateTime) {
const date = new Date(item.connectionDateTime).toISOString().split('T')[0];
dateMap.set(date, (dateMap.get(date) ?? 0) + 1);
}
});
return Array.from(dateMap.entries())
.map(([date, value]) => ({ date, value }))
.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Calculate loyalty distribution
*/
function calculateLoyaltyDistribution(data: SplashWifiConnectionReportDto[]): LoyaltyDistribution[] {
const loyaltyMap = new Map<string, number>();
data.forEach(item => {
const loyalty = item.loyaltyType || 'Unknown';
loyaltyMap.set(loyalty, (loyaltyMap.get(loyalty) ?? 0) + 1);
});
const total = data.length;
return Array.from(loyaltyMap.entries())
.map(([loyaltyType, count]) => ({
loyaltyType,
count,
percentage: total > 0 ? Math.round((count / total) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
}
/**
* Hook for fetching and calculating report metrics
*/
export function useReportMetrics(filters: ReportFilters) {
// Fetch current period data (all records for metrics calculation)
const currentPeriodParams = useMemo(() => ({
StartDate: filters.startDate ? new Date(filters.startDate) : undefined,
EndDate: filters.endDate ? new Date(filters.endDate) : undefined,
NetworkName: filters.networkName,
SelectedNetworks: filters.selectedNetworks,
ConnectionStatus: filters.connectionStatus,
LoyaltyType: filters.loyaltyType,
SkipCount: 0,
MaxResultCount: 2147483647, // int.MaxValue to get all records
}), [filters]);
const currentQuery = useGetApiServicesAppSplashwificonnectionreportGetall(
currentPeriodParams,
{
headers: {
'Content-Type': 'application/json',
query: {
enabled: !!(filters.startDate && filters.endDate),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 2,
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch metrics');
}
const data = await response.json();
return data.result || { metrics: {}, trendData: [], loyaltyDistribution: [] };
}
/**
* Generate mock trend data for development
*/
function generateMockTrendData(
startDate?: string,
endDate?: string
): ChartDataPoint[] {
const start = startDate ? new Date(startDate) : new Date();
const end = endDate ? new Date(endDate) : new Date();
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
const points: ChartDataPoint[] = [];
for (let i = 0; i <= daysDiff; i++) {
const date = new Date(start);
date.setDate(date.getDate() + i);
// Generate random values with some trend
const baseValue = 100;
const trend = i * 2; // Slight upward trend
const randomness = Math.random() * 40 - 20; // +/- 20
const value = Math.max(0, Math.round(baseValue + trend + randomness));
points.push({
date: date.toISOString().split('T')[0], // YYYY-MM-DD format
value,
});
}
return points;
}
/**
* Hook for fetching report metrics with React Query
*/
export function useReportMetrics(filters: ReportFilters) {
// Build query key for caching
const queryKey = useMemo(
() => ['reportMetrics', filters.startDate, filters.endDate, filters.loyaltyType, filters.connectionStatus],
[filters.startDate, filters.endDate, filters.loyaltyType, filters.connectionStatus]
// Calculate previous period dates
const { previousStart, previousEnd } = useMemo(
() => calculatePreviousPeriod(filters.startDate, filters.endDate),
[filters.startDate, filters.endDate]
);
// Fetch metrics data
const query = useQuery({
queryKey,
queryFn: () => fetchReportMetrics(filters),
// Caching strategy
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
// Enable query only if required filters are present
enabled: !!(filters.startDate && filters.endDate),
// Retry configuration
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Fetch previous period data for trend calculation
const previousPeriodParams = useMemo(() => ({
StartDate: previousStart ? new Date(previousStart) : undefined,
EndDate: previousEnd ? new Date(previousEnd) : undefined,
NetworkName: filters.networkName,
SelectedNetworks: filters.selectedNetworks,
ConnectionStatus: filters.connectionStatus,
LoyaltyType: filters.loyaltyType,
SkipCount: 0,
MaxResultCount: 2147483647,
}), [previousStart, previousEnd, filters.networkName, filters.selectedNetworks, filters.connectionStatus, filters.loyaltyType]);
// Transform metrics for easier consumption
const metrics = useMemo(() => {
if (!query.data?.metrics) {
return {
totalConnections: 0,
uniqueUsers: 0,
avgDuration: 0,
newUsers: 0,
totalConnectionsTrend: 0,
uniqueUsersTrend: 0,
avgDurationTrend: 0,
newUsersTrend: 0,
};
const previousQuery = useGetApiServicesAppSplashwificonnectionreportGetall(
previousPeriodParams,
{
query: {
enabled: !!(previousStart && previousEnd && filters.startDate && filters.endDate),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 2,
},
}
return query.data.metrics;
}, [query.data?.metrics]);
);
// Transform trend data for charts
const trendData = useMemo(() => {
return query.data?.trendData ?? [];
}, [query.data?.trendData]);
// Calculate current period metrics
const currentData = currentQuery.data?.items ?? [];
const currentMetrics = useMemo(() => calculateMetrics(currentData), [currentData]);
// Transform loyalty distribution for pie/donut chart
const loyaltyDistribution = useMemo(() => {
return query.data?.loyaltyDistribution ?? [];
}, [query.data?.loyaltyDistribution]);
// Calculate previous period metrics
const previousData = previousQuery.data?.items ?? [];
const previousMetrics = useMemo(() => calculateMetrics(previousData), [previousData]);
// Calculate trends
const metrics = useMemo(() => ({
...currentMetrics,
totalConnectionsTrend: calculateTrend(currentMetrics.totalConnections, previousMetrics.totalConnections),
uniqueUsersTrend: calculateTrend(currentMetrics.uniqueUsers, previousMetrics.uniqueUsers),
avgDurationTrend: calculateTrend(currentMetrics.avgDuration, previousMetrics.avgDuration),
newUsersTrend: calculateTrend(currentMetrics.newUsers, previousMetrics.newUsers),
}), [currentMetrics, previousMetrics]);
// Generate chart data
const trendData = useMemo(() => groupByDate(currentData), [currentData]);
const loyaltyDistribution = useMemo(() => calculateLoyaltyDistribution(currentData), [currentData]);
return {
// Metrics
@@ -214,12 +228,12 @@ export function useReportMetrics(filters: ReportFilters) {
loyaltyDistribution,
// Query state
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
isLoading: currentQuery.isLoading,
isFetching: currentQuery.isFetching || previousQuery.isFetching,
isError: currentQuery.isError,
error: currentQuery.error,
// Query actions
refetch: query.refetch,
refetch: currentQuery.refetch,
};
}

View File

@@ -147,7 +147,7 @@ export const MAX_EXPORT_SIZE = 10000;
export const DEFAULT_FILTERS = {
startDate: (() => {
const date = new Date();
date.setDate(date.getDate() - 29); // Last 30 days
date.setDate(date.getDate() - 6); // Last 7 days
return date.toISOString().split('T')[0];
})(),
endDate: new Date().toISOString().split('T')[0],

View File

@@ -372,3 +372,30 @@ export function downloadFile(blob: Blob, filename: string): void {
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Get avatar background color based on email hash
* Returns consistent color for same email (for visual consistency across views)
*/
export function getAvatarColorFromEmail(email?: string): string {
// Fallback color for users without email
if (!email) return 'bg-gray-500 text-white';
// Palette of 8 vibrant colors
const AVATAR_COLORS = [
'bg-green-600 text-white',
'bg-blue-600 text-white',
'bg-purple-600 text-white',
'bg-pink-600 text-white',
'bg-orange-600 text-white',
'bg-red-600 text-white',
'bg-yellow-600 text-white',
'bg-indigo-600 text-white',
];
// Simple hash function: sum of character codes
const hash = email.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
// Select color based on hash modulo palette size
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}