CoolFace
Apppublic

fmatituy/selectospromanager

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
constructor_components.py746 linesDownload Raw Back to sst
1from nicegui import ui2import asyncio3import json4from .ai_assistant import PermisosAIAssistant5from database.queries_ai import save_formato_generado6from services.auth import get_current_user7sara_engine = PermisosAIAssistant()8 9async def improve_with_sara(state, k):10    """Interfaz para invocar a SARA IA y optimizar la descripción actual."""11    current = state.form_data.get(k, '')12    if not current or len(current.strip()) < 5:13        ui.notify('Escribe una base o selecciona una actividad primero para que SARA pueda optimizarla.', color='warning', icon='info', position='top')14        return15    16    with ui.dialog().classes('backdrop-blur-sm') as dialog, ui.card().classes('p-10 w-full max-w-md rounded-[2.5rem] shadow-2xl border-none'):17        with ui.column().classes('w-full items-center text-center gap-1 mb-6'):18            ui.icon('auto_fix_high', size='3rem', color='blue-9').classes('mb-2 animate-pulse')19            ui.label('OPTIMIZACIÓN SARA IA').classes('text-2xl font-black text-slate-900 tracking-tighter uppercase')20            ui.label('Refina tu descripción técnica al instante').classes('text-[10px] text-slate-400 font-black tracking-widest uppercase opacity-60')21        22        ui.label('¿CÓMO QUIERES MEJORAR EL TEXTO?').classes('text-[9px] font-black text-blue-900 mb-2 tracking-widest')23        inst = ui.input(placeholder='Ej: "Más técnico", "Más corto", "Enfatizar riesgos"...').props('outlined dense rounded-xl bg-slate-50 shadow-inner').classes('w-full font-bold text-sm mb-6')24        25        async def process():26            dialog.close()27            notif = ui.notification('SARA IA está analizando y optimizando...', spinner=True, timeout=0, color='blue-9')28            try:29                # Llamada asíncrona al motor real (Gemini/Ollama)30                improved = await sara_engine.improve_description(current, inst.value)31                state.form_data[k] = improved32                if k in state.field_refs: safe_set_value(state.field_refs[k], improved)33                ui.notify('Descripción refinada exitosamente', color='positive', icon='verified', position='top')34            except Exception as e:35                ui.notify(f'Error en el motor IA: {e}', color='negative')36            finally:37                notif.dismiss()38                39        with ui.row().classes('w-full gap-3 mt-4 mt-auto'):40            ui.button('CANCELAR', on_click=dialog.close).props('flat color=slate-400 font-bold').classes('flex-1')41            ui.button('OPTIMIZAR AHORA', icon='bolt', on_click=process).props('unelevated color=blue-900 rounded-xl py-3 font-black').classes('flex-[2] shadow-xl shadow-blue-100')42    dialog.open()43 44def safe_set_value(ref, val):45    if not ref: return46    try:47        if hasattr(ref, 'options'):48            if val is None or val == '':49                try: ref.set_value(None)50                except: pass51            elif val in ref.options:52                try: ref.set_value(val)53                except: pass54            else:55                try: ref.set_value(None)56                except: pass57        else:58            try: ref.set_value(val if val is not None else '')59            except: pass60    except Exception as e:61        print(f"DEBUG safe_set_value Error: {e}")62 63def render_checklist_seguridad(state, keys, get_nicer_label, show_na=True):64    """Checklist institucional SI/NO con soporte para observaciones."""65    with ui.column().classes('w-full border border-slate-100 rounded-xl overflow-hidden bg-white shadow-sm gap-0 mt-2'):66        for idx, k in enumerate(keys):67            if k == 'e_obs':68                with ui.column().classes('w-full p-6 bg-blue-50/20 border-t border-slate-100 gap-2'):69                    ui.label('OBSERVACIONES ADICIONALES').classes('text-[10px] font-black text-blue-900 tracking-widest')70                    state.field_refs[k] = ui.textarea(placeholder='Escribe aquí las observaciones...').props('outlined autogrow').classes('w-full bg-white text-xs rounded-xl shadow-inner').bind_value(state.form_data, k)71                continue72            73            bg = 'bg-slate-50/50' if idx % 2 == 0 else 'bg-white'74            label_text = get_nicer_label(k)75            is_other = 'OTRO' in label_text.upper() or 'CUÁL' in label_text.upper()76            77            with ui.row().classes(f'w-full {bg} border-b border-slate-50 px-4 sm:px-6 py-2 items-center justify-start hover:bg-blue-50/30 transition-colors gap-10'):78                with ui.column().classes('flex-1 gap-1'):79                    ui.label(label_text).classes('text-xs font-bold text-slate-700 leading-tight')80                    if is_other:81                        with ui.row().classes('w-full items-center gap-2'):82                            ui.label('¿Cuál?').classes('text-[10px] text-slate-400 font-black italic')83                            state.field_refs[f'txt_{k}'] = ui.input(placeholder='Especificar detalles...').props('outlined dense borderless').classes('flex-1 bg-white/50 text-[11px] rounded-lg px-2 h-7 font-medium border-none shadow-inner').bind_value(state.form_data, f'txt_{k}')84                85                with ui.row().classes('items-center gap-6 shrink-0 bg-white/60 px-4 py-2 rounded-xl border border-blue-50/50 shadow-sm'):86                    ui.checkbox('SÍ').bind_value(state.form_data, f"si_{k}").props('color=blue-9 rounded dense').classes('text-[10px] font-black text-blue-900')87                    ui.checkbox('NO').bind_value(state.form_data, f"no_{k}").props('color=red-9 rounded dense').classes('text-[10px] font-black text-red-900')88                    if show_na:89                        ui.checkbox('N/A').bind_value(state.form_data, f"na_{k}").props('color=slate-6 rounded dense').classes('text-[10px] font-black text-slate-500')90 91def render_galeria_seleccionable(state, keys, get_nicer_label):92    """Galería de tarjetas de equipos con carga de imágenes reales."""93    with ui.element('div').classes('grid grid-cols-2 md:grid-cols-4 gap-6 w-full mt-4'):94        for k in keys:95            label = get_nicer_label(k)96            icons = {97                'eq_and': 'layers', 'eq_ele': 'elevator', 'eq_man': 'forklift', 'eq_tij': 'vertical_align_top',98                'eq_ext': 'linear_scale', 'eq_avi': 'stairs', 'eq_gat': 'reorder', 'eq_eti': 'format_line_spacing'99            }100            icon = icons.get(k, 'construction')101            img_key = f'img_{k}'102            with ui.card().classes('group cursor-pointer p-0 overflow-hidden border border-slate-100 rounded-[2.5rem] shadow-sm hover:shadow-2xl hover:translate-y-[-8px] transition-all duration-500 bg-white relative') as card:103                # Contenedor para el evento de click104                click_target = ui.element('div').classes('absolute inset-0 z-10')105                106                # Checkbox de selección visible sin fondo107                cb = ui.checkbox().bind_value(state.form_data, f'si_{k}').props('color=blue-9 rounded dense').classes('absolute top-5 right-5 z-30 scale-125 transition-transform hover:scale-150')108                109                click_target.on('click', lambda _, c=cb: c.set_value(not c.value))110                111                # Contenedor de Imagen o Icono con Formato Vertical112                with ui.element('div').classes('w-full aspect-[4/5] md:aspect-[3/4] relative overflow-hidden bg-slate-50 border-b border-slate-100/50'):113                    # Fondo base (Icono Minimalista)114                    with ui.column().classes('absolute-full items-center justify-center pointer-events-none'):115                        ui.icon(icon, size='18px').classes('text-blue-100 opacity-20 mt-4 scale-150')116                    117                    # Elemento imagen con VINCULACIÓN REACTIVA PERMANENTE118                    # --- SINCRONIZACIÓN AUTOMÁTICA CON INVENTARIO ---119                    current_img = state.form_data.get(img_key, '')120                    if not current_img:121                        try:122                            from database.queries import get_herramientas123                            from core.config import STATIC_PATH124                            all_tools = get_herramientas()125                            126                            # Búsqueda más flexible (coincidencia de palabras clave)127                            norm_label = label.lower().strip()128                            tool_match = next((t for t in all_tools if norm_label in t['nombre'].lower() or t['nombre'].lower() in norm_label), None)129                            130                            if tool_match and tool_match.get('foto_url'):131                                import os, base64, io132                                from PIL import Image, ImageDraw133                                f_url = tool_match['foto_url']134                                # Convertir URL de static a ruta local absoluta135                                sub_path = f_url.replace('/static/', '').lstrip('/')136                                abs_path = os.path.join(STATIC_PATH, sub_path)137                                138                                if os.path.exists(abs_path):139                                    with open(abs_path, 'rb') as f_img:140                                        b64_raw = base64.b64encode(f_img.read()).decode('utf-8')141                                        state.form_data[img_key] = f'data:image/png;base64,{b64_raw}'142                                    143                                    # Ya no generamos X para el UI, el backend lo estampa nativamente en excel.144                                    state.save_to_disk() # Persistir sincronización145                        except Exception as e_sync:146                            print(f"DEBUG SST Sync Error: {e_sync}")147                    148                    img_display = ui.image('').classes('absolute-full w-full h-full object-contain group-hover:scale-105 transition-transform duration-1000 ease-out')149                    150                    # --- VÍNCULO REACTIVO (SÓLO IMAGEN, SIN SOBRESCRITURA ROJA) ---151                    # El chulo nativo (checkbox) es suficiente en la interfaz visual.152                    img_display.bind_source_from(state.form_data, f'img_{k}')153                    img_display.bind_visibility_from(state.form_data, f'img_{k}', backward=lambda x: bool(x))154 155 156                    # Overlay Degradado para legibilidad (Hover)157                    ui.element('div').classes('absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity z-10')158                159                # Footer de la tarjeta (Glassmorphism Effect)160                with ui.row().classes('w-full p-6 items-center justify-between relative z-30 bg-white/70 backdrop-blur-xl'):161                    ui.label(label).classes('text-[13px] font-black text-slate-900 uppercase tracking-tight leading-none pointer-events-none flex-1')162                    163                    # Botón para subir imagen con Generación de Copia para Reporte (X)164                    async def handle_img_upload(e, display=img_display, ref_key=img_key, lbl=label, up=None):165                        import base64, io, os166                        from PIL import Image, ImageDraw167                        try:168                            # 1. Procesar contenido recibido169                            content = await e.file.read()170                            if not content: return171                            172                            # Generar NOMBRE ÚNICO y RUTA LOCAL para persistencia física173                            import time174                            from core.config import STATIC_PATH175                            os.makedirs(os.path.join(STATIC_PATH, 'equipo'), exist_ok=True)176                            fname = f"eq_{int(time.time())}_sst_{e.name}"177                            fpath = os.path.join(STATIC_PATH, 'equipo', fname)178                            179                            with open(fpath, 'wb') as f:180                                f.write(content)181                            182                            uri_publico = f'/static/equipo/{fname}'183                            184                            # 2. Guardar IMAGEN NORMAL en el ESTADO del Formulario185                            b64 = base64.b64encode(content).decode('utf-8')186                            uri_b64 = f'data:image/png;base64,{b64}'187                            state.form_data[ref_key] = uri_b64188                            189                            # 3. --- SINCRONIZACIÓN HACIA INVENTARIO (Global Sync) ---190                            try:191                                from database.queries import get_herramientas, update_herramienta192                                all_tools = get_herramientas()193                                norm_lbl = label.lower().strip()194                                # Buscar el activo correspondiente por nombre195                                tool_match = next((t for t in all_tools if norm_lbl in t['nombre'].lower() or t['nombre'].lower() in norm_lbl), None)196                                197                                if tool_match:198                                    # Actualizamos la foto oficial en el inventario maestro199                                    update_herramienta(tool_match['id'], {'foto_url': uri_publico})200                                    ui.notify(f'INVENTARIO ACTUALIZADO: {tool_match["nombre"]}', type='info', icon='sync_alt')201                            except Exception as e_db:202                                print(f"DEBUG SST -> INV Sync Error: {e_db}")203 204                            # Generar Copia X fue retirado (Backend se encarga con la marca negra)205                            # Finalizar UI206                            display.set_source(uri_b64)207                            display.set_visibility(True)208                            state.save_to_disk()209                            ui.notify(f'EVIDENCIA REGISTRADA: {lbl}', type='positive', color='blue-9', icon='verified')210                            if up: up.run_method('reset')211                        except Exception as ex:212                            ui.notify(f'Error: {str(ex)}', type='negative')213 214                    # Generamos el cargador (Closure)215                    def create_uploader(d=img_display, r=img_key, l=label):216                        up = ui.upload(auto_upload=True, max_files=1).classes('hidden')217                        up.on_upload(lambda e: asyncio.create_task(handle_img_upload(e, display=d, ref_key=r, lbl=l, up=up)))218                        return up219 220                    up_pick = create_uploader()221                    222                    # Menú dinámico basado en si ya existe una imagen cargada223                    # El valor de img_key es un Base64 que empieza con 'data:image...'224                    has_img = state.form_data.get(img_key, '')225 226                    # Función para Borrar AMBAS versiones de la Imagen y PERSISTIR227                    def handle_delete_img(d=img_display, r=img_key, lbl=label):228                        state.form_data[r] = ''229                        state.form_data[f'{r}_x'] = ''230                        d.set_source('')231                        d.set_visibility(False)232                        state.save_to_disk() # <--- GUARDADO FÍSICO233                        ui.notify(f'EVIDENCIA ELIMINADA: {lbl}', type='warning', color='orange-9', icon='delete')234 235                    # --- CONTROLADOR UNIFICADO Y ULTRA-SUTIL ---236                    # Botón de ajustes - Diseño Minimalista y Siempre Presente237                    with ui.button(icon='settings').props('flat round color=slate-300 size=0.75rem').classes('transition-all hover:rotate-180 hover:color-blue-8 opacity-40 hover:opacity-100') as btn_settings:238                        with ui.menu().classes('rounded-2xl border border-slate-100 shadow-xl p-2') as settings_menu:239                            ui.menu_item('SUBIR / ACTUALIZAR IMAGEN', on_click=lambda u=up_pick: (u.run_method('pickFiles'), settings_menu.close())).classes('text-[10px] font-black text-slate-700 hover:bg-blue-50 hover:text-blue-900 rounded-lg')240                            ui.menu_item('ELIMINAR IMAGEN', on_click=lambda d=img_display, r=img_key, l=label: (handle_delete_img(d, r, l), settings_menu.close())).classes('text-[10px] font-black text-red-700 hover:bg-red-50 hover:text-red-900 rounded-lg mt-1') \241                                .bind_visibility_from(state.form_data, img_key, backward=lambda x: bool(x))242                243 244async def handle_save_draft_action(state, dialog):245    """Guarda el estado actual del formulario como un borrador."""246    from services.auth import get_current_user247    import json248    249    user = get_current_user()250    data_json = json.dumps(state.form_data)251    tipo = state.form_data.get('tipo_permiso', 'PERMISO')252    proyecto_id = state.form_data.get('proyecto_id')253    254    save_formato_generado(255        tipo=tipo,256        url='',257        usuario=user['nombre'] if user else 'SARA IA',258        estado='Borrador (Excede líneas)',259        proyecto_id=proyecto_id,260        datos_json=data_json261    )262    ui.notify('✅ Borrador guardado exitosamente en Historial.', color='positive', icon='check_circle', position='top')263    dialog.close()264 265def check_description_limit(text, template_name, state=None):266    """Valida los renglones de la descripción y abre diálogo si excede 8."""267    if not text: return True268    lines = str(text).strip().count('\n') + 1269    t_name = str(template_name or "").upper()270    271    if 'ARO' not in t_name and 'JSA' not in t_name:272        if lines > 8 and state:273            # Control de instancia única para evitar duplicados274            if getattr(state, '_alerta_excedida_abierta', False):275                return False276            277            state._alerta_excedida_abierta = True278            279            def close_dialog():280                state._alerta_excedida_abierta = False281                diag.close()282 283            with ui.dialog().on('dismiss', lambda: setattr(state, '_alerta_excedida_abierta', False)) as diag:284                with ui.card().classes('p-0 overflow-hidden rounded-[1.5rem] shadow-[0_20px_50px_-15px_rgba(0,0,0,0.4)] border-none').style('width: 360px; background: white;'):285                    # Header286                    with ui.row().classes('w-full bg-[#002855] p-3 px-5 items-center justify-between text-white'):287                        with ui.row().classes('items-center gap-2'):288                            ui.icon('print', size='18px')289                            ui.label('REQUISITO DE IMPRESIÓN').classes('text-[10px] font-black tracking-widest')290                        ui.button(on_click=close_dialog).props('flat round dense icon=close color=white size=sm')291                    292                    with ui.column().classes('p-6 items-center text-center w-full'):293                        # Warning Icon294                        with ui.element('div').classes('w-12 h-12 bg-red-50 rounded-full flex items-center justify-center mb-4'):295                            ui.icon('error', color='red-600', size='28px')296                        297                        ui.label('Descripción Excedida').classes('text-xl font-black text-slate-900 mb-1')298                        ui.label('Para garantizar la legibilidad en el formato físico impreso, la descripción debe tener máximo 8 renglones.').classes('text-xs text-slate-500 leading-relaxed mb-6 px-2')299                        300                        # Data Box301                        with ui.column().classes('w-full bg-slate-50 rounded-xl p-4 mb-6 border border-slate-100'):302                            ui.label(str(lines)).classes('text-4xl font-black text-red-600 leading-none')303                            ui.label('LÍNEAS DETECTADAS EN EL SISTEMA').classes('text-[9px] font-bold text-slate-400 mt-1 tracking-wider')304                            ui.element('div').classes('h-1 bg-red-600 w-full rounded-full mt-3')305                            ui.label('Límite permitido: 8 renglones').classes('text-[9px] text-red-600 font-bold italic mt-1.5')306                        307                        # Primary Action308                        ui.button('ENTENDIDO', on_click=close_dialog) \309                            .props('unelevated rounded-xl py-3 px-6 color=blue-900').classes('w-full font-black text-xs shadow-lg shadow-blue-100 mb-4')310                            311                        # Secondary Action312                        ui.button('GUARDAR BORRADOR', icon='save', on_click=lambda: handle_save_draft_action(state, diag)) \313                            .props('flat dense color=slate-400').classes('text-[10px] font-bold hover:text-slate-600')314            315            diag.open()316            return False317    return True318 319def render_grid_datos_generales(state, cat, keys, lista_clientes, lista_proyectos, lista_empleados, get_nicer_label):320    """Grid de datos técnicos agrupados y reordenados."""321    if cat == "FIRMAS Y AUTORIZACIONES":322        render_firmas_y_autorizaciones(state, lista_empleados, get_nicer_label)323        return324        325    order = ['fecha', 'hora_inicio', 'hora_fin', 'altura_trabajo', 'cliente', 'lugar', 'responsable', 'descripcion_actividad']326    sorted_keys = [k for k in order if k in keys]327    for k in keys:328        if k not in sorted_keys and k not in ['empresa', 'nit_empresa', 'h_iam', 'h_ipm', 'h_fam', 'h_fpm']:329            sorted_keys.append(k)330    with ui.row().classes('w-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-4 px-2'):331        for k in order:332            if k not in keys: continue333            334            # --- CASO ESPECIAL: DESCRIPCIÓN (OCUPA TODO EL ANCHO) ---335            if k in ['descripcion_actividad', 'descripcion_activity']:336                with ui.column().classes('w-full col-span-full gap-2 mt-4').style('grid-column: 1 / -1; width: 100%'):337                    with ui.row().classes('w-full items-center justify-between no-wrap m-0 p-0'):338                        ui.label('DESCRIPCIÓN DE ACTIVIDAD').classes('text-[10px] font-black text-blue-900 tracking-[0.2em] uppercase opacity-80')339                        ui.button('EDITAR CON SARA IA', icon='edit', on_click=lambda k_key=k: improve_with_sara(state, k_key)).props('unelevated rounded dense size=sm color=blue-900 text-white font-black').classes('text-[9px] hover:bg-blue-800 transition-all px-4 py-1.5 shadow-md shadow-blue-900/20 rounded-lg')340                    341                    def update_state_and_validate(e):342                        val = getattr(e, 'value', e.args if hasattr(e, 'args') else "")343                        if not isinstance(val, str): val = ""344                        # Contador real de renglones: splitlines + 1 si termina en salto de línea345                        count = len(val.splitlines()) if val else 0346                        if val.endswith('\n'): count += 1 347                        348                        is_valid = count <= 8 or 'ARO' in str(state.excel_template_name).upper() or 'JSA' in str(state.excel_template_name).upper()349                        350                        if not is_valid:351                            check_description_limit(e.value, state.excel_template_name, state)352                        353                        if 'desc_counter' in state.field_refs:354                            state.field_refs['desc_counter'].text = f"LÍNEAS: {count} / 8"355                            # Sutil y Reactivo: Cambia de gris-claro a rojo vivo al instante356                            state.field_refs['desc_counter'].classes(replace='text-red-600 font-bold opacity-100 scale-105' if not is_valid else 'text-slate-300 font-light opacity-60 scale-100')357                                358                        if 'desc_area' in state.field_refs:359                            state.field_refs['desc_area'].classes(replace='border-red-500 bg-red-50/10 shadow-sm' if not is_valid else 'border-slate-200 bg-white shadow-none')360 361                    current_val = state.form_data.get(k, "")362                    initial_count = len(current_val.splitlines()) if current_val else 0363                    if current_val.endswith('\n'): initial_count += 1364 365                    state.field_refs['desc_area'] = ui.textarea(placeholder='La descripción se cargará automáticamente...') \366                        .props('outlined clearable rows=8 no-resize') \367                        .classes('w-full bg-white text-xs font-bold rounded-xl p-3 border-slate-200 text-slate-700 transition-all duration-300 m-0 hide-scrollbar overflow-hidden') \368                        .style('width: 100% !important; min-width: 100%; height: 160px; max-height: 160px; overflow-y: auto !important; scrollbar-width: none; -ms-overflow-style: none;') \369                        .on_value_change(update_state_and_validate) \370                        .bind_value(state.form_data, k)371                    372                    # Estilo refinado para asegurar que no haya desplazamiento lateral ni visual del scrollbar373                    ui.add_head_html('''374                    <style>375                    .hide-scrollbar textarea {376                        scrollbar-width: none !important;377                        -ms-overflow-style: none !important;378                        overflow-y: auto !important;379                        padding-right: 12px !important; /* Espacio de seguridad para que el texto no toque el borde */380                    }381                    .hide-scrollbar textarea::-webkit-scrollbar {382                        display: none !important;383                        width: 0 !important;384                        height: 0 !important;385                    }386                    /* Evitar que el contenedor de Quasar añada scrolls fantasmas */387                    .hide-scrollbar .q-field__control {388                        overflow: hidden !important;389                    }390                    </style>391                    ''')392                    393                    state.field_refs['desc_counter'] = ui.label(f'LÍNEAS: {initial_count} / 8') \394                        .classes('text-[9px] font-medium text-slate-300 px-2 tracking-widest uppercase ml-auto transition-all duration-200 opacity-60')395                    396                    ui.timer(0.1, lambda: update_state_and_validate(type('obj', (object,), {'value': state.form_data.get(k, "")})), once=True)397                continue398 399            with ui.column().classes('w-full gap-1.5'):400                ui.label(get_nicer_label(k).upper()).classes('text-[9px] font-black text-slate-400 tracking-wider')401                402                if k == 'fecha':403                    state.field_refs[k] = ui.input().props('outlined dense type=date').classes('w-full bg-white rounded-xl font-bold border-slate-200 shadow-sm').bind_value(state.form_data, k)404                elif k == 'hora_inicio':405                    def update_start_period(e):406                        val = e.value407                        if not val: return408                        try:409                            h = int(val.split(':')[0])410                            is_pm = h >= 12411                            state.form_data['h_iam'] = not is_pm412                            state.form_data['h_ipm'] = is_pm413                        except: pass414 415                    with ui.row().classes('w-full items-center gap-2'):416                        state.field_refs[k] = ui.input(on_change=update_start_period).props('outlined dense type=time').classes('flex-1 bg-white rounded-xl font-bold border-slate-200 shadow-sm').bind_value(state.form_data, k)417                        with ui.row().classes('gap-1 items-center bg-slate-50 px-2 rounded-lg border border-slate-100'):418                            state.field_refs['h_iam'] = ui.checkbox('AM').bind_value(state.form_data, 'h_iam').props('dense color=blue-9').classes('text-[9px] font-black')419                            state.field_refs['h_ipm'] = ui.checkbox('PM').bind_value(state.form_data, 'h_ipm').props('dense color=blue-9').classes('text-[9px] font-black')420                elif k == 'hora_fin':421                    def update_end_period(e):422                        val = e.value423                        if not val: return424                        try:425                            h = int(val.split(':')[0])426                            is_pm = h >= 12427                            state.form_data['h_fam'] = not is_pm428                            state.form_data['h_fpm'] = is_pm429                        except: pass430 431                    with ui.row().classes('w-full items-center gap-2'):432                        state.field_refs[k] = ui.input(on_change=update_end_period).props('outlined dense type=time').classes('flex-1 bg-white rounded-xl font-bold border-slate-200 shadow-sm').bind_value(state.form_data, k)433                        with ui.row().classes('gap-1 items-center bg-slate-50 px-2 rounded-lg border border-slate-100'):434                            state.field_refs['h_fam'] = ui.checkbox('AM').bind_value(state.form_data, 'h_fam').props('dense color=blue-9').classes('text-[9px] font-black')435                            state.field_refs['h_fpm'] = ui.checkbox('PM').bind_value(state.form_data, 'h_fpm').props('dense color=blue-9').classes('text-[9px] font-black')436                elif k == 'altura_trabajo':437                    state.field_refs[k] = ui.input(placeholder='0.0').props('outlined dense suffix="Mts"').classes('w-full bg-blue-50/50 text-xs font-black rounded-xl border-blue-100 shadow-sm').bind_value(state.form_data, k)438                else:439                    state.field_refs[k] = ui.input().props('outlined dense').classes('w-full bg-white text-xs font-black rounded-xl border-slate-200 shadow-sm').bind_value(state.form_data, k)440 441def render_firmas_y_autorizaciones(state, keys, get_nicer_label):442    """Panel unificado de firmas y personal operativo."""443    with ui.column().classes('w-full gap-8 mt-6'):444        # --- TABLA DE VERIFICACIÓN Y AUTORIZACIÓN SST (Diseño Tabular) ---445        with ui.card().classes('w-full p-0 border border-slate-200 bg-white rounded-[2rem] shadow-xl overflow-hidden mb-6'):446            with ui.row().classes('w-full bg-[#1e3a8a] p-4 items-center gap-4'):447                ui.icon('verified_user', color='white', size='2rem')448                with ui.column().classes('gap-0'):449                    ui.label('Supervisión técnica y de seguridad en el trabajo').classes('text-lg font-black text-white tracking-tighter uppercase')450            451            with ui.column().classes('w-full overflow-x-auto'):452                # Encabezados de Tabla de Supervisión453                with ui.row().classes('w-full bg-slate-50 border-b-2 border-slate-200 px-6 py-4 items-center hidden md:flex h-16'):454                    ui.label('CARGO / ROL EN SITIO').classes('flex-[2] text-[10px] font-black text-slate-800 uppercase tracking-widest')455                    ui.label('NOMBRE Y APELLIDO COMPLETO').classes('flex-[3] text-[10px] font-black text-slate-800 uppercase tracking-widest px-2')456                    ui.label('CEDULA').classes('flex-[1.5] text-[10px] font-black text-slate-800 uppercase tracking-widest text-center')457                    ui.label('FIRMA DIGITAL').classes('flex-[1.5] text-[10px] font-black text-slate-800 uppercase tracking-widest text-center')458 459                # Datos de los 3 Supervisores460                super_list = [461                    ('sup_aux', 'Supervisor técnico o Auxiliar Encargado'),462                    ('sup_tec', 'Coordinador de Trabajo Seguro en Altura'),463                    ('sup_sst', 'Supervisor de seguridad en el trabajo')464                ]465                for prefix, label in super_list:466                    with ui.row().classes('w-full bg-white border-b border-slate-100 px-6 py-4 items-center flex-col md:flex-row gap-4 md:gap-0 hover:bg-blue-50/30 transition-all'):467                        # 1. Cargo468                        ui.label(label).classes('w-full md:flex-[2] text-[11px] font-black text-blue-900 tracking-tight leading-tight uppercase')469                        470                        # 2. Nombre471                        state.field_refs[f'{prefix}_nombre'] = ui.input() \472                            .props('outlined dense rounded').classes('w-full md:flex-[3] text-[11px] font-bold bg-white/50 border-slate-100 md:mx-2') \473                            .bind_value(state.form_data, f'{prefix}_nombre')474                        475                        # 3. Cédula476                        with ui.column().classes('w-full md:flex-[1.5] items-center'):477                            state.field_refs[f'{prefix}_cc'] = ui.input(placeholder='CC') \478                                .props('outlined dense rounded').classes('w-full text-center text-[11px] font-black text-slate-700 bg-white shadow-inner') \479                                .bind_value(state.form_data, f'{prefix}_cc')480                        481                        # 4. FIRMA482                        with ui.row().classes('w-full md:flex-[1.5] justify-center items-center h-16 md:h-12 border-2 border-dashed border-slate-100 rounded-xl bg-white/30 md:ml-4 overflow-hidden'):483                            @ui.refreshable484                            def render_firma_sup(p=prefix):485                                f_url = state.form_data.get(f'{p}_firma')486                                if f_url: ui.image(f_url).classes('h-10 w-auto object-contain')487                                else: ui.label('FIRMA PENDIENTE').classes('text-[8px] font-black text-slate-200')488                            state.field_refs[f'{prefix}_firma'] = render_firma_sup489                            render_firma_sup()490 491 492        # --- TABLA DE PERSONAL OPERATIVO (REPLICA EXACTA DEL FORMATO EXCEL) ---493        with ui.card().classes('w-full p-0 border border-slate-200 bg-white rounded-[2rem] shadow-xl overflow-hidden mb-6'):494            with ui.row().classes('w-full bg-[#1e3a8a] p-4 items-center gap-4'):495                ui.icon('assignment_ind', color='white', size='2rem')496                with ui.column().classes('gap-0'):497                    ui.label('PERSONAS QUE REALIZARÁN EL TRABAJO').classes('text-lg font-black text-white tracking-tighter uppercase')498                    ui.label('Registro detallado de personal autorizado en sitio').classes('text-[10px] text-blue-200 font-bold uppercase tracking-wider opacity-80')499            500            with ui.column().classes('w-full overflow-x-auto'):501                # Encabezados Técnicos (Replica de Imagen)502                with ui.row().classes('w-full bg-slate-50 border-b-2 border-slate-200 px-6 py-4 items-center hidden lg:flex h-16'):503                    ui.label('#').classes('w-8 text-[10px] font-black text-slate-400')504                    ui.label('NOMBRE Y APELLIDO').classes('flex-[3] text-[10px] font-black text-slate-800 uppercase tracking-widest')505                    ui.label('CEDULA').classes('flex-[1.5] text-[10px] font-black text-slate-800 uppercase tracking-widest px-2')506                    ui.label('FIRMA').classes('flex-[1.5] text-[10px] font-black text-slate-800 uppercase tracking-widest text-center')507                    ui.label('VIGENCIA EXAMENES').classes('flex-1 text-[9px] font-black text-slate-800 uppercase tracking-tighter text-center leading-3')508                    ui.label('VIGENCIA CERTIFICACION').classes('flex-[1.5] text-[9px] font-black text-slate-800 uppercase tracking-tighter text-center leading-3 px-1')509 510                # Filas de Datos (7 espacios requeridos por el usuario)511                for i in range(1, 8):512                    px = f'trab{i}'513                    bg_row = 'bg-white' if i % 2 == 0 else 'bg-slate-50/10'514                    with ui.row().classes(f'w-full {bg_row} border-b border-slate-100 px-4 sm:px-6 py-6 sm:py-4 items-center flex-col lg:flex-row gap-4 lg:gap-0 hover:bg-blue-50/40 transition-all'):515                        with ui.row().classes('w-full lg:w-8 items-center gap-2 lg:block'):516                            ui.label(str(i)).classes('text-[11px] font-bold text-slate-300')517                            ui.label('TRABAJADOR').classes('lg:hidden text-[9px] font-black text-slate-400')518                        519                        # 1. Nombre y Apellido520                        state.field_refs[f'{px}_nombre'] = ui.input(placeholder='NOMBRE COMPLETO') \521                            .props('outlined dense rounded borderless').classes('w-full lg:flex-[3] text-[11px] font-black bg-white/50 border-slate-200') \522                            .bind_value(state.form_data, f'{px}_nombre')523                        524                        # 2. Número de Cédula525                        state.field_refs[f'{px}_cc'] = ui.input(placeholder='CÉDULA') \526                            .props('outlined dense rounded').classes('w-full lg:flex-[1.5] text-[11px] bg-white px-2 lg:mx-1') \527                            .bind_value(state.form_data, f'{px}_cc')528                        529                        # 3. FIRMA (En medio)530                        with ui.row().classes('w-full lg:flex-[1.5] justify-center items-center h-16 lg:h-10 border-2 border-dashed border-slate-100 rounded-xl bg-white/30 lg:mx-2 overflow-hidden'):531                            @ui.refreshable532                            def render_firma_t(p=px):533                                f_url = state.form_data.get(f'{p}_firma')534                                if f_url: 535                                    ui.image(f_url).classes('h-10 w-auto object-contain grayscale opacity-80')536                                else: 537                                    ui.label('Firma Digital').classes('text-[8px] font-black text-slate-200 uppercase tracking-tighter')538                            state.field_refs[f'{px}_firma'] = render_firma_t539                            render_firma_t()540 541                        # 4. Vigencia Exámenes542                        state.field_refs[f'{px}_vig_exa'] = ui.input() \543                            .props('outlined dense rounded placeholder="EXÁMENES DD-MM-AA"').classes('w-full lg:flex-1 text-[10px] bg-white lg:mx-1 text-center font-bold') \544                            .bind_value(state.form_data, f'{px}_vig_exa')545                        546                        # 5. Vigencia Certificación547                        state.field_refs[f'{px}_vig_cer'] = ui.input() \548                            .props('outlined dense rounded placeholder="CERTIF. DD-MM-AA"').classes('w-full lg:flex-1 text-[10px] bg-white lg:mx-1 text-center font-bold') \549                            .bind_value(state.form_data, f'{px}_vig_cer')550 551 552def render_guia_marcadores_compacta(state, get_nicer_label, all_metadata):553    """Guía de marcadores técnica con diseño Navy Slate Moderno."""554    from nicegui import ui555    import asyncio556    557    with ui.column().classes('w-full gap-4'):558        # Header de la Guía bajo el estándar Navy Slate (Diseño Limpio)559        with ui.row().classes('w-full items-center justify-between bg-white p-3 px-6 rounded-2xl border border-slate-100 shadow-sm mb-1'):560            with ui.column().classes('gap-0'):561                ui.label('OPERACIONES SST • REFERENCIA TÉCNICA').classes('text-[8px] font-black text-blue-900/30 tracking-[0.4em] uppercase mb-1')562                with ui.row().classes('items-center gap-2'):563                    ui.icon('hub', color='blue-900', size='15px').classes('opacity-30')564                    ui.label('DICCIONARIO DE CONEXIÓN').classes('text-sm font-black text-slate-800 tracking-tighter')565            566            with ui.row().classes('items-center gap-3 bg-slate-50 p-1 px-4 rounded-xl border border-slate-100'):567                ui.label('PROCESO:').classes('text-[8px] font-black text-slate-400 tracking-[0.2em]')568                opt_permisos = [k for k in all_metadata.keys() if all_metadata[k] and k not in ["Trabajo en Altura", "Trabajo en Alturas"]]569                570                def on_proceso_change():571                    data = all_metadata.get(sel_guia.value, {})572                    cats = ["TODAS"] + list(data.keys())573                    sel_categoria.options = cats574                    sel_categoria.value = "TODAS"575                    render_guia_content.refresh()576 577                sel_guia = ui.select(options=opt_permisos, value='Alturas', on_change=on_proceso_change) \578                    .props('dense borderless size=sm color=blue-900').classes('w-44 text-[10px] font-black text-blue-900')579                580                ui.element('div').classes('w-[1px] h-4 bg-slate-200 mx-1')581                582                ui.label('CATEGORÍA:').classes('text-[8px] font-black text-slate-400 tracking-[0.2em]')583                initial_cats = ["TODAS"] + list(all_metadata.get('Alturas', {}).keys())584                sel_categoria = ui.select(options=initial_cats, value='TODAS', on_change=lambda: render_guia_content.refresh()) \585                    .props('dense borderless size=sm color=blue-900').classes('w-44 text-[10px] font-black text-blue-900')586        587        @ui.refreshable588        def render_guia_content():589            if not sel_guia.value: return590            591            async def handle_copy(e, text):592                ui.run_javascript(f'navigator.clipboard.writeText("{text}")')593                orig_icon = e.sender.props['icon']594                e.sender.props('icon=check color=positive')595                ui.notify(f'Marcador Copiado: {text}', color='positive', icon='content_copy', position='top-right', close_button=True)596                await asyncio.sleep(1.5)597                e.sender.props(f'icon={orig_icon} color=slate-300')598 599            data_permiso = all_metadata.get(sel_guia.value, {})600            601            with ui.scroll_area().classes('w-full h-[65vh] bg-slate-50/20 rounded-3xl border border-slate-100 p-4'):602                with ui.column().classes('w-full gap-6 pr-2'):603                    for seccion, contenido in data_permiso.items():604                        if not contenido: continue605                        if sel_categoria.value != "TODAS" and seccion != sel_categoria.value: continue606                        607                        # MEGA-FRAME por Categoría608                        with ui.card().classes('w-full p-6 bg-white border border-slate-100 rounded-[2.5rem] shadow-sm mb-6'):609                            # Header se seccion refinado610                            with ui.row().classes('w-full items-center gap-3 mb-6 px-1'):611                                ui.label(seccion.upper()).classes('text-[9px] font-black text-blue-900/50 tracking-widest')612                                ui.element('div').classes('flex-1 h-[1px] bg-blue-100 opacity-30')613                            614                            is_checklist = any(x in seccion.upper() for x in ["RIESGO", "PELIGRO", "IMPACTO", "PROTECCION", "PROTECCIÓN", "TABLA", "MEDIDA", "PREVENTIVA", "EPP", "CALIDAD", "GESTIÓN", "GESTION"])615                            is_na_allowed = is_checklist and not any(x in seccion.upper() for x in ["RIESGO", "PELIGRO", "IMPACTO", "EPP"])616 617                            def render_fields_grid(flds, custom_label_fn=None):618                                with ui.grid().classes('w-full gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'):619                                    for c in flds:620                                        display_lbl = custom_label_fn(c) if custom_label_fn else get_nicer_label(c).upper()621                                        if is_checklist and c != 'e_obs':622                                            with ui.card().classes('w-full p-4 bg-white border border-slate-100 rounded-3xl shadow-sm hover:border-blue-200 transition-all'):623                                                ui.label(display_lbl).classes('text-[10px] font-black text-slate-800 mb-3 opacity-80 leading-tight')624                                                with ui.column().classes('w-full gap-2 items-center'):625                                                    for m_type, m_icon, m_color in [('si', 'check_circle', 'blue-700'), ('no', 'cancel', 'red-700'), ('na', 'not_interested', 'slate-400')]:626                                                        if m_type == 'na' and not is_na_allowed: continue627                                                        m_text = f"{{{{{m_type}_{c}}}}}"628                                                        with ui.row().classes(f'w-full items-center gap-2 px-3 py-1.5 bg-{m_color.split("-")[0]}-50/50 rounded-xl border border-{m_color.split("-")[0]}-100 group cursor-pointer hover:bg-white transition-all') as row_m:629                                                            ui.icon(m_icon, size='12px', color=m_color).classes('opacity-70 group-hover:opacity-100')630                                                            ui.label(m_text).classes(f'text-[11px] font-mono font-black text-{m_color}')631                                                            row_m.on('dblclick', lambda _, t=m_text: ui.run_javascript(f'navigator.clipboard.writeText("{t}")'))632                                                            ui.button(icon='content_copy', on_click=lambda e, t=m_text: handle_copy(e, t)).props('flat dense size=xs color=slate-300').classes('ml-auto')633                                        else:634                                            marker_id = c635                                            if marker_id.startswith('eq_'):636                                                with ui.card().classes('w-full p-5 bg-blue-50/20 border-2 border-dashed border-blue-100 rounded-[2rem]'):637                                                    with ui.row().classes('w-full items-center justify-between mb-4'):638                                                        ui.label(display_lbl).classes('text-[11px] font-black text-blue-900 tracking-tighter')639                                                        ui.badge('INSPECCIÓN • X', color='slate-900').classes('text-[8px] font-black rounded-lg px-2 py-1 shadow-sm')640                                                    m_key = f"{{{{x_{marker_id}}}}}"641                                                    with ui.row().classes('w-full items-center justify-between px-3 py-3 bg-white rounded-xl border border-slate-100 shadow-inner group hover:border-slate-300 transition-all cursor-pointer') as r_m:642                                                        ui.label(m_key).classes('text-[11px] font-mono font-black text-slate-900')643                                                        ui.button(icon='content_copy', on_click=lambda e, t=m_key: handle_copy(e, t)).props('flat dense size=xs color=slate-300').classes('group-hover:text-blue-900')644                                                        r_m.on('dblclick', lambda _, t=m_key: ui.run_javascript(f'navigator.clipboard.writeText("{t}")'))645                                            else:646                                                m_text = f"{{{{{marker_id}}}}}"647                                                with ui.row().classes('w-full items-center justify-between p-4 bg-white border border-slate-100 rounded-3xl hover:border-blue-200 transition-all shadow-sm group'):648                                                    with ui.column().classes('gap-0 flex-1'):649                                                        ui.label(display_lbl).classes('text-[11px] font-black text-slate-700 tracking-tighter')650                                                        ui.label(m_text).classes('text-sm font-mono font-bold text-blue-900 leading-tight group-hover:scale-[1.02] transition-transform')651                                                    ui.button(icon='content_copy', on_click=lambda e, t=m_text: handle_copy(e, t)).props('flat dense size=sm color=slate-200').classes('hover:color-blue-900')652 653                            def render_content_recursive(data, label=None, level=0):654                                if not data: return655                                if isinstance(data, list):656                                    if label:657                                        with ui.column().classes('w-full gap-2 mt-4 mb-2'):658                                            with ui.row().classes('w-full items-center gap-2 px-2'):659                                                # Color dimmer the deeper we go660                                                opacity = max(10, 50 - (level * 10))661                                                ui.element('div').classes(f'w-3 h-3 rounded-full bg-blue-900/{opacity}')662                                                ui.label(label.upper()).classes('text-[10px] font-black text-slate-700 tracking-tighter')663                                    664                                    # --- LIMPIEZA DE ETIQUETAS REDUNDANTES ---665                                    # Si tenemos un label de sub-categoría, lo quitamos del marcador para evitar repeticiones666                                    def clean_lbl(m_id, parent_lbl=label):667                                        orig = get_nicer_label(m_id)668                                        if not parent_lbl: return orig.upper()669                                        p_up = parent_lbl.upper().strip()670                                        o_up = orig.upper().strip()671                                        if o_up.startswith(p_up):672                                            res = o_up.replace(p_up, "").strip(": ").strip()673                                            return res if res else o_up674                                        return o_up675 676                                    render_fields_grid(list(dict.fromkeys([x for x in data if x])), custom_label_fn=clean_lbl)677                                elif isinstance(data, dict):678                                    if label:679                                        with ui.row().classes('w-full items-center gap-3 mt-8 mb-4 px-2'):680                                            ui.label(label.upper()).classes('text-[11px] font-black text-blue-900 tracking-widest bg-blue-50 px-3 py-1 rounded-lg')681                                            ui.element('div').classes('flex-1 h-[1px] bg-blue-200 opacity-20')682                                    for k, v in data.items():683                                        render_content_recursive(v, label=k, level=level+1)684 685                            render_content_recursive(contenido)686 687        688        render_guia_content()689        690        # Footer Estratégico (Reposicionado)691        with ui.row().classes('w-full p-2 px-6 bg-[#0f172a] rounded-xl items-center gap-3 text-white shadow-md mt-6'):692             ui.icon('lightbulb', color='amber', size='1rem').classes('opacity-80')693             ui.label('TIP ESTRATÉGICO: Use marcadores en celdas ocultas o con fuente blanca si necesita inyectar datos técnicos sin afectar el diseño visual original.') \694                 .classes('text-[8.5px] font-medium leading-tight opacity-70 uppercase tracking-tight flex-1')695 696 697def render_catalogo_plantillas_premium(state, lista_plantillas_db, update_callback, delete_callback, upload_trigger_fn, get_abbr_fn):698    """Gestión de plantillas con estética Navy War Room."""699    from nicegui import ui700    701    with ui.column().classes('w-full gap-6'):702 703        rows = lista_plantillas_db704        for r in rows:705            r['cat_abr'] = get_abbr_fn(r.get('tipo_formato'))706            r['is_ghost'] = not r['archivo_url']707        708        cols = [709            {'name': 'fid', 'label': 'ID', 'field': 'id', 'align': 'left', 'headerStyle': 'width: 80px; padding-left: 32px'},710            {'name': 'nom', 'label': 'IDENTIFICACIÓN DEL FORMATO', 'field': 'nombre', 'align': 'left', 'headerStyle': 'padding-left: 16px'},711            {'name': 'tcat', 'label': 'CATEGORÍA', 'field': 'cat_abr', 'align': 'center', 'headerStyle': 'width: 150px'},712            {'name': 'acc', 'label': 'OPERACIONES', 'field': 'id', 'align': 'right', 'headerStyle': 'width: 250px; padding-right: 32px'}713        ]714        715        with ui.card().classes('w-full p-0 rounded-[2.5rem] shadow-sm border border-slate-100 overflow-hidden bg-white'):716            t = ui.table(rows=rows, columns=cols, row_key='id', pagination={'rowsPerPage': 8}).classes('w-full modern-table').props('flat dense separator=horizontal')717            t.add_slot('body-cell-fid', '<q-td :props="props"><span class="text-[10px] font-black text-slate-300">#{{props.value}}</span></q-td>')718            t.add_slot('body-cell-nom', '<q-td :props="props"><div class="row items-center gap-3"><q-avatar size="32px" color="blue-50" text-color="blue-900" font-size="14px" class="font-black">F</q-avatar><div class="column"><span class="text-[13px] font-black text-slate-800 uppercase tracking-tight">{{props.value}}</span><span class="text-[9px] text-slate-400 font-bold tracking-widest">{{props.row.tipo_formato}}</span></div></div></q-td>')719            t.add_slot('body-cell-tcat', '<q-td :props="props"><q-badge outline class="font-black text-[9px] px-3 py-1 rounded-lg border-blue-200 text-blue-900 bg-blue-50/50">{{props.value}}</q-badge></q-td>')720            t.add_slot('body-cell-acc', '''721                <q-td :props="props">722                    <div class="row no-wrap justify-end gap-2 px-2 items-center">723                        <template v-if="!props.row.is_ghost">724                            <q-btn flat round dense color="blue-9" icon="cloud_download" size="sm" @click="$parent.$emit('download', props.row.archivo_url)">725                                <q-tooltip class="bg-blue-900 text-[10px] font-black uppercase tracking-widest">Descargar XLSX Actual</q-tooltip>726                            </q-btn>727                            <q-btn flat round dense color="slate-9" icon="published_with_changes" size="sm" @click="$parent.$emit('subir', props.row)">728                                <q-tooltip class="bg-slate-900 text-[10px] font-black uppercase tracking-widest text-white">Reemplazar / Actualizar Archivo</q-tooltip>729                            </q-btn>730                            <q-btn flat round dense color="slate-9" icon="delete_outline" size="sm" @click="$parent.$emit('borrar', props.row)">731                                <q-tooltip class="bg-slate-900 text-[10px] font-black uppercase tracking-widest text-white">Eliminar Configuración</q-tooltip>732                            </q-btn>733                        </template>734                        <template v-else>735                            <q-btn unelevated dense color="blue-900" icon="cloud_upload" label="VINCULAR" size="xs" class="px-6 py-2 rounded-xl font-black tracking-widest shadow-lg shadow-blue-100" @click="$parent.$emit('subir', props.row)">736                                <q-tooltip class="bg-blue-900 text-[10px] font-black uppercase tracking-widest">Vincular Primer XLSX</q-tooltip>737                            </q-btn>738                        </template>739                    </div>740                </q-td>741            ''')742            t.on('download', lambda e: ui.download(e.args if isinstance(e.args, str) else e.args[0]))743            t.on('borrar', lambda e: delete_callback(e.args if not isinstance(e.args, list) else e.args[0]))744            t.on('subir', lambda e: upload_trigger_fn(e.args if not isinstance(e.args, list) else e.args[0]))745 746