fmatituy/selectospromanager
0
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 