CoolFace
Apppublic

fmatituy/selectospromanager

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
layout.py738 linesDownload Raw Back to modules
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