fmatituy/selectospromanager
0
1import os
2import asyncio
3from nicegui import ui
4from services.auth import get_current_user, logout
5from services.navigation import get_navigation_schema, is_manager
6from core.help_system import get_help_for_route
7from core.config import STATIC_PATH # Nueva importación para consistencia de rutas de recursos
8
9try:
10 from modules.comunicacion.floating_chat import render_floating_chat
11except ImportError as e:
12 print(f"DEBUG CHAT: ImportError loading floating_chat: {e}")
13 render_floating_chat = None
14
15try:
16 from modules.sst.sos_service import init_sos_system
17except ImportError:
18 init_sos_system = None
19
20
21def init_pwa_headers():
22 ui.add_head_html('''
23 <meta name="apple-mobile-web-app-capable" content="yes">
24 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
25 <meta name="theme-color" content="#1e40af">
26 <link rel="manifest" href="/manifest.json">
27 <script>
28 if ('serviceWorker' in navigator) {
29 window.addEventListener('load', function() {
30 navigator.serviceWorker.register('/static/service-worker.js', { scope: '/' }).then(function(registration) {
31 console.log('ServiceWorker registration successful with scope: ', registration.scope);
32 }, function(err) {
33 console.log('ServiceWorker registration failed: ', err);
34 });
35 });
36 }
37
38 // Interceptor Nativo PWA (Google Chrome / Edge)
39 let deferredPrompt;
40 window.addEventListener('beforeinstallprompt', (e) => {
41 // Previene que aparezca la mini-barra de instalación por defecto del navegador en la parte inferior
42 e.preventDefault();
43 deferredPrompt = e;
44
45 // Mostrar solo en móviles, si no es standalone y si no se ha mostrado en esta sesión
46 const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
47 const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone;
48
49 if (isMobile && !isStandalone && !sessionStorage.getItem('pwaPromptShown')) {
50 sessionStorage.setItem('pwaPromptShown', '1');
51
52 const style = document.createElement('style');
53 style.textContent = `
54 #apk-prompt-overlay { display: none; position: fixed; inset: 0; z-index: 999999; background: rgba(15,23,42,0.85); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); align-items: center; justify-content: center; padding: 1.5rem; opacity: 0; transition: opacity 0.3s ease; }
55 #apk-prompt-overlay.show { opacity: 1; display: flex; }
56 .apk-card { background: white; border-radius: 2rem; padding: 2.5rem 2rem; text-align: center; width: 100%; max-width: 340px; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); transform: translateY(20px); transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
57 #apk-prompt-overlay.show .apk-card { transform: translateY(0); }
58 .apk-card h2 { margin: 0 0 0.5rem; font-size: 1.5rem; font-weight: 900; color: #1e293b; letter-spacing: -0.5px; font-family: 'Inter', sans-serif; }
59 .apk-card p { margin: 0 0 1.5rem; font-size: 0.875rem; color: #64748b; line-height: 1.5; font-family: 'Inter', sans-serif; }
60 .apk-btn { display: block; width: 100%; background: #1e40af; color: white; padding: 1rem; border-radius: 1rem; text-decoration: none; font-weight: 800; font-size: 0.875rem; box-shadow: 0 10px 15px -3px rgba(30,64,175,0.4); margin-bottom: 0.75rem; font-family: 'Inter', sans-serif; cursor: pointer; border: none; transition: background 0.2s; }
61 .apk-btn:active { background: #1e3a8a; }
62 .apk-btn-flat { display: block; width: 100%; background: transparent; border: none; color: #94a3b8; padding: 0.75rem; border-radius: 1rem; font-weight: 800; font-size: 0.875rem; cursor: pointer; font-family: 'Inter', sans-serif; transition: color 0.2s; }
63 .apk-btn-flat:active { color: #475569; }
64 `;
65 document.head.appendChild(style);
66
67 const overlay = document.createElement('div');
68 overlay.id = 'apk-prompt-overlay';
69 overlay.innerHTML = `
70 <div class="apk-card">
71 <div style="background:#eff6ff; width:72px; height:72px; border-radius:1.5rem; display:flex; align-items:center; justify-content:center; margin: 0 auto 1.5rem; color:#1e40af;">
72 <svg viewBox="0 0 24 24" width="36" height="36" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
73 </div>
74 <h2>App Móvil Nativa</h2>
75 <p>Instala la versión nativa (PWA) de Selectos ProManager. Más rápida, segura y adaptada a tu dispositivo.</p>
76 <button id="pwa-install-btn" class="apk-btn">INSTALAR AHORA</button>
77 <button class="apk-btn-flat" onclick="document.getElementById('apk-prompt-overlay').classList.remove('show'); setTimeout(() => document.getElementById('apk-prompt-overlay').style.display='none', 300)">Continuar en la Web</button>
78 </div>
79 `;
80 document.body.appendChild(overlay);
81
82 setTimeout(() => {
83 overlay.style.display = 'flex';
84 overlay.offsetHeight;
85 overlay.classList.add('show');
86 }, 500);
87
88 document.getElementById('pwa-install-btn').addEventListener('click', async () => {
89 // Ocultar modal primero
90 overlay.classList.remove('show');
91 setTimeout(() => overlay.style.display='none', 300);
92
93 // Disparar instalador real de Google / SO
94 if (deferredPrompt) {
95 deferredPrompt.prompt();
96 const { outcome } = await deferredPrompt.userChoice;
97 console.log('Resultado de instalación:', outcome);
98 deferredPrompt = null;
99 }
100 });
101 }
102 });
103 </script>
104 ''')
105
106
107
108def layout_main(title="Selectos ProManager"):
109 return SelectosLayout(title)
110
111
112class SelectosLayout:
113 def __init__(self, title="Selectos ProManager"):
114 self.title = title
115 self.left_drawer = None
116 self.offline_indicator = None
117 self.btn_apk = None
118 self.content = None
119
120 def pwa_colors(self):
121 # Estilos corporativos en NiceGUI usando variables de Quasar - AZUL OSCURO PREMIUN (Navy Slate)
122 ui.colors(primary='#0f172a', secondary='#334155', accent='#3b82f6', positive='#10b981', negative='#ef4444', info='#0ea5e9', warning='#f59e0b')
123 ui.add_head_html('''
124 <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
125 <style>
126 /* Bloqueo de Zoom en Navegadores y Móviles */
127 html, body {
128 touch-action: manipulation;
129 -webkit-text-size-adjust: 100%;
130 -moz-text-size-adjust: 100%;
131 -ms-text-size-adjust: 100%;
132 }
133 .hide-scrollbar::-webkit-scrollbar {
134 display: none;
135 }
136 .hide-scrollbar {
137 -ms-overflow-style: none; /* IE and Edge */
138 scrollbar-width: none; /* Firefox */
139 }
140 /* Scrollbar Minimalista y Transparente */
141 ::-webkit-scrollbar {
142 width: 6px;
143 height: 6px;
144 }
145 ::-webkit-scrollbar-track {
146 background: transparent;
147 }
148 ::-webkit-scrollbar-thumb {
149 background: rgba(148, 163, 184, 0.1); /* Slate-400 con máxima transparencia */
150 border-radius: 10px;
151 }
152 ::-webkit-scrollbar-thumb:hover {
153 background: rgba(30, 64, 175, 0.15); /* Primary con un toque muy sutil al pasar el mouse */
154 }
155 /* Para Firefox */
156 * {
157 scrollbar-width: thin;
158 scrollbar-color: rgba(148, 163, 184, 0.1) transparent;
159 }
160 /* Soporte para modo MINI del Drawer en elementos personalizados */
161 .q-drawer--mini .q-mini-drawer-hide { display: none !important; }
162 .q-mini-drawer-only { display: none !important; }
163 .q-drawer--mini .q-mini-drawer-only {
164 display: block !important;
165 }
166 /* Reset de padding para el contenedor de navegación en modo MINI */
167 .q-drawer--mini .drawer-nav-container {
168 padding-left: 0 !important;
169 padding-right: 0 !important;
170 }
171 .q-drawer--mini .q-mini-drawer-justify-center {
172 justify-content: center !important;
173 padding-left: 0 !important;
174 padding-right: 0 !important;
175 gap: 0 !important;
176 }
177 /* Eliminar rastro de "frame" en modo MINI */
178 .q-drawer--mini {
179 background: transparent !important;
180 border-right: none !important;
181 box-shadow: none !important;
182 }
183 .q-drawer--mini .q-scrollarea {
184 background: transparent !important;
185 }
186
187 /* ESTILOS DE TABLAS MODERNAS (NAVY SLATE) */
188 .modern-table thead tr { height: 72px !important; background-color: #f8fafc; }
189 .modern-table th {
190 font-size: 11px !important;
191 font-weight: 900 !important;
192 letter-spacing: 0.15em !important;
193 color: #1e3a8a !important;
194 text-transform: uppercase;
195 }
196 .modern-table td { padding: 16px 24px !important; }
197 </style>
198 ''')
199
200 def __enter__(self):
201 # UI Global Config (Moved from __init__ to ensure active context)
202 self.pwa_colors()
203
204 # Singleton pattern to prevent memory bloat and timer accumulation
205 client = ui.context.client
206 is_already_init = getattr(client, 'selectos_initialized', False)
207
208 try:
209 if not is_already_init:
210 init_pwa_headers()
211 ui.add_head_html('<script src="/static/offline_manager.js"></script>')
212 # Inject Global JS for Notifications only once
213 ui.add_head_html('''
214 <script>
215 if ("Notification" in window && Notification.permission !== "granted" && Notification.permission !== "denied") {
216 Notification.requestPermission();
217 }
218 function showPushNotification(title, body) {
219 const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone || localStorage.getItem("SelectosAPK_Downloaded") === "yes";
220 if (isStandalone && "Notification" in window && Notification.permission === "granted") {
221 navigator.serviceWorker.ready.then(function(registration) {
222 registration.showNotification(title, {
223 body: body,
224 icon: '/static/icon.png',
225 badge: '/static/icon.png',
226 vibrate: [200, 100, 200]
227 });
228 }).catch(function() {
229 new Notification(title, {body: body, icon: '/static/icon.png'});
230 });
231 }
232 }
233 </script>
234 ''')
235 except Exception as e:
236 print(f"Error initializing PWA headers in layout: {e}")
237
238 user = get_current_user()
239 user_id = user.get('id')
240 rol = user.get('rol', '')
241 nombre = user.get('nombre', '')
242 is_tecnico = rol in ["Operativo", "Técnico", "Asistente de Mantenimiento"]
243 is_manager_role = is_manager(rol)
244
245 # --- PUSH NOTIFICATIONS ENGINE ---
246 if user_id:
247 def check_push_notifications():
248 try:
249 # Defensive check: ensure client context is still valid and connected
250 if not ui.context.client or not ui.context.client.connected:
251 return
252
253 from nicegui import app
254 from modules.comunicacion.services_comunicacion import get_mensajes_chat
255
256 # Stop if server is shutting down
257 if getattr(app, 'is_stopping', False):
258 return
259
260 # Stop checking if user is no longer authenticated or context lost
261 if not app.storage.user.get('authenticated'):
262 return
263
264 msgs = get_mensajes_chat(area=f"Sara IA_{user_id}", limite=1)
265 if msgs:
266 latest_msg = msgs[0]
267 stored_last = app.storage.user.get('last_notif_id', 0)
268 if latest_msg['id'] > stored_last:
269 app.storage.user['last_notif_id'] = latest_msg['id']
270 if stored_last > 0:
271 import json
272 clean_text = latest_msg['mensaje'].replace('**', '').replace('📋', '')
273 js_msg = json.dumps(clean_text)
274 # Double check client connection before JS execution
275 if ui.context.client.connected:
276 ui.run_javascript(f"showPushNotification('Alerta Central', {js_msg})", timeout=2.0)
277 elif stored_last == 0:
278 app.storage.user['last_notif_id'] = latest_msg['id']
279 except Exception:
280 pass
281
282 # Create timer for each page load. NiceGUI SPA clears the previous page's timers.
283 # This ensures notifications work after navigation without accumulation.
284 ui.timer(15.0, check_push_notifications)
285 if not is_already_init:
286 print(f"DEBUG NAV: Push Notifications Engine Inicializado para {nombre}")
287
288 # Marcar cliente como inicializado para evitar duplicidad en navegaciones SPA (NiceGUI)
289 client.selectos_initialized = True
290
291 # Asegurar directorios de avatares en la ruta estática correcta
292 os.makedirs(os.path.join(STATIC_PATH, 'avatars', 'presets'), exist_ok=True)
293
294 # Detectar ruta actual completa en tiempo real para resaltado activo
295 try:
296 # Intentar obtener la ruta real de la página en el cliente
297 current_path = ui.context.client.page.path
298 # Si hay discrepancia o viene de request inicial
299 if not current_path or current_path == "":
300 current_path = ui.context.client.request.url.path
301
302 # Limpiar ruta para comparaciones seguras (quitar trailing slash si existe)
303 if len(current_path) > 1 and current_path.endswith('/'):
304 current_path = current_path[:-1]
305
306 # Detectar dispositivo móvil
307 user_agent = ui.context.client.request.headers.get('user-agent', '').lower()
308 is_mobile_agent = any(k in user_agent for k in ["mobile", "android", "iphone", "ipad", "ipod", "blackberry", "iemobile", "opera mini"])
309 # print(f"DEBUG NAV: Render Path='{current_path}', Mobile={is_mobile_agent}")
310 except Exception as e:
311 current_path = "/"
312 is_mobile_agent = False
313 print(f"DEBUG NAV ERROR: {e}")
314
315 with ui.header().classes('bg-slate-900 text-white justify-between items-center shadow-2xl py-2 px-2 flex-row border-b border-white/5'):
316 with ui.row().classes('items-center gap-4 shrink-0'):
317 if not is_tecnico or is_manager_role:
318 def toggle_mini():
319 if not hasattr(self, 'left_drawer') or not self.left_drawer: return
320 # Toggle robusto manipulando el diccionario interno de NiceGUI
321 if 'mini' in self.left_drawer._props:
322 self.left_drawer._props.pop('mini')
323 else:
324 self.left_drawer._props['mini'] = True
325 self.left_drawer.update()
326
327 menu_btn = ui.button(on_click=toggle_mini, icon='menu').props('flat round color=white')
328
329 # Branding Textual: PRO MANAGER (Robust Industrial Logo)
330 with ui.column().classes('gap-0 cursor-pointer ml-1').on('click', lambda: ui.navigate.to('/')):
331 with ui.row().classes('items-center gap-1.5 leading-none'):
332 ui.label('PRO').classes('text-[28px] font-[900] tracking-tighter text-blue-400')
333 ui.icon('engineering', size='32px', color='white').props('color=white')
334 ui.label('MANAGER').classes('text-[28px] font-[900] tracking-tighter text-white')
335 ui.label('INGENIERÍA • OPERACIONES • SEGURIDAD SST').classes('text-[10px] font-black tracking-[0.25em] text-slate-400 uppercase -mt-0.5 ml-1')
336
337 # --- NAVEGACIÓN SST UNIFICADA (SINGLE BAR) ---
338 if current_path.startswith('/sst') and not is_tecnico:
339 sst_nav_items = [
340 {'label': 'MANDO', 'path': '/sst/industrial/hub', 'icon': 'dashboard'},
341 {'label': 'IPVR', 'path': '/sst/industrial/gtc45-pro', 'icon': 'warning'},
342 {'label': 'SALUD', 'path': '/sst/industrial/medicina', 'icon': 'medical_services'},
343 {'label': 'ESTUDIO', 'path': '/sst/sara-estudio', 'icon': 'auto_awesome'},
344 {'label': 'CONSTRUCTOR', 'path': '/sst/constructor', 'icon': 'psychology'},
345 ]
346 with ui.row().classes('items-center gap-1 flex-1 justify-start ml-8 hidden md:flex'):
347 for item in sst_nav_items:
348 is_active = current_path == item['path'] or current_path.startswith(item['path'] + '?')
349 with ui.row().classes(f'items-center gap-1.5 px-3 py-1.5 rounded-lg cursor-pointer transition-all hover:bg-white/10 {"bg-white/20 border border-white/20 shadow-sm" if is_active else "opacity-70 hover:opacity-100"}') \
350 .on('click', lambda item=item: ui.navigate.to(item['path'])):
351 ui.icon(item['icon'], size='14px', color='white')
352 ui.label(item['label']).classes('text-[11px] font-bold tracking-widest text-white uppercase')
353
354 # INFO DE USUARIO (SOLO PC) Y RUTA (BREADCRUMBS)
355 breadcrumbs_visible = not (current_path.startswith('/sst') and not is_tecnico)
356 with ui.row().classes('items-center grow justify-center gap-4 hidden md:flex' if breadcrumbs_visible else 'hidden'):
357 # Sistema de "Miga de Pan" (Breadcrumbs) dinámico
358 parts = current_path.strip('/').split('/')
359 with ui.row().classes('items-center gap-2 bg-white/10 px-4 py-1.5 rounded-full border border-white/5'):
360 ui.icon('home', size='xs', color='white').classes('opacity-60')
361 for i, part in enumerate(parts):
362 if part:
363 ui.icon('chevron_right', size='14px', color='white').classes('opacity-40')
364 ui.label(part.replace('-', ' ').title()).classes('text-[11px] font-bold tracking-widest text-white uppercase')
365
366 # Indicador Offline (Visible vía JS cuando navigator.onLine es falso)
367 with ui.row().classes('items-center gap-2 bg-amber-500/20 px-3 py-1 rounded-full border border-amber-500/30 ml-4 animate-pulse').style('display: none') as self.offline_indicator:
368 ui.icon('cloud_off', color='amber-500', size='xs')
369 ui.label('TRABAJANDO SIN CONEXIÓN').classes('text-[9px] font-black text-amber-500 tracking-tighter uppercase')
370
371 ui.add_body_html(f'''
372 <script>
373 setInterval(() => {{
374 const indicator = document.getElementById('c{self.offline_indicator.id}');
375 if (indicator) {{
376 indicator.style.display = navigator.onLine ? 'none' : 'flex';
377 }}
378 }}, 2000);
379 </script>
380 ''')
381 # CAJA DE ACCIONES SUPERIOR (DERECHA)
382 with ui.row().classes('items-center gap-2'):
383 # Botón de Ayuda Contextual (NUEVO)
384 help_info = get_help_for_route(current_path)
385 if help_info:
386 async def show_help():
387 with ui.dialog() as help_dialog, ui.card().classes('w-full max-w-lg p-0 rounded-[2rem] overflow-hidden'):
388 with ui.column().classes('w-full'):
389 # Header del Dialogo
390 with ui.row().classes('w-full bg-slate-900 text-white p-6 items-center justify-between'):
391 with ui.row().classes('items-center gap-3'):
392 ui.icon('info', size='24px', color='blue-400')
393 with ui.column().classes('gap-0'):
394 ui.label('GUÍA DE USUARIO').classes('text-[10px] font-black tracking-[0.2em] text-blue-400')
395 ui.label(help_info['modulo']).classes('text-lg font-black tracking-tight')
396 ui.button(icon='close', on_click=help_dialog.close).props('flat round color=white size=sm').classes('opacity-50')
397
398 # Contenido
399 with ui.column().classes('p-8 gap-6'):
400 with ui.column().classes('gap-1'):
401 ui.label('OBJETIVO DEL MÓDULO').classes('text-[9px] font-black text-slate-400 tracking-widest uppercase')
402 ui.label(help_info['objetivo']).classes('text-sm text-slate-700 font-medium leading-relaxed')
403
404 ui.separator().classes('bg-slate-100')
405
406 with ui.column().classes('gap-4'):
407 ui.label('¿CÓMO UTILIZARLO?').classes('text-[9px] font-black text-slate-400 tracking-widest uppercase')
408 for i, paso in enumerate(help_info['pasos'], 1):
409 with ui.row().classes('items-start gap-4 flex-nowrap'):
410 ui.label(str(i)).classes('bg-blue-600 text-white w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-black shrink-0 mt-0.5')
411 ui.label(paso).classes('text-xs text-slate-600 leading-normal')
412
413 ui.button('ENTENDIDO, VOLVER AL MÓDULO', on_click=help_dialog.close).props('unelevated color=slate-900 rounded-xl').classes('w-full font-black py-3 mt-4')
414 help_dialog.open()
415
416 ui.button(icon='info', on_click=show_help).props('flat round color=white').classes('opacity-80 hover:opacity-100 transition-opacity')
417 with ui.tooltip().classes('text-xs font-bold bg-slate-900'):
418 ui.label(f'Guía de {help_info["modulo"]}')
419
420 ui.button('SALIR', icon='power_settings_new', on_click=logout).props('flat no-caps color=white rounded-xl bg-white/10').classes('px-3 text-[10px] font-black tracking-widest')
421
422
423 # Barra lateral con efecto Glassmorphism y modo Mini (REPLEGABLE)
424 drawer_props = 'bordered width=300 mini-width=80 mini behavior=desktop breakpoint=700'
425 if is_tecnico and not is_manager_role: drawer_props += ' no-swipe-open no-swipe-close'
426
427 # El menú inicia SIEMPRE en mini mode
428 drawer_init_state = (not is_tecnico or is_manager_role)
429
430 with ui.left_drawer(value=drawer_init_state).classes('bg-white/70 backdrop-blur-xl border-r border-slate-200/50 h-full relative z-40' + (' hidden' if is_tecnico and not is_manager_role else '')).props(drawer_props) as self.left_drawer:
431 with ui.scroll_area().classes('w-full h-full relative z-50 overscroll-contain').props(':thumb-style="{ width: \\"2px\\", background: \\"transparent\\", opacity: 0 }" :bar-style="{ width: \\"2px\\", background: \\"transparent\\", opacity: 0 }"'):
432 with ui.column().classes('pl-0 pr-1 pt-2 pb-32 w-full gap-0 drawer-nav-container'):
433
434 def nav_link(title, icon, url, allowed_roles=["Administrador"]):
435 if rol in allowed_roles or "Todos" in allowed_roles:
436 is_active = current_path == url
437 active_classes = 'bg-blue-50/50 text-blue-600 shadow-sm' if is_active else 'text-slate-600 hover:text-blue-600'
438
439 with ui.link(target=url).classes(f'w-full no-underline transition-all group {active_classes} rounded-xl hover:shadow-[0_4px_15px_rgba(59,130,246,0.25)]'):
440 with ui.row().classes('items-center gap-2 py-3 pl-0 pr-3 rounded-xl group-hover:bg-blue-100/30 w-full flex-nowrap q-mini-drawer-justify-center'):
441 ui.icon(icon, size='sm').classes(f'flex-shrink-0 transition-colors {"text-blue-600" if is_active else "text-slate-400 group-hover:text-blue-600"}')
442 ui.label(title).classes(f'font-bold text-[12px] tracking-wide whitespace-nowrap overflow-hidden truncate leading-tight text-left flex-1 q-mini-drawer-hide {"text-blue-600" if is_active else "text-slate-700 group-hover:text-blue-600"}')
443
444 from contextlib import contextmanager
445
446 menu_instances = []
447 def open_exclusive(target_menu):
448 for m in menu_instances:
449 if m != target_menu:
450 try: m.close()
451 except: pass
452 try: target_menu.open()
453 except: pass
454
455 @contextmanager
456 def nav_expansion(title, icon, module_id, allowed_roles=["Administrador"], color=None, items_urls=[]):
457 is_open = any(url == current_path for url in items_urls)
458 if rol in allowed_roles or "Todos" in allowed_roles:
459 # Hide "personal" on mobile ONLY if user is not in management roles
460 is_tecnico_ui = is_tecnico and not is_manager_role
461 mobile_hide = "hidden md:flex" if module_id == "personal" and not is_manager_role else ""
462
463 is_open = any(url == current_path for url in items_urls)
464 active_bg = "bg-blue-50/50 shadow-sm" if is_open else "hover:bg-slate-100"
465 active_text = "text-blue-600 font-black" if is_open else "text-slate-600 group-hover:text-blue-600"
466 icon_color = "text-blue-600" if is_open else "text-slate-400 group-hover:text-blue-600"
467
468 if color == "violet":
469 active_bg = "bg-violet-50 border border-violet-100" if is_open else "hover:bg-violet-50/50"
470 active_text = "text-violet-700 font-black" if is_open else "text-violet-600 group-hover:text-violet-800"
471 icon_color = "text-violet-500" if is_open else "text-violet-400"
472
473 if is_tecnico_ui:
474 # Para rol técnico, no usamos menú flotante, mostramos directamente como bloque de accesos rápidos
475 with ui.column().classes(f'w-full gap-1 p-2 bg-slate-50/50 border border-slate-100 rounded-2xl mb-3 {mobile_hide} shadow-sm group/header'):
476 with ui.row().classes('w-full items-center gap-2 mb-1 pl-0.5 pr-1 justify-between flex-nowrap'):
477 with ui.row().classes('items-center gap-2 overflow-hidden flex-nowrap'):
478 ui.icon(icon, size='xs').classes('text-slate-400 flex-shrink-0')
479 ui.label(title).classes('text-[10px] font-black tracking-[0.1em] uppercase text-slate-500 truncate')
480 ui.icon('expand_more', size='14px').classes('text-slate-300 flex-shrink-0 transition-transform group-hover/header:translate-y-0.5')
481 with ui.column().classes('w-full gap-0.5') as flat_container:
482 yield flat_container
483 else:
484 # Modal/Flyout approach to avoid DOM reflows
485 btn_row = ui.row().classes(f'w-full flex-nowrap items-center gap-2 py-3 pl-0 pr-3 transition-all rounded-xl cursor-pointer group {active_bg} {active_text} {mobile_hide} hover:shadow-[0_4px_15px_rgba(59,130,246,0.25)] q-mini-drawer-justify-center')
486 with btn_row:
487 ui.icon(icon, size='sm').classes(f'flex-shrink-0 transition-all {icon_color}')
488 ui.label(title).classes(f'font-bold text-[12px] tracking-wide flex-1 whitespace-nowrap overflow-hidden truncate leading-tight text-left q-mini-drawer-hide {active_text}')
489 ui.icon('chevron_right', size='16px').classes(f'transition-all duration-300 flex-shrink-0 q-mini-drawer-hide {icon_color}')
490
491 # Despliegue fijo al lado derecho (SIN SCROLL HORIZONTAL)
492 with ui.menu().props('anchor="top right" self="top left" :offset="[10, 0]" auto-close transition-show="scale" transition-hide="fade"').classes(
493 'p-1 bg-white/95 backdrop-blur-xl border border-slate-200/60 rounded-xl shadow-xl min-w-[220px] overflow-x-hidden'
494 ) as flyout_menu:
495 yield flyout_menu
496
497 menu_instances.append(flyout_menu)
498 btn_row.on('click', lambda _, m=flyout_menu: open_exclusive(m))
499 flyout_menu.on('mouseleave', flyout_menu.close)
500 else:
501 yield None
502
503
504
505 def sub_nav_item(title, target, expansion_context=None):
506 if expansion_context is not None:
507 is_active = current_path == target
508 active_classes = 'text-blue-600 bg-blue-50/80 font-black shadow-inner shadow-blue-500/10' if is_active else 'text-slate-600 hover:text-blue-600 hover:bg-slate-50'
509
510 with ui.link(target=target).classes(f'no-underline w-full group flex flex-nowrap items-center justify-start gap-3 py-2.5 px-3 rounded-xl transition-all duration-300 hover:scale-[1.02] hover:shadow-[0_4px_12px_rgba(59,130,246,0.3)] origin-left overflow-hidden {active_classes} q-mini-drawer-justify-center'):
511 ui.icon('play_arrow' if is_active else 'chevron_right', size='12px').classes(f'flex-shrink-0 transition-colors {"text-blue-600" if is_active else "text-slate-300 group-hover:text-blue-600"}')
512 ui.label(title).classes(f'text-[12px] font-bold whitespace-nowrap truncate flex-1 block min-w-0 q-mini-drawer-hide {"text-blue-600" if is_active else "text-slate-700 group-hover:text-blue-600"}')
513
514 nav_schema = get_navigation_schema(rol)
515
516 current_category = None
517 for item in nav_schema:
518 # Dibujar el encabezado de categoría si cambia
519 item_category = item.get('category')
520 if item_category and item_category != current_category:
521 with ui.row().classes('w-full items-center'):
522 ui.label(item_category.upper()).classes('text-[10px] font-black tracking-[0.25em] text-slate-300 mt-6 mb-2 pl-0 pr-3 w-full border-b border-slate-50 pb-1 q-mini-drawer-hide uppercase')
523 ui.separator().classes('q-mini-drawer-only w-8 mx-auto bg-slate-200 mt-8 mb-4 h-[1px] shadow-sm')
524 current_category = item_category
525
526 if item['type'] == 'link':
527 # Normalizar URL del item para comparación robusta
528 item_url = item['url']
529 if item_url != "/" and item_url.endswith('/'): item_url = item_url[:-1]
530
531 nav_link(item['title'], item['icon'], item_url, allowed_roles=["Todos"])
532 elif item['type'] == 'expansion':
533 urls = [it[1] for it in item['items']]
534 # Normalizar URLs de la expansión
535 norm_urls = [u[:-1] if u != "/" and u.endswith('/') else u for u in urls]
536 with nav_expansion(item['title'], item['icon'], item['id'], allowed_roles=["Todos"], color=item.get('color'), items_urls=norm_urls) as exp:
537 for label, url, _ in item['items']:
538 sub_nav_item(label, url, exp)
539
540 with ui.column().classes('py-3 absolute bottom-0 left-0 w-full border-t border-slate-200/50 bg-white z-[60] shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.05)] items-center justify-center'):
541 # Preparar datos de perfil y función de diálogo
542 uid = user.get('id', nombre.replace(' ', '_').lower())
543 avatar_filename = f"{uid}.png"
544 avatar_fs_path = os.path.join(STATIC_PATH, 'avatars', avatar_filename)
545 avatar_url_path = f"static/avatars/{avatar_filename}"
546 has_avatar = os.path.exists(avatar_fs_path)
547
548 def show_avatar_dialog():
549 with ui.dialog() as d, ui.card().classes('p-8 rounded-[2rem] shadow-2xl text-center flex flex-col items-center max-w-sm w-full'):
550 if has_avatar:
551 # Cache bust usando mtime real del sistema de archivos
552 ui.image(f"/{avatar_url_path}?v={int(os.path.getmtime(avatar_fs_path))}").classes('w-32 h-32 rounded-full border-[6px] border-white shadow-xl object-cover mb-4 mx-auto')
553 else:
554 with ui.column().classes('bg-blue-50 p-4 rounded-full mx-auto mb-2 border-4 border-white shadow-sm'):
555 ui.icon('face', size='4xl', color='primary')
556 ui.label('Actualizar Fotografía').classes('text-xl font-black text-slate-800 tracking-tighter text-center w-full block')
557 ui.label('Sube una imagen o elige un avatar predeterminado.').classes('text-sm text-slate-500 mb-4 font-medium leading-tight text-center w-full block')
558
559 def handle_preset(preset_filename):
560 import shutil
561 try:
562 src = os.path.join(STATIC_PATH, 'avatars', 'presets', preset_filename)
563 os.makedirs(os.path.dirname(avatar_fs_path), exist_ok=True)
564 shutil.copy(src, avatar_fs_path)
565 ui.notify('¡Avatar animado seleccionado!', type='positive', icon='check_circle')
566 ui.timer(1.0, lambda: ui.navigate.to(current_path))
567 except Exception as e:
568 ui.notify(f'Error al aplicar el avatar: {e}', type='negative')
569
570 ui.label('AVATARES SUGERIDOS (ESTILO PELÍCULA)').classes('text-[10px] font-black tracking-[0.2em] text-slate-400 mb-2 mt-2 text-center w-full block')
571 with ui.row().classes('justify-center gap-3 mb-6 w-full flex-wrap items-center'):
572 import glob
573 preset_pattern = os.path.join(STATIC_PATH, 'avatars', 'presets', 'preset_*.png')
574 preset_files = [os.path.basename(f) for f in glob.glob(preset_pattern)]
575 for preset_filename in preset_files:
576 img_url = f"/static/avatars/presets/{preset_filename}"
577 preset_fs_path = os.path.join(STATIC_PATH, 'avatars', 'presets', preset_filename)
578 is_selected = has_avatar and os.path.exists(avatar_fs_path) and os.path.getsize(preset_fs_path) == os.path.getsize(avatar_fs_path)
579
580 base_classes = 'rounded-full cursor-pointer transition-all bg-white flex-shrink-0 object-cover '
581 if is_selected:
582 base_classes += 'w-16 h-16 border-4 border-blue-500 shadow-[0_0_20px_rgba(59,130,246,0.6)] scale-110 z-10'
583 else:
584 base_classes += 'w-14 h-14 border-2 border-slate-100 hover:scale-110 hover:shadow-xl hover:border-blue-200'
585 ui.image(img_url).classes(base_classes).on('click', lambda _, f=preset_filename: handle_preset(f))
586
587 def handle_upload(e):
588 os.makedirs(os.path.dirname(avatar_fs_path), exist_ok=True)
589 with open(avatar_fs_path, 'wb') as f:
590 f.write(e.content.read())
591 d.close()
592 ui.notify('¡Fotografía local actualizada!', type='positive', icon='check_circle')
593 ui.timer(1.0, lambda: ui.navigate.to(current_path))
594
595 ui.label('O SUBE UNA FOTO LOCAL').classes('text-[10px] text-center w-full font-black tracking-[0.2em] text-slate-400 mt-4 mb-2 block')
596 ui.upload(on_upload=handle_upload, auto_upload=True, max_files=1, label="Seleccionar foto de disco").props('accept="image/*" color="primary" flat no-thumbnails').classes('w-full border-2 border-slate-100 bg-slate-50 hover:bg-slate-100 transition-colors shadow-none rounded-2xl mx-auto overflow-hidden text-center')
597 ui.button('CERRAR', on_click=d.close).props('flat text-color=slate-400').classes('font-bold px-4 w-full mt-2')
598 d.open()
599
600 # --- BLOQUE DE PERFIL CLICKEABLE ---
601 with ui.row().classes('w-full items-center justify-center gap-3 mb-1 cursor-pointer hover:bg-slate-50/50 p-1.5 rounded-[1.25rem] transition-all group/profile q-mini-drawer-justify-center').on('click', show_avatar_dialog):
602 def get_role_color(r_str):
603 r = str(r_str).lower()
604 if 'admin' in r or 'gerent' in r: return 'blue-600'
605 if 'sst' in r or 'seguridad' in r: return 'emerald-500'
606 return 'blue-500'
607
608 role_color = get_role_color(rol)
609
610 if has_avatar:
611 with ui.avatar(color=role_color, text_color='white').classes('w-10 h-10 shadow-lg flex-shrink-0 overflow-hidden p-0 ring-2 ring-white shadow-blue-500/10'):
612 ui.image(f'/{avatar_url_path}?v={int(os.path.getmtime(avatar_fs_path))}').classes('w-full h-full object-cover scale-110 shadow-inner')
613 else:
614 iniciales = "".join([w[0].upper() for w in nombre.split()[:2]]) if nombre else "U"
615 ui.avatar(iniciales, color=role_color, text_color='white').classes('w-10 h-10 shadow-md flex-shrink-0 text-[11px] font-black text-white')
616
617 with ui.column().classes('gap-0 flex-1 overflow-hidden ml-1 q-mini-drawer-hide'):
618 ui.label(nombre).classes('text-sm font-black text-slate-800 leading-tight mb-0.5 group-hover/profile:text-primary transition-colors')
619 if str(nombre).lower() != str(rol).lower():
620 ui.label(rol).classes(f'text-[10px] uppercase font-black tracking-[0.1em] text-{role_color} opacity-70')
621
622 with ui.row().classes('w-full px-2 mt-2 gap-2 q-mini-drawer-hide'):
623 ui.button('CERRAR SESIÓN', icon='logout', on_click=logout).props('flat color=rose-500 size=xs rounded-xl w-full').classes('font-black text-slate-400 hover:text-rose-600 transition-colors py-2')
624
625 ui.separator().classes('mb-2 bg-slate-100/50 q-mini-drawer-hide')
626 # Botón de Descargar App (OCULTO a gerentes, o si ya se descargó. Visible en móvil)
627 def on_download_apk():
628 ui.run_javascript('localStorage.setItem("SelectosAPK_Downloaded", "yes");')
629 if self.btn_apk:
630 self.btn_apk.classes('hidden')
631 ui.notify('Iniciando descarga...', type='positive')
632 ui.navigate.to('/descargar/apk')
633
634 self.btn_apk = ui.button('Descargar APK', icon='android', on_click=on_download_apk).props('unelevated color=emerald-600 rounded-xl w-full').classes('font-black mb-1 shadow-lg shadow-emerald-500/30 text-white hidden q-mini-drawer-hide')
635
636 # Inyectar Chat Flotante Global (Fijo a Esquina Inferior Derecha)
637 if render_floating_chat:
638 try:
639 render_floating_chat()
640 except Exception as e:
641 import traceback
642 print(f"DEBUG CHAT: Error rendering floating chat: {e}")
643 traceback.print_exc() # APK Download Dialog for Mobile
644 with ui.dialog() as apk_dialog, ui.card().classes('p-8 items-center text-center rounded-[2rem] shadow-2xl max-w-sm'):
645 with ui.column().classes('p-4 bg-emerald-50 rounded-full mb-2 border-4 border-emerald-100'):
646 ui.icon('android', size='5xl', color='emerald-500')
647 ui.label('¡Descubre la App Móvil!').classes('text-2xl font-black text-slate-800 tracking-tighter')
648 ui.label('Instala Selectos ProManager en tu celular para acceder a reportes rápidos, código QR, y notificaciones PUSH operativas.').classes('text-sm text-slate-500 my-2 font-medium')
649
650 with ui.row().classes('w-full justify-center gap-2 mt-4 flex-nowrap'):
651 def on_dialog_download():
652 ui.run_javascript('localStorage.setItem("SelectosAPK_Downloaded", "yes");')
653 if self.btn_apk:
654 self.btn_apk.classes('hidden')
655 apk_dialog.close()
656 ui.notify('Iniciando descarga de Selectos ProManager APK (v1.0.3)...', type='positive', icon='download', position='top')
657 ui.navigate.to('/descargar/apk')
658
659 ui.button('MÁS TARDE', on_click=apk_dialog.close).props('flat text-color=slate-400').classes('font-bold px-4')
660 ui.button('DESCARGAR APK', on_click=on_dialog_download).props('unelevated color=emerald-600 rounded-xl').classes('font-black px-4 shadow-lg shadow-emerald-500/30 text-[11px] whitespace-nowrap')
661
662 # Mobile detection injection for both Dialog and Button
663 async def check_mobile():
664 try:
665 # Si es administrador/gerente, NUNCA se le muestra la opción de instalar APK Android
666 if is_manager(rol):
667 return
668
669 is_mobile = await ui.run_javascript('return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);', timeout=2.0)
670 if is_mobile:
671 # Si ya ha descargado la aplicación previamente en este navegador, tampoco lo molestamos
672 has_downloaded = await ui.run_javascript('return localStorage.getItem("SelectosAPK_Downloaded") === "yes";', timeout=2.0)
673 if has_downloaded:
674 return
675
676 # Mostrar el botón lateral permanente
677 if self.btn_apk:
678 self.btn_apk.remove_classes('hidden')
679
680 # Mostrar el diálogo invasivo de bienvenida solo la primera vez que entra y no lo ha cerrado/descargado
681 has_viewed = await ui.run_javascript('return localStorage.getItem("SelectosAPK_Prompt_Viewed") === "yes";', timeout=2.0)
682 if not has_viewed:
683 await ui.run_javascript('localStorage.setItem("SelectosAPK_Prompt_Viewed", "yes");', timeout=1.0)
684 apk_dialog.open()
685 except Exception:
686 pass
687
688 def safe_check_mobile():
689 try:
690 asyncio.create_task(check_mobile())
691 except Exception:
692 pass
693 ui.timer(1.5, safe_check_mobile, once=True)
694 # --- NAVEGACIÓN INFERIOR (MÓVIL / TABLET) ---
695 with ui.row().classes('fixed bottom-0 left-0 w-full bg-white z-[80] md:hidden shadow-[0_-5px_20px_-5px_rgba(0,0,0,0.1)] border-t border-slate-100 pt-2 pb-safe justify-around items-center px-1 touch-none'):
696 def mobile_nav_btn(icon, label, target):
697 active = current_path == target
698 base_c = 'bg-blue-50/80 text-primary scale-105' if active else 'text-slate-400 opacity-80'
699 with ui.column().classes(f'items-center justify-center p-2 rounded-2xl transition-all cursor-pointer flex-1 {base_c}').on('click', lambda: ui.navigate.to(target)):
700 ui.icon(icon, size='24px')
701 ui.label(label).classes(f'text-[9px] font-black uppercase mt-1 tracking-widest text-center leading-none {"text-primary" if active else "text-slate-500"}')
702
703 is_tecnico_mobile = any(r in str(rol).lower() for r in ['operativo', 'técnico', 'tecnico', 'auxiliar', 'asistente', 'supervisor'])
704
705 if is_tecnico_mobile:
706 from services.navigation import MANAGER_ROLES
707 cleaned_rol = str(rol).strip()
708 is_manager_role = any(m.lower() == cleaned_rol.lower() for m in MANAGER_ROLES)
709 # print(f"DEBUG ROLE: '{cleaned_rol}' -> Is Manager: {is_manager_role}")
710
711 # Solo los managers ven el dashboard de proyectos completo; el resto va al formulario
712 target_avance = '/proyectos' if is_manager_role else '/proyectos/avance-operativo'
713
714 mobile_nav_btn('home', 'Inicio', '/')
715 mobile_nav_btn('architecture', 'Avance', target_avance)
716 mobile_nav_btn('security', 'SST', '/sst/industrial/hub')
717 mobile_nav_btn('inventory_2', 'Pedidos', '/proyectos/materiales/solicitud')
718 mobile_nav_btn('timer', 'Fichar', '/personal/asistencia')
719 else:
720 mobile_nav_btn('home', 'Inicio', '/')
721 mobile_nav_btn('engineering', 'Obras', '/proyectos')
722 mobile_nav_btn('security', 'SST', '/sst/industrial/hub')
723 mobile_nav_btn('folder_shared', 'Docs', '/documentos/consultar')
724
725 # --- SISTEMA DE PÁNICO Y ALERTA (MÓDULO INDEPENDIENTE) ---
726 if init_sos_system:
727 try:
728 init_sos_system(nombre, rol, is_tecnico, is_manager_role)
729 except Exception as e:
730 print(f"Error al inicializar el Sistema SOS: {e}")
731
732 self.content = ui.column().classes('w-full xl:max-w-[1800px] 2xl:max-w-[98%] mx-auto p-3 sm:px-8 sm:py-4 mb-24 pb-8 md:pb-0')
733 return self.content.__enter__()
734
735 def __exit__(self, *_):
736 if self.content:
737 self.content.__exit__(*_)
738 