save before clean

This commit is contained in:
Abraham
2025-08-02 22:08:11 -07:00
parent 5f08a7556f
commit 6740789690
8 changed files with 1425 additions and 59 deletions

View File

@@ -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;
$$;

View File

@@ -181,6 +181,15 @@ descripcion (text)
id (serial) (PK), id (serial) (PK),
nombre (text) 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: ******* ******* VISTAS: *******
-- vista_cables_en_uso -- -- vista_cables_en_uso --

View File

@@ -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<String, dynamic> 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<String, dynamic> toMap() => {
'componente_id': componenteId,
'nombre': nombre,
'categoria_id': categoriaId,
'descripcion': descripcion,
'ubicacion': ubicacion,
'imagen_url': imagenUrl,
'en_uso': enUso,
'activo': activo,
};
}

View File

@@ -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<ComponenteEnRackDetalle> componentes;
RackConComponentes({
required this.rackId,
required this.nombreRack,
this.ubicacionRack,
required this.componentes,
});
factory RackConComponentes.fromMap(Map<String, dynamic> map) {
return RackConComponentes(
rackId: map['rack_id'] ?? '',
nombreRack: map['nombre_rack'] ?? '',
ubicacionRack: map['ubicacion_rack'],
componentes: (map['componentes'] as List<dynamic>? ?? [])
.map((comp) => ComponenteEnRackDetalle.fromMap(comp))
.toList(),
);
}
Map<String, dynamic> 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<ComponenteEnRackDetalle> 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<String, dynamic> 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<String, dynamic> 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;
}
}
}

View File

@@ -5,6 +5,8 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:nethive_neo/theme/theme.dart'; import 'package:nethive_neo/theme/theme.dart';
import 'package:nethive_neo/providers/nethive/componentes_provider.dart'; import 'package:nethive_neo/providers/nethive/componentes_provider.dart';
import 'package:nethive_neo/models/nethive/topologia_completa_model.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 { class TopologiaPage extends StatefulWidget {
const TopologiaPage({Key? key}) : super(key: key); const TopologiaPage({Key? key}) : super(key: key);
@@ -1195,69 +1197,17 @@ class _TopologiaPageState extends State<TopologiaPage>
} }
Widget _buildRackView(bool isMediumScreen, ComponentesProvider provider) { Widget _buildRackView(bool isMediumScreen, ComponentesProvider provider) {
return Container( return RackViewWidget(
padding: const EdgeInsets.all(24), isMediumScreen: isMediumScreen,
child: Center( provider: provider,
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),
),
],
),
),
); );
} }
Widget _buildFloorPlanView( Widget _buildFloorPlanView(
bool isMediumScreen, ComponentesProvider provider) { bool isMediumScreen, ComponentesProvider provider) {
return Container( return FloorPlanViewWidget(
padding: const EdgeInsets.all(24), isMediumScreen: isMediumScreen,
child: Center( provider: provider,
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),
),
],
),
),
); );
} }

View File

@@ -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<String> _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<String, int> _agruparComponentesPorPiso() {
Map<String, int> 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;
}
}

View File

@@ -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>(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}');
}
}

View File

@@ -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/detalle_equipo_activo_model.dart';
import 'package:nethive_neo/models/nethive/vista_conexiones_por_cables_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/vista_topologia_por_negocio_model.dart';
import 'package:nethive_neo/models/nethive/rack_con_componentes_model.dart';
class ComponentesProvider extends ChangeNotifier { class ComponentesProvider extends ChangeNotifier {
// State managers // State managers
@@ -65,6 +66,7 @@ class ComponentesProvider extends ChangeNotifier {
// Variables para gestión de topología // Variables para gestión de topología
bool isLoadingTopologia = false; bool isLoadingTopologia = false;
bool isLoadingRacks = false;
List<String> problemasTopologia = []; List<String> problemasTopologia = [];
// Detalles específicos por tipo de componente // Detalles específicos por tipo de componente
@@ -77,6 +79,9 @@ class ComponentesProvider extends ChangeNotifier {
DetalleRouterFirewall? detalleRouterFirewall; DetalleRouterFirewall? detalleRouterFirewall;
DetalleEquipoActivo? detalleEquipoActivo; DetalleEquipoActivo? detalleEquipoActivo;
// Nueva lista para racks con componentes
List<RackConComponentes> racksConComponentes = [];
// Variable para controlar si el provider está activo // Variable para controlar si el provider está activo
bool _isDisposed = false; bool _isDisposed = false;
@@ -866,7 +871,13 @@ class ComponentesProvider extends ChangeNotifier {
empresaSeleccionadaId = empresaId; empresaSeleccionadaId = empresaId;
_limpiarDatosAnteriores(); _limpiarDatosAnteriores();
await getTopologiaPorNegocio(negocioId);
// Cargar datos en paralelo
await Future.wait([
getTopologiaPorNegocio(negocioId),
getRacksConComponentes(negocioId),
]);
_safeNotifyListeners(); _safeNotifyListeners();
} catch (e) { } catch (e) {
print('Error en setNegocioSeleccionado: ${e.toString()}'); print('Error en setNegocioSeleccionado: ${e.toString()}');
@@ -895,6 +906,9 @@ class ComponentesProvider extends ChangeNotifier {
detalleUps = null; detalleUps = null;
detalleRouterFirewall = null; detalleRouterFirewall = null;
detalleEquipoActivo = null; detalleEquipoActivo = null;
racksConComponentes.clear();
isLoadingRacks = false;
} }
// MÉTODOS DE UTILIDAD PARA TOPOLOGÍA // MÉTODOS DE UTILIDAD PARA TOPOLOGÍA
@@ -961,4 +975,88 @@ class ComponentesProvider extends ChangeNotifier {
.where((c) => c.origenId == componenteId || c.destinoId == componenteId) .where((c) => c.origenId == componenteId || c.destinoId == componenteId)
.toList(); .toList();
} }
// MÉTODOS PARA CARGAR RACKS CON COMPONENTES
Future<void> 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<RackConComponentes> get racksOrdenadosPorOcupacion {
final racks = [...racksConComponentes];
racks
.sort((a, b) => b.porcentajeOcupacion.compareTo(a.porcentajeOcupacion));
return racks;
}
List<RackConComponentes> 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();
}
} }