From 6740789690986df2054c7a3d9b055fad9487f090 Mon Sep 17 00:00:00 2001 From: Abraham Date: Sat, 2 Aug 2025 22:08:11 -0700 Subject: [PATCH] save before clean --- .../referencia/fn_racks_con_componentes.txt | 33 + assets/referencia/tablas_nethive.txt | 9 + .../componente_en_rack_function_model.dart | 45 + .../nethive/rack_con_componentes_model.dart | 155 ++++ .../infrastructure/pages/topologia_page.dart | 66 +- .../floor_plan_view_widget.dart | 213 +++++ .../rack_view_widget.dart | 863 ++++++++++++++++++ .../nethive/componentes_provider.dart | 100 +- 8 files changed, 1425 insertions(+), 59 deletions(-) create mode 100644 assets/referencia/fn_racks_con_componentes.txt create mode 100644 lib/models/nethive/componente_en_rack_function_model.dart create mode 100644 lib/models/nethive/rack_con_componentes_model.dart create mode 100644 lib/pages/infrastructure/pages/widgets/topologia_page_widgets/floor_plan_view_widget.dart create mode 100644 lib/pages/infrastructure/pages/widgets/topologia_page_widgets/rack_view_widget.dart diff --git a/assets/referencia/fn_racks_con_componentes.txt b/assets/referencia/fn_racks_con_componentes.txt new file mode 100644 index 0000000..c2e213c --- /dev/null +++ b/assets/referencia/fn_racks_con_componentes.txt @@ -0,0 +1,33 @@ +CREATE OR REPLACE FUNCTION nethive.fn_racks_con_componentes(p_negocio_id uuid) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN ( + SELECT jsonb_agg(rack_info) + FROM ( + SELECT + rack.id AS rack_id, + rack.nombre AS nombre_rack, + rack.ubicacion AS ubicacion_rack, + jsonb_agg( + jsonb_build_object( + 'componente_id', comp.id, + 'nombre', comp.nombre, + 'categoria_id', comp.categoria_id, + 'descripcion', comp.descripcion, + 'ubicacion', comp.ubicacion, + 'imagen_url', comp.imagen_url, + 'en_uso', comp.en_uso, + 'activo', comp.activo + ) + ) AS componentes + FROM nethive.componente rack + JOIN nethive.componente_en_rack cer ON rack.id = cer.rack_id + JOIN nethive.componente comp ON cer.componente_id = comp.id + WHERE rack.negocio_id = p_negocio_id AND rack.categoria_id = 4 -- RACK + GROUP BY rack.id + ) rack_info + ); +END; +$$; diff --git a/assets/referencia/tablas_nethive.txt b/assets/referencia/tablas_nethive.txt index dc13b70..5434840 100644 --- a/assets/referencia/tablas_nethive.txt +++ b/assets/referencia/tablas_nethive.txt @@ -181,6 +181,15 @@ descripcion (text) id (serial) (PK), nombre (text) + +-- componente_en_rack -- + +id UUID (PK) +rack_id (UUID) (FK de nethive.componente.id), +componente_id (UUID) (FK de nethive.componente.id), +posicion_u (INT4), +fecha_registro (TIMESTAMP) + ******* VISTAS: ******* -- vista_cables_en_uso -- diff --git a/lib/models/nethive/componente_en_rack_function_model.dart b/lib/models/nethive/componente_en_rack_function_model.dart new file mode 100644 index 0000000..4cc87df --- /dev/null +++ b/lib/models/nethive/componente_en_rack_function_model.dart @@ -0,0 +1,45 @@ +class ComponenteEnRack { + final String componenteId; + final String nombre; + final int categoriaId; + final String? descripcion; + final String? ubicacion; + final String? imagenUrl; + final bool enUso; + final bool activo; + + ComponenteEnRack({ + required this.componenteId, + required this.nombre, + required this.categoriaId, + this.descripcion, + this.ubicacion, + this.imagenUrl, + required this.enUso, + required this.activo, + }); + + factory ComponenteEnRack.fromMap(Map map) { + return ComponenteEnRack( + componenteId: map['componente_id'] ?? '', + nombre: map['nombre'] ?? '', + categoriaId: map['categoria_id'] ?? 0, + descripcion: map['descripcion'], + ubicacion: map['ubicacion'], + imagenUrl: map['imagen_url'], + enUso: map['en_uso'] ?? false, + activo: map['activo'] ?? false, + ); + } + + Map toMap() => { + 'componente_id': componenteId, + 'nombre': nombre, + 'categoria_id': categoriaId, + 'descripcion': descripcion, + 'ubicacion': ubicacion, + 'imagen_url': imagenUrl, + 'en_uso': enUso, + 'activo': activo, + }; +} diff --git a/lib/models/nethive/rack_con_componentes_model.dart b/lib/models/nethive/rack_con_componentes_model.dart new file mode 100644 index 0000000..cf41f0f --- /dev/null +++ b/lib/models/nethive/rack_con_componentes_model.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +class RackConComponentes { + final String rackId; + final String nombreRack; + final String? ubicacionRack; + final List componentes; + + RackConComponentes({ + required this.rackId, + required this.nombreRack, + this.ubicacionRack, + required this.componentes, + }); + + factory RackConComponentes.fromMap(Map map) { + return RackConComponentes( + rackId: map['rack_id'] ?? '', + nombreRack: map['nombre_rack'] ?? '', + ubicacionRack: map['ubicacion_rack'], + componentes: (map['componentes'] as List? ?? []) + .map((comp) => ComponenteEnRackDetalle.fromMap(comp)) + .toList(), + ); + } + + Map toMap() { + return { + 'rack_id': rackId, + 'nombre_rack': nombreRack, + 'ubicacion_rack': ubicacionRack, + 'componentes': componentes.map((comp) => comp.toMap()).toList(), + }; + } + + factory RackConComponentes.fromJson(String source) => + RackConComponentes.fromMap(json.decode(source)); + + String toJson() => json.encode(toMap()); + + // Métodos de utilidad + int get cantidadComponentes => componentes.length; + + int get componentesActivos => componentes.where((c) => c.activo).length; + + int get componentesEnUso => componentes.where((c) => c.enUso).length; + + double get porcentajeOcupacion { + // Asumiendo racks estándar de 42U + const int alturaMaximaU = 42; + final posicionesOcupadas = componentes + .where((c) => c.posicionU != null) + .map((c) => c.posicionU!) + .toSet() + .length; + + return posicionesOcupadas / alturaMaximaU * 100; + } + + List get componentesOrdenadosPorPosicion { + final componentesConPosicion = componentes + .where((c) => c.posicionU != null) + .toList() + ..sort((a, b) => (a.posicionU ?? 0).compareTo(b.posicionU ?? 0)); + + final componentesSinPosicion = + componentes.where((c) => c.posicionU == null).toList(); + + return [...componentesConPosicion, ...componentesSinPosicion]; + } +} + +class ComponenteEnRackDetalle { + final String componenteId; + final String nombre; + final int categoriaId; + final String? descripcion; + final String? ubicacion; + final String? imagenUrl; + final bool enUso; + final bool activo; + final int? posicionU; + + ComponenteEnRackDetalle({ + required this.componenteId, + required this.nombre, + required this.categoriaId, + this.descripcion, + this.ubicacion, + this.imagenUrl, + required this.enUso, + required this.activo, + this.posicionU, + }); + + factory ComponenteEnRackDetalle.fromMap(Map map) { + return ComponenteEnRackDetalle( + componenteId: map['componente_id'] ?? '', + nombre: map['nombre'] ?? '', + categoriaId: map['categoria_id'] ?? 0, + descripcion: map['descripcion'], + ubicacion: map['ubicacion'], + imagenUrl: map['imagen_url'], + enUso: map['en_uso'] ?? false, + activo: map['activo'] ?? false, + posicionU: map['posicion_u'], + ); + } + + Map toMap() { + return { + 'componente_id': componenteId, + 'nombre': nombre, + 'categoria_id': categoriaId, + 'descripcion': descripcion, + 'ubicacion': ubicacion, + 'imagen_url': imagenUrl, + 'en_uso': enUso, + 'activo': activo, + 'posicion_u': posicionU, + }; + } + + String get estadoTexto { + if (!activo) return 'Inactivo'; + if (enUso) return 'En Uso'; + return 'Disponible'; + } + + Color get colorEstado { + if (!activo) return Colors.grey; + if (enUso) return Colors.green; + return Colors.orange; + } + + IconData get iconoCategoria { + // Mapeo básico de categorías a iconos + switch (categoriaId) { + case 1: // Switch + return Icons.router; + case 2: // Server + return Icons.dns; + case 3: // Patch Panel + return Icons.view_module; + case 5: // UPS + return Icons.battery_charging_full; + case 6: // Router/Firewall + return Icons.security; + default: + return Icons.memory; + } + } +} diff --git a/lib/pages/infrastructure/pages/topologia_page.dart b/lib/pages/infrastructure/pages/topologia_page.dart index 5f3c8b4..d4003d4 100644 --- a/lib/pages/infrastructure/pages/topologia_page.dart +++ b/lib/pages/infrastructure/pages/topologia_page.dart @@ -5,6 +5,8 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:nethive_neo/theme/theme.dart'; import 'package:nethive_neo/providers/nethive/componentes_provider.dart'; import 'package:nethive_neo/models/nethive/topologia_completa_model.dart'; +import 'package:nethive_neo/pages/infrastructure/pages/widgets/topologia_page_widgets/rack_view_widget.dart'; +import 'package:nethive_neo/pages/infrastructure/pages/widgets/topologia_page_widgets/floor_plan_view_widget.dart'; class TopologiaPage extends StatefulWidget { const TopologiaPage({Key? key}) : super(key: key); @@ -1195,69 +1197,17 @@ class _TopologiaPageState extends State } Widget _buildRackView(bool isMediumScreen, ComponentesProvider provider) { - return Container( - padding: const EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.dns, - size: 80, - color: Colors.white.withOpacity(0.7), - ), - const SizedBox(height: 20), - const Text( - 'Vista de Racks', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - 'Próximamente: Visualización detallada de racks\ncon ${provider.componentesTopologia.where((c) => c.esRack).length} racks detectados', - textAlign: TextAlign.center, - style: - TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 16), - ), - ], - ), - ), + return RackViewWidget( + isMediumScreen: isMediumScreen, + provider: provider, ); } Widget _buildFloorPlanView( bool isMediumScreen, ComponentesProvider provider) { - return Container( - padding: const EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.map, - size: 80, - color: Colors.white.withOpacity(0.7), - ), - const SizedBox(height: 20), - const Text( - 'Plano de Planta', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - 'Próximamente: Plano de distribución física\ncon ubicaciones de ${provider.componentesTopologia.length} componentes', - textAlign: TextAlign.center, - style: - TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 16), - ), - ], - ), - ), + return FloorPlanViewWidget( + isMediumScreen: isMediumScreen, + provider: provider, ); } diff --git a/lib/pages/infrastructure/pages/widgets/topologia_page_widgets/floor_plan_view_widget.dart b/lib/pages/infrastructure/pages/widgets/topologia_page_widgets/floor_plan_view_widget.dart new file mode 100644 index 0000000..e9e4151 --- /dev/null +++ b/lib/pages/infrastructure/pages/widgets/topologia_page_widgets/floor_plan_view_widget.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:nethive_neo/providers/nethive/componentes_provider.dart'; + +class FloorPlanViewWidget extends StatelessWidget { + final bool isMediumScreen; + final ComponentesProvider provider; + + const FloorPlanViewWidget({ + Key? key, + required this.isMediumScreen, + required this.provider, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.map, + size: 80, + color: Colors.white.withOpacity(0.7), + ).animate().scale(duration: 600.ms), + const SizedBox(height: 20), + const Text( + 'Plano de Planta', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ).animate().fadeIn(delay: 300.ms), + const SizedBox(height: 8), + Text( + 'Próximamente: Distribución geográfica de componentes\ncon ${_getUbicacionesUnicas().length} ubicaciones identificadas', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withOpacity(0.7), + fontSize: 16, + ), + ).animate().fadeIn(delay: 500.ms), + const SizedBox(height: 24), + + // Panel de información adicional para planos + if (isMediumScreen) _buildFloorPlanInfoPanel(), + ], + ), + ), + ); + } + + List _getUbicacionesUnicas() { + final ubicaciones = provider.componentesTopologia + .where((c) => c.ubicacion != null && c.ubicacion!.trim().isNotEmpty) + .map((c) => c.ubicacion!) + .toSet() + .toList(); + return ubicaciones; + } + + Widget _buildFloorPlanInfoPanel() { + final ubicaciones = _getUbicacionesUnicas(); + final componentesPorPiso = _agruparComponentesPorPiso(); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.4), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Información del Plano', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + if (ubicaciones.isNotEmpty) ...[ + Text( + 'Ubicaciones detectadas: ${ubicaciones.length}', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, + ), + ), + const SizedBox(height: 8), + ...ubicaciones.take(4).map((ubicacion) { + final componentesEnUbicacion = provider.componentesTopologia + .where((c) => c.ubicacion == ubicacion) + .length; + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + '• $ubicacion ($componentesEnUbicacion componentes)', + style: TextStyle( + color: Colors.white.withOpacity(0.7), + fontSize: 12, + ), + ), + ); + }), + if (ubicaciones.length > 4) + Text( + '... y ${ubicaciones.length - 4} más', + style: TextStyle( + color: Colors.white.withOpacity(0.5), + fontSize: 11, + ), + ), + ] else ...[ + Text( + 'No se encontraron ubicaciones específicas', + style: TextStyle( + color: Colors.white.withOpacity(0.7), + fontSize: 14, + ), + ), + ], + if (componentesPorPiso.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text( + 'Distribución por niveles:', + style: TextStyle( + color: Colors.cyan, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + ...componentesPorPiso.entries.take(3).map((entry) => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '${entry.key}: ${entry.value} componentes', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 11, + ), + ), + )), + ], + const SizedBox(height: 12), + const Text( + 'Funcionalidades planificadas:', + style: TextStyle( + color: Colors.cyan, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + ...[ + '• Mapa interactivo de ubicaciones', + '• Vista por pisos y áreas', + '• Trazado de rutas de cableado', + '• Ubicación GPS de componentes', + ].map((feature) => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + feature, + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 11, + ), + ), + )), + ], + ), + ).animate().fadeIn(delay: 700.ms).slideY(begin: 0.3); + } + + Map _agruparComponentesPorPiso() { + Map pisos = {}; + + for (var componente in provider.componentesTopologia) { + if (componente.ubicacion != null) { + String ubicacion = componente.ubicacion!.toLowerCase(); + String piso = 'Otros'; + + if (ubicacion.contains('piso') || ubicacion.contains('planta')) { + // Extraer número de piso + RegExp regex = RegExp(r'(piso|planta)\s*(\d+)', caseSensitive: false); + var match = regex.firstMatch(ubicacion); + if (match != null) { + piso = 'Piso ${match.group(2)}'; + } + } else if (ubicacion.contains('pb') || + ubicacion.contains('planta baja')) { + piso = 'Planta Baja'; + } else if (ubicacion.contains('sotano') || + ubicacion.contains('sótano')) { + piso = 'Sótano'; + } else if (ubicacion.contains('azotea') || + ubicacion.contains('terraza')) { + piso = 'Azotea'; + } + + pisos[piso] = (pisos[piso] ?? 0) + 1; + } + } + + return pisos; + } +} diff --git a/lib/pages/infrastructure/pages/widgets/topologia_page_widgets/rack_view_widget.dart b/lib/pages/infrastructure/pages/widgets/topologia_page_widgets/rack_view_widget.dart new file mode 100644 index 0000000..99dc2b1 --- /dev/null +++ b/lib/pages/infrastructure/pages/widgets/topologia_page_widgets/rack_view_widget.dart @@ -0,0 +1,863 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:nethive_neo/helpers/constants.dart'; +import 'package:nethive_neo/providers/nethive/componentes_provider.dart'; +import 'package:nethive_neo/models/nethive/rack_con_componentes_model.dart'; + +class RackViewWidget extends StatelessWidget { + final bool isMediumScreen; + final ComponentesProvider provider; + + const RackViewWidget({ + Key? key, + required this.isMediumScreen, + required this.provider, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (provider.isLoadingRacks) { + return _buildLoadingView(); + } + + if (provider.racksConComponentes.isEmpty) { + return _buildEmptyView(); + } + + return Container( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header con estadísticas + _buildRackSummaryHeader(), + const SizedBox(height: 24), + + // Vista principal de racks + Expanded( + child: isMediumScreen + ? _buildDesktopRackView() + : _buildMobileRackView(), + ), + ], + ), + ); + } + + Widget _buildLoadingView() { + return Container( + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ).animate().scale(duration: 600.ms), + const SizedBox(height: 20), + const Text( + 'Cargando vista de racks...', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ).animate().fadeIn(delay: 300.ms), + const SizedBox(height: 8), + const Text( + 'Obteniendo componentes de cada rack', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ).animate().fadeIn(delay: 500.ms), + ], + ), + ), + ); + } + + Widget _buildEmptyView() { + return Container( + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.dns, + size: 80, + color: Colors.white.withOpacity(0.5), + ).animate().scale(duration: 600.ms), + const SizedBox(height: 20), + const Text( + 'Sin Racks Detectados', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ).animate().fadeIn(delay: 300.ms), + const SizedBox(height: 8), + const Text( + 'No se encontraron racks registrados\nen este negocio', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white70, + fontSize: 16, + ), + ).animate().fadeIn(delay: 500.ms), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.4), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.2)), + ), + child: Column( + children: [ + const Text( + 'Para ver racks aquí:', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + const Text( + '1. Cree componentes de tipo "Rack"', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + const SizedBox(height: 4), + const Text( + '2. Asigne otros componentes a los racks', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + const SizedBox(height: 4), + const Text( + '3. Configure posiciones U si es necesario', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ).animate().fadeIn(delay: 700.ms), + ], + ), + ), + ); + } + + Widget _buildRackSummaryHeader() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.blue.withOpacity(0.8), + Colors.blue.withOpacity(0.6), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.3), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.dns, + color: Colors.white, + size: 24, + ), + ).animate().scale(duration: 600.ms), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Vista de Racks - ${provider.negocioSeleccionadoNombre}', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ).animate().fadeIn(delay: 300.ms), + const SizedBox(height: 4), + Text( + '${provider.totalRacks} racks • ${provider.totalComponentesEnRacks} componentes • ${provider.porcentajeOcupacionPromedio.toStringAsFixed(1)}% ocupación promedio', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, + ), + ).animate().fadeIn(delay: 500.ms), + ], + ), + ), + if (provider.racksConProblemas.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.warning, color: Colors.white, size: 16), + const SizedBox(width: 8), + Text( + '${provider.racksConProblemas.length} alertas', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ).animate().fadeIn(delay: 700.ms), + ], + ), + ).animate().fadeIn().slideY(begin: -0.3, end: 0); + } + + Widget _buildDesktopRackView() { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Lista de racks + Expanded( + flex: 2, + child: _buildRacksList(), + ), + const SizedBox(width: 24), + // Panel de información + Expanded( + flex: 1, + child: _buildRackInfoPanel(), + ), + ], + ); + } + + Widget _buildMobileRackView() { + return _buildRacksList(); + } + + Widget _buildRacksList() { + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isMediumScreen ? 2 : 1, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: isMediumScreen ? 1.2 : 1.5, + ), + itemCount: provider.racksConComponentes.length, + itemBuilder: (context, index) { + final rack = provider.racksConComponentes[index]; + return _buildRackCard(rack, index); + }, + ); + } + + Widget _buildRackCard(RackConComponentes rack, int index) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black.withOpacity(0.8), + Colors.black.withOpacity(0.6), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.2), + blurRadius: 15, + offset: const Offset(0, 8), + ), + BoxShadow( + color: Colors.black.withOpacity(0.4), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => _showRackDetails(rack), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Imagen del rack más grande + Container( + width: 90, + height: 90, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.blue.withOpacity(0.4), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: _buildRackImage(rack), + ), + ), + const SizedBox(width: 20), + + // Información principal + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header del rack + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.blue.withOpacity(0.4), + width: 1, + ), + ), + child: const Icon( + Icons.dns, + color: Colors.blue, + size: 18, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rack.nombreRack, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (rack.ubicacionRack != null) + Text( + rack.ubicacionRack!, + style: TextStyle( + color: Colors.white.withOpacity(0.7), + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Estadísticas mejoradas + Row( + children: [ + _buildEnhancedStatItem( + rack.cantidadComponentes.toString(), + 'Total', + Colors.blue, + Icons.memory, + ), + const SizedBox(width: 12), + _buildEnhancedStatItem( + rack.componentesActivos.toString(), + 'Activos', + Colors.green, + Icons.check_circle, + ), + const SizedBox(width: 12), + _buildEnhancedStatItem( + '${rack.porcentajeOcupacion.toStringAsFixed(0)}%', + 'Ocupación', + rack.porcentajeOcupacion > 80 + ? Colors.red + : rack.porcentajeOcupacion > 60 + ? Colors.orange + : Colors.green, + Icons.dashboard, + ), + ], + ), + const SizedBox(height: 16), + + // Barra de ocupación mejorada + _buildEnhancedOccupationBar(rack), + + const SizedBox(height: 12), + + // Componentes preview mejorado + if (rack.componentes.isNotEmpty) + _buildEnhancedComponentsPreview(rack), + ], + ), + ), + ], + ), + ), + ), + ), + ).animate().fadeIn(delay: (100 * index).ms).slideY(begin: 0.3); + } + + Widget _buildRackImage(RackConComponentes rack) { + // Buscar la imagen del rack en los componentes + final rackComponent = provider.componentesTopologia + .where((c) => c.id == rack.rackId) + .firstOrNull; + + final imagenUrl = rackComponent?.imagenUrl; + + if (imagenUrl != null && imagenUrl.isNotEmpty) { + // Construir URL completa de Supabase + final fullImageUrl = + "$supabaseUrl/storage/v1/object/public/nethive/componentes/$imagenUrl?${DateTime.now().millisecondsSinceEpoch}"; + + return Image.network( + fullImageUrl, + fit: BoxFit.cover, + width: 90, + height: 90, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.3), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: CircularProgressIndicator( + color: Colors.blue, + strokeWidth: 2, + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/placeholder_no_image.jpg', + fit: BoxFit.cover, + width: 90, + height: 90, + ); + }, + ); + } else { + // Usar imagen placeholder local + return Image.asset( + 'assets/images/placeholder_no_image.jpg', + fit: BoxFit.cover, + width: 90, + height: 90, + ); + } + } + + Widget _buildEnhancedStatItem( + String value, String label, Color color, IconData icon) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withOpacity(0.2), + color.withOpacity(0.1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.4), width: 1), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: color, + size: 20, + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + color: color, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: TextStyle( + color: color.withOpacity(0.8), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + Widget _buildEnhancedOccupationBar(RackConComponentes rack) { + final ocupacion = rack.porcentajeOcupacion; + final color = ocupacion > 80 + ? Colors.red + : ocupacion > 60 + ? Colors.orange + : Colors.green; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Ocupación del Rack', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.2), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.4)), + ), + child: Text( + '${ocupacion.toStringAsFixed(1)}%', + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + height: 8, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: ocupacion / 100, + backgroundColor: Colors.transparent, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ), + ], + ); + } + + Widget _buildEnhancedComponentsPreview(RackConComponentes rack) { + final componentesOrdenados = rack.componentesOrdenadosPorPosicion; + final maxPreview = 3; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.list_alt, + color: Colors.white.withOpacity(0.8), + size: 16, + ), + const SizedBox(width: 6), + Text( + 'Componentes principales:', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 8), + ...componentesOrdenados.take(maxPreview).map((comp) => Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: comp.colorEstado.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: comp.colorEstado.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + comp.iconoCategoria, + size: 14, + color: comp.colorEstado, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + comp.nombre, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (comp.posicionU != null) + Text( + 'Posición U${comp.posicionU}', + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 10, + ), + ), + ], + ), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: comp.colorEstado.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + comp.estadoTexto, + style: TextStyle( + color: comp.colorEstado, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + )), + if (componentesOrdenados.length > maxPreview) + Container( + margin: const EdgeInsets.only(top: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.more_horiz, + color: Colors.blue, + size: 14, + ), + const SizedBox(width: 4), + Text( + '+${componentesOrdenados.length - maxPreview} componentes más', + style: const TextStyle( + color: Colors.blue, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildRackInfoPanel() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.4), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Resumen de Racks', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + _buildInfoRow('Total de Racks', provider.totalRacks.toString()), + _buildInfoRow('Componentes Totales', + provider.totalComponentesEnRacks.toString()), + _buildInfoRow( + 'Racks Activos', provider.racksConComponentesActivos.toString()), + _buildInfoRow('Ocupación Promedio', + '${provider.porcentajeOcupacionPromedio.toStringAsFixed(1)}%'), + if (provider.racksConProblemas.isNotEmpty) ...[ + const SizedBox(height: 16), + const Divider(color: Colors.white24), + const SizedBox(height: 16), + const Text( + 'Racks con Alertas', + style: TextStyle( + color: Colors.orange, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ...provider.racksConProblemas.map((rack) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + '• ${rack.nombreRack}', + style: TextStyle( + color: Colors.white.withOpacity(0.7), + fontSize: 12, + ), + ), + )), + ], + const Spacer(), + const Text( + 'Funcionalidades disponibles:', + style: TextStyle( + color: Colors.cyan, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + ...[ + '• Vista detallada de cada rack', + '• Gestión de posiciones U', + '• Estados de componentes', + '• Alertas de ocupación', + ].map((feature) => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + feature, + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 11, + ), + ), + )), + ], + ), + ).animate().fadeIn(delay: 800.ms).slideX(begin: 0.3); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 12, + ), + ), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + void _showRackDetails(RackConComponentes rack) { + // TODO: Implementar modal con detalles completos del rack + print('Mostrar detalles del rack: ${rack.nombreRack}'); + } +} diff --git a/lib/providers/nethive/componentes_provider.dart b/lib/providers/nethive/componentes_provider.dart index 39cb665..e045209 100644 --- a/lib/providers/nethive/componentes_provider.dart +++ b/lib/providers/nethive/componentes_provider.dart @@ -24,6 +24,7 @@ import 'package:nethive_neo/models/nethive/detalle_router_firewall_model.dart'; import 'package:nethive_neo/models/nethive/detalle_equipo_activo_model.dart'; import 'package:nethive_neo/models/nethive/vista_conexiones_por_cables_model.dart'; import 'package:nethive_neo/models/nethive/vista_topologia_por_negocio_model.dart'; +import 'package:nethive_neo/models/nethive/rack_con_componentes_model.dart'; class ComponentesProvider extends ChangeNotifier { // State managers @@ -65,6 +66,7 @@ class ComponentesProvider extends ChangeNotifier { // Variables para gestión de topología bool isLoadingTopologia = false; + bool isLoadingRacks = false; List problemasTopologia = []; // Detalles específicos por tipo de componente @@ -77,6 +79,9 @@ class ComponentesProvider extends ChangeNotifier { DetalleRouterFirewall? detalleRouterFirewall; DetalleEquipoActivo? detalleEquipoActivo; + // Nueva lista para racks con componentes + List racksConComponentes = []; + // Variable para controlar si el provider está activo bool _isDisposed = false; @@ -866,7 +871,13 @@ class ComponentesProvider extends ChangeNotifier { empresaSeleccionadaId = empresaId; _limpiarDatosAnteriores(); - await getTopologiaPorNegocio(negocioId); + + // Cargar datos en paralelo + await Future.wait([ + getTopologiaPorNegocio(negocioId), + getRacksConComponentes(negocioId), + ]); + _safeNotifyListeners(); } catch (e) { print('Error en setNegocioSeleccionado: ${e.toString()}'); @@ -895,6 +906,9 @@ class ComponentesProvider extends ChangeNotifier { detalleUps = null; detalleRouterFirewall = null; detalleEquipoActivo = null; + + racksConComponentes.clear(); + isLoadingRacks = false; } // MÉTODOS DE UTILIDAD PARA TOPOLOGÍA @@ -961,4 +975,88 @@ class ComponentesProvider extends ChangeNotifier { .where((c) => c.origenId == componenteId || c.destinoId == componenteId) .toList(); } + + // MÉTODOS PARA CARGAR RACKS CON COMPONENTES + Future getRacksConComponentes(String negocioId) async { + try { + isLoadingRacks = true; + _safeNotifyListeners(); + + print( + 'Llamando a función RPC fn_racks_con_componentes con negocio_id: $negocioId'); + + final response = + await supabaseLU.rpc('fn_racks_con_componentes', params: { + 'p_negocio_id': negocioId, + }); + + print('Respuesta RPC racks: $response'); + + if (response != null && response is List) { + racksConComponentes = + (response).map((rack) => RackConComponentes.fromMap(rack)).toList(); + + print('Racks cargados: ${racksConComponentes.length}'); + for (var rack in racksConComponentes) { + print( + '- ${rack.nombreRack}: ${rack.cantidadComponentes} componentes'); + } + } else { + racksConComponentes = []; + print('No se encontraron racks o respuesta vacía'); + } + } catch (e) { + print('Error en getRacksConComponentes: ${e.toString()}'); + racksConComponentes = []; + } finally { + isLoadingRacks = false; + _safeNotifyListeners(); + } + } + + // MÉTODOS DE UTILIDAD PARA RACKS + RackConComponentes? getRackById(String rackId) { + try { + return racksConComponentes.firstWhere((rack) => rack.rackId == rackId); + } catch (e) { + return null; + } + } + + int get totalRacks => racksConComponentes.length; + + int get totalComponentesEnRacks => racksConComponentes.fold( + 0, (sum, rack) => sum + rack.cantidadComponentes); + + int get racksConComponentesActivos => + racksConComponentes.where((rack) => rack.componentesActivos > 0).length; + + double get porcentajeOcupacionPromedio { + if (racksConComponentes.isEmpty) return 0.0; + + final totalOcupacion = racksConComponentes.fold( + 0.0, (sum, rack) => sum + rack.porcentajeOcupacion); + + return totalOcupacion / racksConComponentes.length; + } + + List get racksOrdenadosPorOcupacion { + final racks = [...racksConComponentes]; + racks + .sort((a, b) => b.porcentajeOcupacion.compareTo(a.porcentajeOcupacion)); + return racks; + } + + List get racksConProblemas { + return racksConComponentes.where((rack) { + // Rack con problemas si tiene componentes inactivos o sin posición U + final componentesInactivos = + rack.componentes.where((c) => !c.activo).length; + + final componentesSinPosicion = + rack.componentes.where((c) => c.posicionU == null).length; + + return componentesInactivos > 0 || componentesSinPosicion > 0; + }).toList(); + } }