CoolFace
Apppublic

fmatituy/selectospromanager

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
constructor.py1523 linesDownload Raw Back to sst
1from nicegui import ui, run2import json3import math4import os5import re6import io7import asyncio8import datetime9import uuid10 11# --- IMPORTACIONES MODULARES ---12from .constructor_state import state13from .constructor_metadata import ALL_METADATA, get_type_icon, get_nicer_label14from .constructor_components import (15    render_checklist_seguridad, 16    render_grid_datos_generales, 17    render_firmas_y_autorizaciones,18    render_galeria_seleccionable,19    render_guia_marcadores_compacta,20    render_catalogo_plantillas_premium,21    check_description_limit,22    safe_set_value23)24 25from core.config import STATIC_PATH26from services.auth import require_auth, get_current_user27from modules.layout import SelectosLayout28 29# Estado Global de Filtros30class FilterState:31    search = ""32    estado = "TODOS"33    proyecto = "TODOS"34    rows_per_page = 1035    page = 136    view_mode = "table" # O "cards"37 38filters = FilterState()39 40def clean_permit_name(name):41    if not name: return ""42    # En la interfaz de usuario, los formatos se presentan como "Permiso de..."43    # La palabra "Plantilla" se reserva exclusivamente para los archivos Excel internos.44    return str(name).replace("Plantilla ", "Permiso ").strip()45 46def get_abbreviated_cat(raw):47    if not raw: return "GEN"48    r = str(raw).upper()49    if 'ALTURA' in r: return 'ATR'50    if 'ARO' in r: return 'ARO'51    if 'JSA' in r: return 'JSA'52    if 'CALIENTE' in r: return 'CAL'53    if 'ESC' in r or 'CONFINADO' in r: return 'ESC'54    if 'ELE' in r: return 'ELE'55    if 'EXC' in r: return 'EXC'56    if 'IZA' in r: return 'IZA'57    if 'LOTO' in r or 'ENERG' in r: return 'ENP'58    return 'ATR' if 'RIESGO' in r else 'DOC'59 60def parse_formats_from_details(details_raw):61    """Extrae los tipos de formato mencionados en el detalle de la solicitud."""62    fmt_list = []63    if not details_raw: return fmt_list64    d_up = details_raw.upper()65    if 'ARO' in d_up: fmt_list.append('ARO')66    if 'JSA' in d_up: fmt_list.append('JSA')67    68    keyword_map = {69        'ALTURAS': 'Alturas',70        'CALIENTE': 'Trabajo en Caliente',71        'CONFINADOS': 'Espacios Confinados',72        'ESC': 'Espacios Confinados',73        'ENERGÍAS': 'Energías Cero',74        'LOTO': 'Energías Cero',75        'IZAJE': 'Mecánico / Izaje',76        'MECÁNICO': 'Mecánico / Izaje',77        'EXCAVACIÓN': 'Excavación y Demolición',78        'DEMOLICIÓN': 'Excavación y Demolición',79        'LIMPIEZA': 'Limpieza y Desinfección'80    }81    for key, val in keyword_map.items():82        if key in d_up and val not in fmt_list:83            fmt_list.append(val)84    try:85        m = re.search(r'Permiso Alto Riesgo \((.*?)\)', details_raw)86        if m:87            p_items = json.loads(m.group(1).replace("'", '"'))88            if isinstance(p_items, list):89                for pi in p_items: 90                    norm_pi = keyword_map.get(pi.upper(), pi)91                    if norm_pi not in fmt_list: fmt_list.append(norm_pi)92    except: pass93    return fmt_list94 95async def update_project_data(e, state, act_state, run):96    from database.queries import get_actividades_por_proyecto_detallado, get_proyecto_detallado97    if state.busy_updating: return98    p_id = getattr(e, 'value', e)99    if not p_id: return100    state.current_project_id = p_id101    state.busy_updating = True102    try:103        # Defaults corporativos104        state.form_data.update({'empresa': 'Selectivos SAS', 'nit_empresa': '900.221.433-2'})105        for k in ['empresa', 'nit_empresa']:106            if k in state.field_refs: safe_set_value(state.field_refs[k], state.form_data[k])107 108        proj_det = await run.io_bound(get_proyecto_detallado, p_id)109        if proj_det:110            state.form_data.update({'cliente': proj_det.get('cliente_nombre', ''), 'lugar': proj_det.get('ubicacion', '')})111            112            # Cargar responsables del proyecto por defecto si no hay actividad113            if proj_det.get('director_nombre'):114                state.form_data.update({'sup_tec_nombre': proj_det['director_nombre']})115            if proj_det.get('responsable_sst_nombre'):116                state.form_data.update({'sup_sst_nombre': proj_det['responsable_sst_nombre']})117 118            for k in ['cliente', 'lugar', 'sup_tec_nombre', 'sup_sst_nombre']:119                if k in state.field_refs: safe_set_value(state.field_refs[k], state.form_data.get(k, ''))120                121        act_state.actividades = await run.io_bound(get_actividades_por_proyecto_detallado, p_id)122        if 'sel_act' in state.field_refs:123            # Resetear actividad si el proyecto cambia para evitar ValueError en ui.select124            state.current_activity_name = None125            state.field_refs['sel_act'].options = [a['nombre'] for a in act_state.actividades]126            state.field_refs['sel_act'].set_value(None)127            state.field_refs['sel_act'].update()128    finally: state.busy_updating = False129 130async def on_activity_change(e, state, act_state, lista_plantillas_db, clean_name, refresh_ui=None, target_tipo=None, lock_tipo=False, act_override=None):131    if state.busy_updating: return132    # Si 'e' es None, intentamos usar el nombre de la actividad actual guardado en el estado133    a_name = getattr(e, 'value', e) if e else state.current_activity_name134    state.busy_updating = True135    from database.queries import db_fetchall136    try:137        # Recuperar permisos ya generados para esta actividad/proyecto138        state.existing_permits_for_act = []139        if state.current_project_id and (a_name or act_override):140            a_id = act_override.get('id') if act_override else next((a['id'] for a in act_state.actividades if a.get('nombre') == a_name), None)141            if a_id:142                res = db_fetchall("SELECT tipo_formato FROM formatos_sst WHERE proyecto_id = ? AND actividad_id = ? AND estado != 'Eliminado'", (state.current_project_id, a_id))143                state.existing_permits_for_act = [str(r['tipo_formato']).upper() for r in res]144                print(f"DEBUG: Permisos existentes para act {a_id}: {state.existing_permits_for_act}")145        # Si venimos de cambio de tipo de permiso sin cambiar la actividad, conservamos la descripción146        if not a_name and not act_override and state.form_data.get('descripcion_actividad'):147            # Si ya tenemos datos, no hacemos nada que los borre148            pass149        else:150            if hasattr(state, 'stepper_ui') and state.stepper_ui:151                try:152                    steps = [c for c in state.stepper_ui.default_slot.children if hasattr(c, 'name')]153                    if steps: state.stepper_ui.value = steps[0].name154                except: pass155            156            keys_to_clear = ['sup_tec_nombre', 'sup_tec_cc', 'sup_sst_nombre', 'sup_sst_cc', 'sup_sst_licencia', 'descripcion_actividad']157            for i in range(1, 8): keys_to_clear.extend([f'trab{i}_nombre', f'trab{i}_cc', f'trab{i}_firma', f'trab{i}_vig_exa', f'trab{i}_vig_cer'])158            for k in keys_to_clear:159                state.form_data[k] = ''160                if k in state.field_refs: safe_set_value(state.field_refs[k], '')161 162        # Procedemos con el filtrado y categorización incluso si no hay actividad163        act = None164        if a_name or act_override:165            act = act_override or next((a for a in act_state.actividades if a.get('nombre', '').strip().lower() == str(a_name).strip().lower()), None)166        167        allowed = []168        if act:169            if act.get('requiere_aro'): allowed.append('ARO')170            if act.get('requiere_jsa'): allowed.append('JSA')171            if act.get('tipos_permiso_json'):172                try:173                    import json174                    extra = json.loads(act['tipos_permiso_json'])175                    if isinstance(extra, list): allowed.extend(extra)176                except: pass177        178        if 'sel_pl' in state.field_refs:179            options = {}180            # 1. Permisos requeridos por la actividad (Prioridad en la lista)181            import unicodedata182            def norm(txt):183                if not txt: return ""184                return "".join(c for c in unicodedata.normalize('NFD', str(txt)) if unicodedata.category(c) != 'Mn').strip().upper()185            186            if allowed:187                options['H_ASOC'] = '─── PERMISOS REQUERIDOS ───'188                for t in allowed:189                    # Buscamos por tipo_formato o nombre con normalización robusta190                    nt = norm(t)191                    p_db = None192                    for x in lista_plantillas_db:193                        x_tipo = norm(x.get('tipo_formato', ''))194                        x_nom = norm(x.get('nombre', ''))195                        196                        # Correspondencia directa o de substring197                        if x_tipo == nt or x_nom == nt: p_db = x; break198                        if len(x_tipo) > 4 and (x_tipo in nt or nt in x_tipo): p_db = x; break199                        200                        # Correspondencia por núcleo semántico clave201                        kw_core = ['ALTURAS', 'CALIENTE', 'CONFINADOS', 'ELECTRICO', 'EXCAVAC', 'IZAJE', 'ENERGIAS', 'LIMPIEZA', 'ARO', 'JSA']202                        for k in kw_core:203                            if k in nt and (k in x_tipo or k in x_nom):204                                p_db = x; break205                        if p_db: break206                        207                    if p_db: 208                        label = clean_name(p_db['nombre'])209                        if p_db.get('tipo_formato', '').upper() in state.existing_permits_for_act:210                            label = f"✅ {label} (DILIGENCIADO)"211                        options[p_db['id']] = f"⭐ {label}"212                    else: 213                        options[f"MISSING_{t}"] = f"⚠️ {t} (SIN PLANTILLA)"214            215            # 2. Resto de permisos del catálogo general (Separado visualmente)216            options['H_GEN'] = '─── OTROS DISPONIBLES ───'217            for p in lista_plantillas_db:218                if p['id'] in options: continue219                label = clean_name(p['nombre'])220                if p.get('tipo_formato', '').upper() in state.existing_permits_for_act:221                    label = f"✅ {label} (DILIGENCIADO)"222                options[p['id']] = label223            224            state.field_refs['sel_pl'].options = options225            state.field_refs['sel_pl'].update()226            227            first_match_pid = None228            if target_tipo:229                for pid, pnm in options.items():230                    if target_tipo.upper() in str(pnm).upper() or target_tipo.upper() in str(pid).upper():231                        first_match_pid = pid; break232            233            if first_match_pid:234                state.field_refs['sel_pl'].set_value(first_match_pid)235                state.current_template_id = first_match_pid236                if not str(first_match_pid).startswith("MISSING_"):237                    p_m = next((x for x in lista_plantillas_db if x['id'] == first_match_pid), None)238                    if p_m:239                        if not lock_tipo: state.current_mapping_type = p_m.get('tipo_formato') or 'Excavación y Demolición'240                        state.excel_template_name = p_m['nombre']241                        state.excel_template_path = p_m.get('archivo_url')242            243            # Forzar actualización de metadatos y pasos244            # if refresh_ui: refresh_ui.refresh() # Moved to end of try block245            # await asyncio.sleep(0.1) # Moved to end of try block246 247        # --- 3. CARGA DE DESCRIPCIÓN TÉCNICA (FUENTE: CRONOGRAMA) ---248        # Cargamos únicamente la descripción técnica detallada. 249        # Evitamos el nombre de la actividad para no ser redundantes.250        desc_final = act.get('descripcion') or ''251        252        # Sincronización forzosa con el formulario (ambas claves por compatibilidad)253        for k in ['descripcion_actividad', 'descripcion_activity']:254            state.form_data[k] = desc_final255            if k in state.field_refs: safe_set_value(state.field_refs[k], desc_final)256        257        # Validación inmediata al cargar258        is_valid = check_description_limit(desc_final, state.excel_template_name)259        260        try:261            from database.connection import get_db_connection262            import json, ast263            op_ids = []264            if act.get('personal_operativo_json'):265                try: op_ids = json.loads(act['personal_operativo_json'])266                except: 267                    try: op_ids = ast.literal_eval(act['personal_operativo_json'])268                    except: op_ids = []269            with get_db_connection() as conn:270                for prefix, field_id in [('sup_tec', 'responsable_id'), ('sup_sst', 'responsable_sst_id')]:271                    if act.get(field_id):272                        r = conn.execute("SELECT nombre, documento, firma_url, licencia_sst FROM empleados WHERE id=?", (act[field_id],)).fetchone()273                        if r:274                            state.form_data.update({275                                f'{prefix}_nombre': r['nombre'], 276                                f'{prefix}_cc': r['documento'] or '', 277                                f'{prefix}_firma': r['firma_url'] or ''278                            })279                            if prefix == 'sup_sst':280                                state.form_data['sup_sst_licencia'] = r['licencia_sst'] or 'SST-CERT-2026'281 282                            # for suffix in ['nombre', 'cc', 'licencia']: # Moved to end of try block283                            #     f_key = f'{prefix}_{suffix}'284                            #     if f_key in state.field_refs: safe_set_value(state.field_refs[f_key], state.form_data.get(f_key, ''))285                286                if isinstance(op_ids, list):287                    for i in range(min(len(op_ids), 12)):288                        r = conn.execute("SELECT nombre, documento, firma_url FROM empleados WHERE id=?", (op_ids[i],)).fetchone()289                        if r:290                            px = f'trab{i+1}'291                            state.form_data.update({f'{px}_nombre': r['nombre'], f'{px}_cc': r['documento'] or '', f'{px}_firma': r['firma_url'] or ''})292                            # for suffix in ['nombre', 'cc']: # Moved to end of try block293                            #     if f'{px}_{suffix}' in state.field_refs: safe_set_value(state.field_refs[f'{px}_{suffix}'], state.form_data[f'{px}_{suffix}'])294                295                # Al estar dentro de render_stepper_form.refresh(), los componentes se actualizarán solos.296                # No es necesario iterar field_refs para refrescar cada firma individualmente si refrescamos el stepper.297                pass298        except Exception as ex: 299            print(f"Error cargando personal actividad: {ex}")300 301        # REFRESCAR UI AL FINAL para que el bind_value tome los datos ya poblados302        if refresh_ui: refresh_ui.refresh()303        await asyncio.sleep(0.1) # Breve espera para que los componentes se creen304 305        # Sincronizar field_refs por si ya existen (y no se refrescaron)306        for k, v in state.form_data.items():307            if k in state.field_refs: safe_set_value(state.field_refs[k], v)308 309    finally: state.busy_updating = False310 311@ui.page('/sst/constructor')312@require_auth313def constructor_page(draft_id: int = None, mode: str = None, proyecto_id: int = None, tipo: str = None, actividad_id: int = None):314    from .constructor_components import render_guia_marcadores_compacta315    ui.add_head_html('''316    <style>317        @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');318        body { font-family: 'Inter', sans-serif !important; overflow-x: hidden; }319        .modern-table thead tr th {320            background-color: #F8FAFC !important;321            color: #475569 !important;322            font-weight: 800 !important;323            text-transform: uppercase !important;324            letter-spacing: 0.05em !important;325            font-size: 10px !important;326            padding: 16px 20px !important;327        }328        .modern-table td { padding: 14px 20px !important; border-bottom: 1px solid #f1f5f9 !important; font-size: 13px !important; }329        .ver-req-cursor { 330            cursor: pointer; 331            padding: 2px 10px;332            border-radius: 6px;333            background: rgba(30, 58, 138, 0.05);334            transition: all 0.2s ease;335        }336        .ver-req-cursor:hover { background: rgba(30, 58, 138, 0.1); }337        .modern-tooltip {338            background: #111827 !important;339            color: #f9fafb !important;340            border-radius: 20px !important;341            padding: 24px !important;342            box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6) !important;343            border-left: 8px solid #3b82f6 !important;344            font-family: 'Inter', sans-serif !important;345            min-width: 320px !important;346            max-width: 400px !important;347            line-height: 1.6 !important;348        }349        .tooltip-info-row {350            display: flex;351            align-items: center; gap: 12px; margin-bottom: 8px;352            background: rgba(255, 255, 255, 0.03); padding: 8px 12px; border-radius: 12px;353        }354        .q-table__bottom {355            background-color: #F8FAFC !important;356            border-top: 1px solid #e2e8f0 !important;357            font-weight: 700 !important;358            color: #64748b !important;359            padding: 10px 20px !important;360        }361        .filter-header {362            background: white; border: 1px solid #f1f5f9;363            box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);364            padding: 16px 24px; border-radius: 24px; margin-bottom: 16px;365        }366        /* Estilos Ultra Compactos para Stepper */367        .q-stepper__tab { padding: 4px 10px !important; min-height: 42px !important; }368        .q-stepper__title { font-size: 9px !important; font-weight: 800 !important; letter-spacing: 0.025em !important; text-transform: uppercase !important; }369        .q-stepper__dot { width: 20px !important; height: 20px !important; min-width: 20px !important; }370        .q-stepper__dot .q-icon { font-size: 11px !important; }371        .q-stepper__header--alternative-labels .q-stepper__tab { padding: 12px 4px !important; }372        .q-stepper__line:before, .q-stepper__line:after { background: #e2e8f0 !important; opacity: 0.5; }373    </style>374    ''')375    state.field_refs.clear()376    state.reset()377    state.current_draft_id = draft_id378    user = get_current_user()379    380    filters.search = ""381    filters.estado = "TODOS"382    filters.proyecto = "TODOS"383 384    from database.queries import get_projects, get_clients, get_dropdown_employees, get_solicitudes_permiso385    from database.queries_ai import get_plantillas_formatos, delete_plantilla_formato, save_plantilla_formato, save_formato_generado, get_formatos_generados, delete_formato_sst386    387    lista_proyectos = get_projects()388    lista_clientes = get_clients()389    lista_empleados = get_dropdown_employees()390    lista_plantillas_db = get_plantillas_formatos()391    392    class ActivityState: actividades = []393    act_state = ActivityState()394 395    def reset_form_data():396        state.form_data.clear()397        state.form_data.update({'fecha': datetime.date.today().strftime('%Y-%m-%d'), 'h_iam': False, 'h_ipm': False, 'h_fam': False, 'h_fpm': False})398 399    async def abrir_nuevo_limpio():400        state.reset()401        state.form_data.clear()402        state.is_nuevo_permiso = True403        state.tipo_generacion = 'PERMISO ASOCIADO A PROYECTO'404        reset_form_data()405        for k in ['sel_proy', 'sel_act', 'sel_pl']:406            if k in state.field_refs: state.field_refs[k].set_value(None)407        state.excel_template_name = "Emisión de Permiso"408        state.current_mapping_type = 'Excavación y Demolición' # Por defecto, pero ahora el selector es más prominente409        render_stepper_form.refresh()410        dialog_replica.open()411 412    async def seleccionar_formato(row, proj_id=None, act_id=None, sol_id=None, existing_data=None, doc_id=None):413        print(f"DEBUG: Seleccionando formato para {row.get('nombre')}, tipo: {row.get('tipo_formato')}")414        state.is_nuevo_permiso = False415        state.tipo_generacion = 'PERMISO ASOCIADO A PROYECTO'416        reset_form_data()417        state.current_mapping_type = row.get('tipo_formato') or 'Excavación y Demolición'418        state.excel_template_name = row.get('nombre', 'Formato')419        state.excel_template_path = row.get('archivo_url')420        state.current_template_id = row.get('id')421        if sol_id: state.current_solicitud_id = sol_id422        state.current_document_id = doc_id423        if existing_data: state.form_data.update(existing_data)424 425        426        dialog_replica.open() # Abrir primero427        428        if proj_id:429            state.current_project_id = int(proj_id)430            await update_project_data(int(proj_id), state, act_state, run)431            432            if act_id:433                state.current_activity_id = int(act_id)434                from database.queries import get_actividad_by_id435                act_data = await run.io_bound(get_actividad_by_id, int(act_id))436                if act_data:437                    act_name = str(act_data.get('nombre'))438                    state.current_activity_name = act_name439                    440                    # CARGA AUTOMÁTICA DE DATOS ADICIONALES DE LA ACTIVIDAD441                    # Fecha y Horas442                    if act_data.get('fecha_inicio'): state.form_data['fecha'] = act_data['fecha_inicio']443                    if act_data.get('hora_inicio'): state.form_data['hora_inicio'] = act_data['hora_inicio']444                    if act_data.get('hora_fin'): state.form_data['hora_fin'] = act_data['hora_fin']445                    446                    # Forzar poblado de campos (descripcion, responsables, etc)447                    # Pasamos act_data para evitar que on_activity_change tenga que buscarlo448                    await on_activity_change(act_name, state, act_state, lista_plantillas_db, clean_permit_name, None, target_tipo=state.current_mapping_type, lock_tipo=True, act_override=act_data)449        450        if existing_data:451            state.form_data.update(existing_data)452        453        render_stepper_form.refresh()454 455    async def proc_borrar(row, on_success=None):456        from database.queries_ai import delete_plantilla_formato457        await run.io_bound(delete_plantilla_formato, row['id'])458        ui.notify(f"Plantilla {row.get('tipo_formato')} eliminada", color='negative')459        if on_success: on_success()460 461    async def proc_generar_final():462        if not state.excel_template_path: 463            ui.notify('Sin plantilla técnica', color='negative'); return464        btn = state.field_refs.get('btn_generar_replica')465        if btn: btn.props('loading')466 467        # --- PRE-POBLADO DE IMÁGENES FALTANTES (REQUERIMIENTO USUARIO) ---468        # Si el usuario no visitó la galería de equipos, las imágenes no están en memoria.469        # Las rescatamos de la DB justo antes de generar.470        try:471            from database.queries import get_herramientas472            from PIL import Image, ImageDraw473            import os, base64, io474            all_tools = get_herramientas()475            for k, v in state.form_data.items():476                if k.startswith('si_') and v is True:477                    img_key = k.replace('si_', 'img_')478                    if not state.form_data.get(img_key):479                        label = get_nicer_label(k.replace('si_', ''))480                        norm_label = label.lower().strip()481                        tool_match = next((t for t in all_tools if norm_label in t['nombre'].lower() or t['nombre'].lower() in norm_label), None)482                        if tool_match and tool_match.get('foto_url'):483                            # Cargar y generar versión con X484                            sub_path = tool_match['foto_url'].replace('/static/', '').lstrip('/')485                            abs_path = os.path.join(STATIC_PATH, sub_path)486                            if os.path.exists(abs_path):487                                with open(abs_path, 'rb') as f_img:488                                    b64_raw = base64.b64encode(f_img.read()).decode('utf-8')489                                    state.form_data[img_key] = f'data:image/png;base64,{b64_raw}'490                                491                                # Generar copia profesional con Marca X Original (Transparente) para el reporte492                                img_pil = Image.open(abs_path).convert('RGBA')493                                w, h = img_pil.size494                                495                                x_path = os.path.join(STATIC_PATH, 'img', 'marca_x_gigante.png')496                                if os.path.exists(x_path):497                                    x_img = Image.open(x_path).convert('RGBA')498                                    # Ajustar X al tamaño de la foto del equipo499                                    x_w, x_h = x_img.size500                                    501                                    # Proporción base502                                    ratio = min(w / x_w, h / x_h)503                                    target_w = int(x_w * ratio * 0.95) # Un poco margen504                                    target_h = int(x_h * ratio * 0.95)505                                    506                                    # REQUERIMIENTO: Reducir ancho en un 4% para escaleras (escalera tipo escalera)507                                    if any(x in norm_label for x in ['escalera', 'extensió', 'tijera', 'ext']):508                                        target_w = int(target_w * 0.96) # -4% del ancho509 510                                    x_resized = x_img.resize((target_w, target_h), Image.Resampling.LANCZOS)511                                    512                                    # Centrar X513                                    off_x = (w - target_w) // 2514                                    off_y = (h - target_h) // 2515                                    img_pil.paste(x_resized, (off_x, off_y), x_resized)516                                else:517                                    # Fallback (Línea Negra si no hay imagen maestro)518                                    draw = ImageDraw.Draw(img_pil)519                                    lw = max(5, int(w * 0.015))520                                    draw.line((0, 0, w, h), fill=(0, 0, 0, 150), width=lw)521                                    draw.line((0, h, w, 0), fill=(0, 0, 0, 150), width=lw)522 523                                buf = io.BytesIO()524                                img_pil.save(buf, format='PNG')525                                state.form_data[f"{img_key}_x"] = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode('utf-8')}"526        except Exception as pre_e:527            print(f"Error en pre-poblado de imágenes: {pre_e}")528 529        try:530            from services.excel_editor_ia import rellenar_excel_por_marcadores531            from services.pdf_generator import convertir_excel_a_pdf532            533            # Preparación inteligente de marcadores para Excel534            markers = {}535            for k, v in state.form_data.items():536                # Lógica DINÁMICA de Marcado de Imágenes (Requerimiento Usuario)537                # Si es un equipo chuleado (si_...) -> Usamos la versión con la marca quemada (puesto la X)538                # Si NO está chuleado -> Usamos la versión limpia (imagen normal)539                if k.startswith('si_'):540                    base_key = k.replace('si_', 'img_')541                    if v is True:542                         # Seleccionado: Intentar usar la que tiene la marca X543                         if f"{base_key}_x" in state.form_data:544                             markers[base_key] = state.form_data[f"{base_key}_x"]545                    else:546                         # No seleccionado: Enviar la imagen normal limpia547                         if base_key in state.form_data:548                             markers[base_key] = state.form_data[base_key]549                550                # Valores estándar551                # El marcador de la celda de selección (si_...) se llena con una 'X' textual para el chulo552                display_v = "X" if v is True else ("" if v is False else str(v or ""))553                markers[k] = display_v554 555            # Generar Excel (Ruta absoluta requerida para win32com posterior)556            out_url = await run.io_bound(rellenar_excel_por_marcadores, state.excel_template_path.replace('/static/', STATIC_PATH + os.sep), markers)557            558            if out_url and not out_url.startswith('ERR_'):559                # Convertir a PDF usando rutas absolutas560                u_id = str(uuid.uuid4())[:8]561                pdf_filename = f"Permiso_{u_id}.pdf"562                pdf_loc = os.path.join(STATIC_PATH, 'permisos_pdf', pdf_filename)563                os.makedirs(os.path.dirname(pdf_loc), exist_ok=True)564                565                # Sincronización de ruta de entrada Excel566                excel_abs_path = out_url.replace('/static/', STATIC_PATH + os.sep)567                568                pdf_success = await run.io_bound(convertir_excel_a_pdf, excel_abs_path, pdf_loc)569                pdf_url = f"/static/permisos_pdf/{pdf_filename}" if pdf_success else None570                571                consecutivo = save_formato_generado(572                    tipo=state.excel_template_name, 573                    url=out_url, 574                    usuario=user.get('nombre'), 575                    proyecto_id=state.current_project_id, 576                    estado='Generado', 577                    archivo_pdf_url=pdf_url, 578                    actividad_id=getattr(state, 'current_activity_id', None), 579                    datos_json=json.dumps(state.form_data), 580                    id_plantilla=state.current_template_id,581                    doc_id=getattr(state, 'current_document_id', None)582                )583                584                if getattr(state, 'current_solicitud_id', None):585                    from database.queries import update_estado_solicitud_permiso586                    update_estado_solicitud_permiso(state.current_solicitud_id, 'Generado')587                588                if not pdf_success:589                    ui.notify('Permiso generado en Excel, pero falló la conversión a PDF. Verifique que Excel esté instalado.', type='warning', icon='warning')590 591                with ui.dialog() as d, ui.card().classes('p-10 items-center text-center rounded-2xl shadow-xl'):592                    ui.icon('verified', size='4rem', color='blue-900')593                    ui.label('PERMISO EMITIDO').classes('text-2xl font-black mb-1 text-blue-900 uppercase')594                    ui.label(f'Cons: {consecutivo}').classes('text-xs text-slate-400 mb-6 font-bold')595                    596                    if pdf_url:597                        ui.button('VER PDF', icon='visibility', on_click=lambda: ui.navigate.to(pdf_url, new_tab=True)) \598                            .props('unelevated color=blue-900 rounded-lg px-8 py-2 font-black shadow-lg shadow-blue-50')599                    else:600                        ui.button('DESCARGAR EXCEL', icon='download', on_click=lambda: ui.download(out_url)) \601                            .props('unelevated color=indigo rounded-lg px-8 py-2 font-black shadow-lg shadow-indigo-50')602                    603                    ui.button('CERRAR', on_click=lambda: [d.close(), dialog_replica.close()]).props('flat color=slate-400').classes('w-full mt-2')604                d.open()605                render_requests.refresh()606            else:607                ui.notify(f'Fallo al inyectar datos en la plantilla: {out_url}', type='negative')608        except Exception as ex:609            ui.notify(f'Error crítico en la emisión: {str(ex)}', type='negative')610        finally: 611            if btn: btn.props(remove='loading')612 613    async def auto_emitir_ia(row):614        from services.generador_permisos_ia import generar_y_emitir_permiso_sara615        notif = ui.notification('SARA IA está procesando...', timeout=0, color='indigo', spinner=True)616        try:617            texto = f"Emitir permiso para {row['proyecto_nombre']}: {row['detalles']}"618            res = await generar_y_emitir_permiso_sara(texto, user['nombre'], user.get('rol'), row['proyecto_id'], row.get('actividad_id'), solicitud_id=row['id'])619            notif.dismiss()620            if "success" in res:621                ui.notify(f"Éxito: {res['consecutivo']}", color='positive')622                from database.queries import update_estado_solicitud_permiso623                update_estado_solicitud_permiso(row['id'], 'Generado')624                render_requests.refresh()625            else: ui.notify(f"Error: {res.get('error')}", color='negative')626        except Exception as e:627            notif.dismiss(); ui.notify(f"Error técnico: {e}", color='negative')628 629    with SelectosLayout('Gestor SST Pro'):630        with ui.column().classes('w-full h-full bg-[#f8fafc] p-6 gap-0'):631            632            # --- HEADER REDISEÑADO ---633            with ui.column().classes('w-full max-w-7xl mx-auto mb-10 px-4 gap-4'):634                with ui.column().classes('gap-1'):635                    ui.label('OPERACIONES & SEGURIDAD').classes('text-[11px] font-black text-black tracking-[0.3em] uppercase opacity-70')636                    ui.label('CENTRO DE GESTIÓN Y EMISIÓN DE PERMISOS DE TRABAJO').classes('text-3xl font-black text-[#1e3a8a] tracking-tighter leading-none')637                    ui.label('Administre, genere y supervise los permisos de trabajo de alto riesgo con cumplimiento institucional.').classes('text-sm text-slate-500 mt-2 font-medium')638                with ui.row().classes('gap-3 items-center'):639                    ui.button('+ NUEVO PERMISO', on_click=abrir_nuevo_limpio).props('unelevated color=blue-900 rounded-xl font-black text-[11px]').classes('px-8 py-3.5 shadow-xl shadow-blue-900/20 hover:scale-105 transition-all')640                    ui.button('GESTIONAR PLANTILLAS', icon='settings', on_click=lambda: dialog_catalogo_global.open()).props('unelevated color=slate-50 text-color=slate-500 rounded-xl font-black text-[9.5px] size=sm').classes('px-5 py-2.5 border border-slate-100 hover:text-blue-900 hover:border-blue-200 hover:bg-white transition-all shadow-sm')641 642            # --- BARRA DE FILTROS PREMIUM ---643            with ui.row().classes('w-full max-w-7xl mx-auto items-center gap-6 mb-8 px-4 py-8 bg-white rounded-[2rem] shadow-sm border border-slate-50'):644                with ui.row().classes('flex-1 items-center bg-slate-50/50 rounded-2xl px-5 py-1.5 border border-slate-100 transition-all focus-within:border-blue-200'):645                    ui.icon('search', color='slate-300', size='sm')646                    ui.input(placeholder='Buscar por código, proyecto o contratista...', on_change=lambda e: (setattr(filters, 'search', e.value), render_requests.refresh())).props('borderless dense clearable').classes('flex-1 text-sm font-bold opacity-80')647                648                649                with ui.column().classes('gap-1'):650                    ui.label('PROYECTO').classes('text-[9px] font-black text-slate-300 tracking-widest')651                    p_options = {"TODOS": "Todos los Proyectos"}652                    for p in lista_proyectos: p_options[str(p['id'])] = p['nombre']653                    ui.select(p_options, value="TODOS", on_change=lambda e: (setattr(filters, 'proyecto', e.value), render_requests.refresh())).props('dense borderless').classes('w-64 bg-slate-50 rounded-xl px-4 text-xs font-bold border border-slate-100 h-10')654                655                with ui.column().classes('gap-1'):656                    ui.label('ESTADO').classes('text-[9px] font-black text-slate-300 tracking-widest')657                    with ui.row().classes('bg-slate-50 rounded-xl p-1 border border-slate-100 items-center h-10'):658                        for label, value in [('TODOS', 'TODOS'), ('PENDIENTE', 'Pendiente'), ('GENERADO', 'Generado')]:659                            async def set_filter(v=value):660                                filters.estado = v661                                render_requests.refresh()662                            663                            active_classes = 'bg-white text-blue-900 shadow-sm'664                            if filters.estado == value:665                                if value == 'Pendiente': active_classes = 'bg-yellow-400 text-slate-900 shadow-md'666                                elif value == 'Generado': active_classes = 'bg-emerald-800 text-white shadow-md'667                                else: active_classes = 'bg-white text-blue-900 shadow-sm'668                            669                            ui.button(label, on_click=set_filter).props('flat dense size=sm').classes(f'px-5 text-[10px] font-black rounded-lg transition-all ' + (active_classes if filters.estado == value else 'text-slate-400'))670 671            # --- LISTADO DE TARJETAS Y TABLA ---672            with ui.column().classes('w-full max-w-7xl mx-auto gap-1'):673                # Selector Sutil de Modo (Sobre la tabla pero fuera del refreshable)674                with ui.row().classes('w-full items-center justify-end mb-1 px-4 gap-2'):675                    ui.button('HISTORIAL / MANUALES', icon='history', on_click=lambda: dialog_historial_manual.open()).props('flat dense size=sm color=slate-400').classes('text-[9px] font-black tracking-widest hover:text-blue-900 px-3 rounded-xl bg-slate-50 border border-slate-100')676                    with ui.row().classes('bg-slate-50 p-1 rounded-2xl items-center border border-slate-100 shadow-sm'):677                        ui.button(on_click=lambda: (setattr(filters, 'view_mode', 'table'), render_requests.refresh()), icon='view_list').props('flat dense size=sm') \678                            .classes('px-3 rounded-xl transition-all ' + ('bg-white text-blue-900 shadow-sm' if filters.view_mode == 'table' else 'text-slate-400 opacity-60'))679                        ui.button(on_click=lambda: (setattr(filters, 'view_mode', 'cards'), render_requests.refresh()), icon='grid_view').props('flat dense size=sm') \680                            .classes('px-3 rounded-xl transition-all ' + ('bg-white text-blue-900 shadow-sm' if filters.view_mode == 'cards' else 'text-slate-400 opacity-60'))681 682                # Headers Silenciosos (Solo en Modo Cuadrícula)683                with ui.row().classes('w-full px-12 py-4 items-center gap-0 opacity-50 flex-nowrap') \684                        .bind_visibility_from(filters, 'view_mode', backward=lambda v: v == 'cards'):685                    ui.label('IDENTIFICACIÓN').classes('flex-1 text-center text-[10px] font-black tracking-widest')686                    ui.label('PROYECTO / ACTIVIDAD').classes('flex-[1.5] text-center text-[10px] font-black tracking-widest')687                    ui.label('SOLICITANTE').classes('flex-1 text-center text-[10px] font-black tracking-widest')688                    ui.label('PERMISOS SOLICITADOS').classes('flex-1 text-center text-[10px] font-black tracking-widest')689                    ui.label('ESTADO').classes('flex-1 text-center text-[10px] font-black tracking-widest')690                    ui.label('GESTIÓN').classes('flex-1 text-center text-[10px] font-black tracking-widest')691 692                @ui.refreshable693                def render_requests():694                    from database.queries import db_fetchall695                    query = """696                        SELECT s.*, 697                               COALESCE(e.nombre, u.nombre, 'Sistema/Auto') as solicitante,698                               p.nombre as proyecto_nombre, p.ubicacion as proyecto_ubicacion,699                               a.nombre as actividad_nombre,700                               a.requiere_aro, a.requiere_jsa, a.requiere_permiso, a.tipos_permiso_json,701                               s.fecha as fecha_solicitud,702                               (SELECT COUNT(*) FROM formatos_sst f WHERE f.proyecto_id = s.proyecto_id AND f.actividad_id = s.actividad_id AND f.estado='Generado') as total_formatos703                        FROM sst_solicitudes_permisos s704                        LEFT JOIN empleados e ON s.usuario_id = e.id705                        LEFT JOIN usuarios u ON s.usuario_id = u.id706                        JOIN proyectos p ON s.proyecto_id = p.id707                        LEFT JOIN actividades_proyecto a ON s.actividad_id = a.id708                        WHERE 1=1709                    """710                    params = []711                    if filters.estado != "TODOS":712                        query += " AND s.estado = ?"713                        params.append(filters.estado)714                    if filters.proyecto != "TODOS":715                        query += " AND s.proyecto_id = ?"716                        params.append(int(filters.proyecto))717                    if filters.search:718                        query += " AND (s.consecutivo LIKE ? OR p.nombre LIKE ? OR solicitante LIKE ?)"719                        search_val = f"%{filters.search}%"720                        params.extend([search_val, search_val, search_val])721                    722                    query += " GROUP BY s.id ORDER BY (CASE WHEN s.estado = 'Pendiente' THEN 1 ELSE 2 END), s.id DESC"723                    res = db_fetchall(query, tuple(params))724                    solicitudes_all = [dict(r) for r in res]725                    726                    total_count = len(solicitudes_all)727                    total_pages = math.ceil(total_count / filters.rows_per_page) if total_count > 0 else 1728                    start_idx = (filters.page - 1) * filters.rows_per_page729                    end_idx = start_idx + filters.rows_per_page730                    solicitudes = solicitudes_all[start_idx:end_idx]731 732                    if filters.view_mode == 'table' and solicitudes:733                        columns = [734                            {'name': 'id', 'label': 'ID', 'field': 'consecutivo', 'align': 'left'},735                            {'name': 'proyecto', 'label': 'PROYECTO / ACTIVIDAD', 'field': 'proyecto_nombre', 'align': 'left'},736                            {'name': 'solicitante', 'label': 'SOLICITANTE', 'field': 'solicitante', 'align': 'left'},737                            {'name': 'requerimientos', 'label': 'PERMISOS SOLICITADOS', 'field': 'id', 'align': 'center'},738                            {'name': 'estado', 'label': 'ESTADO', 'field': 'estado', 'align': 'center'},739                            {'name': 'gestion', 'label': 'GESTIÓN', 'field': 'id', 'align': 'center'},740                        ]741                        table_rows = []742                        for s in solicitudes:743                            # 1. DETERMINAR FORMATOS REQUERIDOS BASADO EN ACTIVIDAD (FUENTE OFICIAL) O DETALLES (FALLBACK)744                            fmt_list = []745                            if s.get('actividad_id'):746                                if s.get('requiere_aro'): fmt_list.append('ARO')747                                if s.get('requiere_jsa'): fmt_list.append('JSA')748                                if s.get('requiere_permiso') and s.get('tipos_permiso_json'):749                                    try:750                                        extra = json.loads(s['tipos_permiso_json'])751                                        if isinstance(extra, list): fmt_list.extend(extra)752                                        else: fmt_list.append('Permiso')753                                    except: fmt_list.append('Permiso')754                            755                            # Fallback si no hay data de actividad o está vacía756                            if not fmt_list: fmt_list = parse_formats_from_details(s['detalles'])757                            758                            # 2. CONSTRUIR BADGES (rb) PARA UI759                            rb = []760                            for r in fmt_list:761                                cat = get_abbreviated_cat(r)762                                if cat in ['ARO', 'JSA']:763                                    if not any(x['l'] == cat for x in rb): rb.append({'l': cat, 'd': f"Requerido: {r}"})764                            765                            atr_list = [r for r in fmt_list if get_abbreviated_cat(r) not in ['ARO', 'JSA']]766                            if atr_list: rb.append({'l': 'ATR', 'd': " / ".join(atr_list)})767                            768                            s['rb'] = rb769                            s['required_count'] = len(fmt_list) if fmt_list else 1770                            s['generated_count'] = s['total_formatos'] or 0771                            if s['generated_count'] == 0:772                                s['estado_label'] = "PENDIENTE"; s['badge_classes'] = "bg-yellow-400 text-slate-900"773                            elif s['generated_count'] < s['required_count']:774                                s['estado_label'] = "DILIGENCIANDO"; s['badge_classes'] = "bg-blue-800 text-white"775                            else:776                                s['estado_label'] = "GENERADO"; s['badge_classes'] = "bg-emerald-800 text-white"777                            table_rows.append(s)778 779                        with ui.table(columns=columns, rows=table_rows, row_key='id').classes('w-full bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden') as t:780                            t.add_slot('body-cell-id', '<q-td :props="props"><span class="text-sm font-black text-blue-900 tracking-tighter">{{ props.value }}</span></q-td>')781                            t.add_slot('body-cell-proyecto', '<q-td :props="props"><div class="column gap-1"><span class="text-[11px] font-black text-blue-900 uppercase leading-tight">{{ props.row.proyecto_nombre }}</span><div class="row items-center gap-2"><q-icon name="construction" size="10px" class="text-slate-400" /><span class="text-[10px] font-bold text-slate-600 uppercase tracking-tight">{{ props.row.actividad_nombre || \'Actividad Directa\' }}</span></div></div></q-td>')782                            t.add_slot('body-cell-solicitante', '<q-td :props="props"><div class="row items-center gap-2"><q-avatar size="22px" font-size="12px" color="blue-1" text-color="blue-9" icon="person" class="shadow-sm"/><span class="text-[10px] font-black text-slate-700 uppercase tracking-tighter">{{ props.value }}</span></div></q-td>')783                            t.add_slot('body-cell-requerimientos', '''784                                <q-td :props="props">785                                    <div class="row q-gutter-xs justify-center items-center">786                                        <template v-for="b in props.row.rb">787                                            <q-badge :label="b.l" outline class="font-black text-[8px] px-2 py-0.5 rounded-lg text-blue-900 bg-blue-50 border-blue-200 cursor-pointer shadow-sm hover:scale-110 transition-transform" @click="$parent.$emit('ver_requerimiento', props.row)">788                                                <q-tooltip class="bg-blue-9 text-[9px] font-black tracking-widest uppercase">{{ b.d }}</q-tooltip>789                                            </q-badge>790                                        </template>791                                        <q-badge v-if="!props.row.rb.length" label="GENERAL" outline color="slate-4" class="font-black text-[8px] px-2 py-0.5 opacity-50 cursor-pointer" @click="$parent.$emit('ver_requerimiento', props.row)" />792                                    </div>793                                </q-td>794                            ''')795                            t.on('ver_requerimiento', lambda e: ui.notify(f"Detalle Solicitud: {e.args['detalles']}", type='info', icon='info', position='top'))796                            t.add_slot('body-cell-estado', '<q-td :props="props"><div class="row justify-center"><span :class="props.row.badge_classes + \' px-5 py-1.5 rounded-full font-black text-[9px] tracking-widest\'">{{ props.row.estado_label }}</span></div></q-td>')797                            t.add_slot('body-cell-gestion', '<q-td :props="props"><div class="row justify-center q-gutter-xs"><q-btn flat round size="xs" color="blue-9" icon="bolt" @click="() => $parent.$emit(\'ia\', props.row)"></q-btn><q-btn flat round size="xs" color="grey-6" icon="visibility" @click="() => $parent.$emit(\'ver\', props.row)"></q-btn><q-btn flat round size="xs" color="grey-6" icon="file_download" @click="() => $parent.$emit(\'pdf\', props.row)"></q-btn><q-btn flat round size="xs" color="grey-6" icon="edit" @click="() => $parent.$emit(\'edit\', props.row)"></q-btn></div></q-td>')798                            t.on('ia', lambda e: auto_emitir_ia(e.args)); t.on('ver', lambda e: abrir_lista_documentos(e.args)); t.on('pdf', lambda e: descargar_todos_pdf(e.args)); t.on('edit', lambda e: abrir_pre_flight(e.args))799                    else:800                        for s in solicitudes:801                            # REPETIR LÓGICA DE FORMATOS PARA CARDS802                            fmt_list = []803                            if s.get('actividad_id'):804                                if s.get('requiere_aro'): fmt_list.append('ARO')805                                if s.get('requiere_jsa'): fmt_list.append('JSA')806                                if s.get('requiere_permiso') and s.get('tipos_permiso_json'):807                                    try:808                                        extra = json.loads(s['tipos_permiso_json'])809                                        if isinstance(extra, list): fmt_list.extend(extra)810                                    except: pass811                            if not fmt_list: fmt_list = parse_formats_from_details(s['detalles'])812                            813                            rb = []814                            for r in fmt_list:815                                cat = get_abbreviated_cat(r)816                                if cat in ['ARO', 'JSA']:817                                    if not any(x['l'] == cat for x in rb): rb.append({'l': cat, 'd': f"Requerido: {r}"})818                            atr_list = [r for r in fmt_list if get_abbreviated_cat(r) not in ['ARO', 'JSA']]819                            if atr_list: rb.append({'l': 'ATR', 'd': " / ".join(atr_list)})820                            s['rb'] = rb821                            s['required_count'] = len(fmt_list) if fmt_list else 1822                            s['generated_count'] = s['total_formatos'] or 0823                            if s['generated_count'] == 0:824                                s['state_l'] = "PENDIENTE"; s['state_c'] = "bg-yellow-400"; s['acc_c'] = "bg-yellow-600"; s['badge_c'] = "bg-yellow-400 text-slate-900 shadow-sm"825                            elif s['generated_count'] < s['required_count']:826                                s['state_l'] = "DILIGENCIANDO"; s['state_c'] = "bg-blue-800"; s['acc_c'] = "bg-blue-900"; s['badge_c'] = "bg-blue-800 text-white shadow-sm"827                            else:828                                s['state_l'] = "GENERADO"; s['state_c'] = "bg-emerald-800"; s['acc_c'] = "bg-emerald-900"; s['badge_c'] = "bg-emerald-800 text-white shadow-sm"829 830                            with ui.card().classes('w-full p-0 bg-white rounded-2xl shadow-sm border border-slate-50 hover:shadow-md transition-all duration-300 group mb-1.5 overflow-hidden'):831                                with ui.row().classes('w-full items-center px-6 py-2 gap-0 relative'):832                                    ui.element('div').classes(f'absolute left-0 top-0 bottom-0 w-1.5 {s["acc_c"]} opacity-90')833                                    with ui.row().classes('w-full items-center gap-0'):834                                        with ui.column().classes('flex-1 items-center justify-center px-2'): ui.label(s['consecutivo']).classes('text-sm font-black text-blue-900')835                                        with ui.column().classes('flex-[1.5] items-start justify-center gap-1 px-4 border-l border-slate-50'):836                                            ui.label(s['proyecto_nombre']).classes('text-[11px] font-black text-blue-900 uppercase leading-none')837                                            with ui.row().classes('items-center gap-2'): 838                                                ui.icon('construction', size='12px', color='slate-300')839                                                ui.label(s.get('actividad_nombre') or 'Actividad Directa').classes('text-[10px] font-bold text-slate-500 uppercase tracking-tight')840                                        with ui.column().classes('flex-1 items-start justify-center px-4 border-l border-slate-50'):841                                            ui.label('SOLICITANTE').classes('text-[8px] font-black text-slate-300 tracking-widest mb-1')842                                            with ui.row().classes('items-center gap-2'): 843                                                ui.icon('person', size='14px', color='blue-900')844                                                ui.label(s['solicitante']).classes('text-[9px] font-black text-slate-700 uppercase')845                                        with ui.column().classes('flex-1 items-center justify-center border-l border-slate-50 px-4'):846                                            with ui.row().classes('gap-1 items-center justify-center'):847                                                if not s['rb']: ui.label('GENERAL').classes('text-[8px] font-black text-slate-400 bg-slate-50 px-2 py-0.5 rounded border border-slate-100')848                                                else:849                                                    for item in s['rb']: 850                                                        badge = ui.label(item['l']).classes('text-[8px] font-black text-blue-900 bg-blue-50 px-2 py-0.5 rounded uppercase border border-blue-100 cursor-help')851                                                        badge.tooltip(item['d'])852                                        with ui.column().classes('flex-1 items-center justify-center border-l border-slate-50 px-4'):853                                            ui.label(s['state_l']).classes(f'px-5 py-1.5 rounded-full font-black text-[9px] tracking-widest {s["badge_c"]}')854                                        with ui.column().classes('flex-1 items-center justify-center border-l border-slate-50 px-2'):855                                            with ui.row().classes('items-center gap-0.5'):856                                                if s['generated_count'] == 0: ui.button(icon='bolt', on_click=lambda d=s: auto_emitir_ia(d)).props('flat round color=blue-900 size=xs')857                                                else: ui.button(icon='bolt').props('flat round color=slate-200 size=xs').classes('opacity-20')858                                                ui.separator().props('vertical').classes('h-5 opacity-10 mx-1')859                                                ui.button(icon='visibility', on_click=lambda d=s: abrir_lista_documentos(d)).props('flat round color=slate-400 size=xs')860                                                ui.button(icon='file_download', on_click=lambda d=s: descargar_todos_pdf(d)).props('flat round color=slate-400 size=xs')861                                                ui.button(icon='edit', on_click=lambda d=s: abrir_pre_flight(d)).props('flat round color=slate-400 size=xs')862 863                    if not solicitudes_all:864                        with ui.column().classes('w-full items-center justify-center p-20 bg-white rounded-[2.5rem] border border-dashed border-slate-200'):865                            ui.icon('inbox', size='4rem', color='slate-200')866                            ui.label('No se encontraron registros activos').classes('text-slate-400 font-bold mt-4 uppercase text-[10px] tracking-widest')867                    868                    # --- BARRA DE PAGINACIÓN PREMIUM ---869                    with ui.row().classes('w-full items-center justify-between px-10 py-6 bg-white rounded-[2rem] shadow-sm border border-slate-50 mt-4 mb-20'):870                        with ui.row().classes('items-center gap-4'):871                            ui.label('Filas por página:').classes('text-[11px] font-black text-slate-400 uppercase tracking-widest')872                            ui.select({2: '2', 5: '5', 10: '10', 20: '20', 50: '50'}, value=filters.rows_per_page, 873                                on_change=lambda e: (setattr(filters, 'rows_per_page', e.value), setattr(filters, 'page', 1), render_requests.refresh())) \874                                .props('dense borderless').classes('bg-slate-50 px-4 rounded-xl text-xs font-black w-24 h-10 border border-slate-100')875                        876                        with ui.row().classes('items-center gap-8'):877                            first_item = start_idx + 1 if total_count > 0 else 0878                            last_item = min(end_idx, total_count)879                            ui.label(f'{first_item} - {last_item} de {total_count} resultados').classes('text-[11px] font-black text-slate-400 uppercase tracking-widest')880                            881                            with ui.row().classes('gap-1'):882                                # Navegación883                                ui.button(icon='first_page', on_click=lambda: (setattr(filters, 'page', 1), render_requests.refresh())) \884                                    .props('flat round dense color=slate-500').classes('hover:text-blue-900 hover:bg-blue-50')885                                886                                ui.button(icon='chevron_left', on_click=lambda: (setattr(filters, 'page', max(1, filters.page - 1)), render_requests.refresh())) \887                                    .props('flat round dense color=slate-500').classes('hover:text-blue-900 hover:bg-blue-50')888                                889                                # Números de Página (Sencillo: indicación de página actual)890                                with ui.row().classes('items-center bg-blue-50 px-4 py-1.5 rounded-xl border border-blue-100'):891                                    ui.label(f'PAGINA {filters.page} / {total_pages}').classes('text-[10px] font-black text-blue-900 tracking-[0.2em]')892 893                                ui.button(icon='chevron_right', on_click=lambda: (setattr(filters, 'page', min(total_pages, filters.page + 1)), render_requests.refresh())) \894                                    .props('flat round dense color=slate-500').classes('hover:text-blue-900 hover:bg-blue-50')895                                896                                ui.button(icon='last_page', on_click=lambda: (setattr(filters, 'page', total_pages), render_requests.refresh())) \897                                    .props('flat round dense color=slate-500').classes('hover:text-blue-900 hover:bg-blue-50')898 899                render_requests()900 901            # --- DIALOG LISTA DOCUMENTOS ---902            with ui.dialog().classes('w-full') as dialog_docs:903                with ui.card().classes('p-8 w-full max-w-xl rounded-[2.5rem] shadow-2xl border-none'):904                    ui.label('DOCUMENTOS GENERADOS').classes('text-2xl font-black text-slate-800 tracking-tighter mb-4')905                    @ui.refreshable906                    def render_lista_docs():907                        if not hasattr(dialog_docs, 'active_sol'): return908                        sol = dialog_docs.active_sol909                        from database.queries import db_fetchall910                        docs = db_fetchall("SELECT * FROM formatos_sst WHERE proyecto_id = ? AND actividad_id = ? AND estado='Generado'", (sol['proyecto_id'], sol['actividad_id']))911                        912                        if not docs:913                            with ui.column().classes('w-full items-center justify-center p-12 bg-slate-50 rounded-3xl border border-dashed border-slate-200 gap-4'):914                                ui.icon('history_edu', size='3rem', color='slate-300')915                                with ui.column().classes('items-center gap-1'):916                                    ui.label('Aún no hay documentos generados').classes('text-sm font-black text-slate-400 uppercase tracking-tighter')917                                    ui.label('Utiliza el botón de emisión para crear el primer formato.').classes('text-[10px] text-slate-400 font-medium')918                            return919 920                        with ui.column().classes('w-full gap-3 p-2'):921                            for d in docs:922                                with ui.row().classes('w-full items-center justify-between p-4 bg-slate-50 rounded-2xl border border-slate-100 hover:bg-blue-50 transition-all'):923                                    with ui.column().classes('gap-0'):924                                        ui.label(d['tipo_formato']).classes('text-sm font-black text-slate-800 uppercase')925                                        ui.label(d['consecutivo']).classes('text-[10px] text-slate-400 font-bold')926                                    with ui.row().classes('gap-1'):927                                        ui.button(icon='visibility', on_click=lambda d=d: ui.navigate.to(d['archivo_pdf_url'], new_tab=True)).props('flat round color=blue-9 size=sm').classes('opacity-60 hover:opacity-100')928                                        ui.button(icon='download', on_click=lambda d=d: ui.download(d['archivo_pdf_url'])).props('flat round color=slate-600 size=sm').classes('opacity-60 hover:opacity-100')929                                        930                                        async def corregir(doc=d, s=sol):931                                            dialog_docs.close()932                                            js_data = {}933                                            try: js_data = json.loads(doc.get('datos_json') or '{}')934                                            except: pass935                                            p_db = get_plantillas_formatos()936                                            937                                            # Búsqueda Robusta Mejorada: Prioridad a id_plantilla, luego coincidencias parciales inteligentes938                                            target_id = doc.get('id_plantilla')939                                            tpl = next((x for x in p_db if x.get('id') == target_id), None) if target_id else None940                                            941                                            if not tpl:942                                                d_type = (doc['tipo_formato'] or "").strip().upper()943                                                # Intentar match exacto primero944                                                tpl = next((x for x in p_db if (x.get('tipo_formato') or "").strip().upper() == d_type or (x.get('nombre') or "").strip().upper() == d_type), None)945                                                # Si no, match parcial946                                                if not tpl:947                                                    tpl = next((x for x in p_db if d_type in (x.get('nombre') or "").upper() or (x.get('tipo_formato') or "").upper() in d_type), None)948                                            949                                            if tpl: 950                                                await seleccionar_formato(tpl, s['proyecto_id'], s.get('actividad_id'), s['id'], existing_data=js_data, doc_id=doc['id'])951                                            else: 952                                                ui.notify('Plantilla original no encontrada. Contacte a Soporte SST.', color='negative', icon='warning')953 954                                        ui.button(icon='build_circle', on_click=corregir).props('flat round color=orange-8 size=sm').classes('opacity-80 hover:opacity-100')955                                        956                                        async def borrar_doc(doc=d):957                                            from database.queries import db_execute958                                            db_execute("DELETE FROM formatos_sst WHERE id=?", (doc['id'],))959                                            ui.notify('Documento eliminado', color='negative')960                                            render_lista_docs.refresh()961                                            render_requests.refresh()962 963                                        ui.button(icon='delete', on_click=borrar_doc).props('flat round color=red-8 size=sm').classes('opacity-60 hover:opacity-100')964                                            965                    render_lista_docs()966 967            def abrir_lista_documentos(row):968                dialog_docs.active_sol = row; render_lista_docs.refresh(); dialog_docs.open()969 970            async def descargar_todos_pdf(row):971                from database.queries import db_fetchall972                docs = db_fetchall("SELECT archivo_pdf_url FROM formatos_sst WHERE proyecto_id = ? AND actividad_id = ? AND estado='Generado'", (row['proyecto_id'], row['actividad_id']))973                if not docs: ui.notify('No hay PDFs generados'); return974                ui.notify(f'Iniciando descarga de {len(docs)} archivos...')975                for d in docs:976                    if d['archivo_pdf_url']: ui.download(d['archivo_pdf_url'])977                    await asyncio.sleep(0.5)978 979            async def editar_ultimo_formato(row):980                from database.queries import db_fetchall981                last = db_fetchall("SELECT * FROM formatos_sst WHERE proyecto_id = ? AND actividad_id = ? ORDER BY id DESC LIMIT 1", (row['proyecto_id'], row['actividad_id']))982                if last:983                    data = {}984                    try: data = json.loads(last[0].get('datos_json') or '{}')985                    except: pass986                    p_db = get_plantillas_formatos()987                    match = next((x for x in p_db if (x.get('tipo_formato') or "").strip().upper() == last[0]['tipo_formato'].strip().upper()), None)988                    if match: 989                        await seleccionar_formato(match, row['proyecto_id'], row['actividad_id'], row['id'], existing_data=data)990                    else: 991                        ui.notify('No se encontró la plantilla técnica vinculada')992 993            # --- DIALOG GUÍA DE MARCADORES (MODERNIZADO) ---994            with ui.dialog().classes('w-full') as dialog_guia_marcadores:995                with ui.card().classes('p-0 w-full max-w-2xl rounded-[3rem] shadow-2xl border-none overflow-hidden'):996                    with ui.column().classes('w-full p-8'):997                        with ui.row().classes('w-full justify-end mb-2'):998                            ui.button(on_click=dialog_guia_marcadores.close).props('flat round dense icon=close color=slate-400')999                        1000                        render_guia_marcadores_compacta(state, get_nicer_label, ALL_METADATA)1001 1002 1003 1004            # --- DIALOG HISTORIAL GLOBAL / MANUALES ---1005            with ui.dialog().classes('w-full') as dialog_historial_manual:1006                with ui.card().classes('p-10 w-full max-w-5xl rounded-[3rem] shadow-2xl border-none overflow-hidden'):1007                    with ui.column().classes('w-full h-[80vh]'):1008                        with ui.row().classes('w-full justify-between items-center mb-6'):1009                            with ui.column().classes('gap-1'):1010                                ui.label('REGISTRO HISTÓRICO').classes('text-xs font-black text-blue-900 tracking-[0.2em] uppercase opacity-40')1011                                ui.label('Permisos Generados').classes('text-4xl font-black text-slate-800 tracking-tighter')1012                            with ui.row().classes('items-center'):1013                                ui.button('VER BORRADORES', icon='description', on_click=lambda: ui.navigate.to('/sst/borradores?tab=drafts')) \1014                                    .props('flat dense size=sm color=blue-700').classes('text-[9px] font-black tracking-widest mr-4')1015                                ui.button(on_click=dialog_historial_manual.close).props('flat round dense icon=close color=slate-400')1016                        1017                        # --- BARRA DE FILTROS PREMIUM ---1018                        with ui.row().classes('w-full items-center gap-4 p-6 bg-slate-50/50 rounded-[2rem] border border-slate-100 mb-6'):1019                            ui.icon('filter_alt', color='blue-900').classes('opacity-40 ml-2')1020                            1021                            # Filtro 1: Búsqueda Texto1022                            search_input = ui.input(placeholder='Buscar por ID o Actividad...').props('outlined dense rounded-xl bg-white').classes('flex-[2] font-medium text-sm shadow-sm').on('update:model-value', lambda: render_tabla_historial.refresh())1023                            1024                            # Filtro 2: Tipo de Permiso1025                            map_types = ['TODOS', 'ARO', 'ALT', 'CAL', 'EXC', 'ESC', 'ELE', 'IZA', 'ENP']1026                            type_select = ui.select(options=map_types, value='TODOS').props('outlined dense rounded-xl bg-white').classes('flex-1 font-black text-xs uppercase shadow-sm').on('update:model-value', lambda: render_tabla_historial.refresh())1027                            1028                            # Filtro 3: Origen1029                            origin_select = ui.select(options=['TODOS', 'VINCULADOS', 'MANUALES'], value='TODOS').props('outlined dense rounded-xl bg-white').classes('flex-1 font-black text-xs uppercase shadow-sm').on('update:model-value', lambda: render_tabla_historial.refresh())1030 1031                        @ui.refreshable1032                        def render_tabla_historial():1033                            # FILTRADO INTELIGENTE: Excluir borradores y diseños, mostrar todo lo generado de cualquier medio1034                            all_docs = get_formatos_generados()1035                            1036                            txt = (search_input.value or "").upper()1037                            typ = type_select.value1038                            org = origin_select.value1039                            1040                            docs = [d for d in all_docs if not (str(d.get('estado') or '').startswith('Borrador') or d.get('estado') == 'Diseño')]1041                            1042                            if txt:1043                                docs = [d for d in docs if txt in (d['consecutivo'] or "").upper() or txt in (d.get('datos_json') or "").upper()]1044                            1045                            if typ != 'TODOS':1046                                docs = [d for d in docs if (d['consecutivo'] or "").startswith(typ)]1047                            1048                            if org != 'TODOS':1049                                if org == 'MANUALES': docs = [d for d in docs if not d['proyecto_id']]1050                                else: docs = [d for d in docs if d['proyecto_id']]1051 1052                            with ui.scroll_area().classes('w-full flex-1'):1053                                if not docs:1054                                    ui.label('No se han registrado permisos aún.').classes('text-slate-400 text-center w-full mt-20 italic')1055                                    return1056                                1057                                with ui.column().classes('w-full border border-slate-100 rounded-[2rem] bg-white shadow-xl overflow-hidden'):1058                                    # Header Estilo Dashboard Excel (Fijo)1059                                    with ui.row().classes('w-full px-6 py-4 bg-slate-50 border-b border-slate-200 gap-0 items-center min-w-[1100px]'):1060                                        ui.label('ID').classes('w-[7%] text-[9px] font-black text-slate-400 tracking-widest')1061                                        ui.label('TIPO / ACTIVIDAD').classes('w-[24%] text-[9px] font-black text-slate-400 tracking-widest px-2')1062                                        ui.label('CONTEXTO OPERATIVO').classes('w-[26%] text-[9px] font-black text-slate-400 tracking-widest px-2')1063                                        ui.label('RESPONSABLE / AUTOR').classes('w-[18%] text-[9px] font-black text-slate-400 tracking-widest px-2')1064                                        ui.label('FECHA GESTIÓN').classes('w-[10%] text-[9px] font-black text-slate-400 tracking-widest px-2 text-center')1065                                        ui.label('OPERACIONES').classes('flex-1 text-[9px] font-black text-slate-400 tracking-widest text-right')1066 1067                                    with ui.column().classes('w-full gap-0'):1068                                        for i, d in enumerate(docs):1069                                            # Extracción Inteligente de Datos1070                                            import json1071                                            jd = {}1072                                            try: jd = json.loads(d.get('datos_json') or '{}')1073                                            except: pass1074                                            1075                                            proy_name = d.get('proyecto_nombre') or jd.get('txt_proy') or 'Independiente'1076                                            lugar = jd.get('lugar') or 'Ubicación'1077                                            act_name = jd.get('txt_act') or d['tipo_formato'] or 'Actividad'1078                                            resp = jd.get('responsable') or 'Sin asignar'1079                                            autor = d['creado_por'] or 'IA'1080                                            1081                                            # Fila Organizadora Estilo Grilla Pro1082                                            is_last = (i == len(docs)-1)1083                                            with ui.row().classes(f'w-full items-center py-3 px-6 hover:bg-blue-50/30 transition-colors group min-w-[1100px] gap-0 {"border-b border-slate-100" if not is_last else ""}'):1084                                                1085                                                # 1. ID (7%)1086                                                with ui.column().classes('w-[7%]'):1087                                                    ui.label(d['consecutivo'] or f"ID-{d['id']}").classes('text-[11px] font-black text-blue-900 font-mono tracking-tighter')1088                                                1089                                                # 2. ACTIVIDAD (24%)1090                                                with ui.column().classes('w-[24%] gap-0 px-2'):1091                                                    ui.label(act_name).classes('text-[13px] font-black text-slate-800 uppercase tracking-tighter truncate w-full')1092                                                    ui.label(d['tipo_formato']).classes('text-[8px] text-slate-400 font-bold tracking-widest uppercase opacity-60')1093                                                1094                                                # 3. CONTEXTO (26%)1095                                                with ui.column().classes('w-[26%] gap-0 px-2'):1096                                                    ui.label(proy_name).classes('text-[12px] font-bold text-slate-600 truncate w-full')1097                                                    ui.label(lugar).classes('text-[9px] text-blue-600/50 font-black italic truncate-w-full italic')1098                                                1099                                                # 4. TRAZABILIDAD (18%)1100                                                with ui.column().classes('w-[18%] gap-0 px-2'):1101                                                    ui.label(resp).classes('text-[11px] font-black text-slate-500 truncate uppercase')1102                                                    ui.label(f"Por: {autor}").classes('text-[8px] font-bold text-slate-300 uppercase tracking-tight')1103                                                1104                                                # 5. FECHA (10%)1105                                                with ui.column().classes('w-[10%] items-center px-2'):1106                                                    ui.label(d['fecha'].split(' ')[0] if d['fecha'] else '---').classes('text-[11px] font-black text-slate-600')1107                                                    ui.label(d['fecha'].split(' ')[1] if d['fecha'] and ' ' in d['fecha'] else '').classes('text-[8px] font-bold text-slate-300 font-mono')1108                                                1109                                                # 6. OPERACIONES (Flex-1)1110                                                with ui.row().classes('flex-1 justify-end gap-1'):1111                                                    if d['archivo_pdf_url']:1112                                                        ui.button(on_click=lambda d=d: ui.navigate.to(d['archivo_pdf_url'], new_tab=True)).props('flat round color=blue-9 size=sm icon=visibility').classes('bg-blue-50/40')1113                                                        ui.button(on_click=lambda d=d: ui.download(d['archivo_pdf_url'])).props('flat round color=slate-400 size=sm icon=download')1114                                                    1115                                                    with ui.button(icon='more_horiz').props('flat round color=slate-300 size=sm') as opts_btn:1116                                                        with ui.menu().classes('rounded-2xl border-none shadow-2xl p-2') as opts_menu:1117                                                            async def edit_p(doc=d):1118                                                                opts_menu.close(); dialog_historial_manual.close()1119                                                                from database.queries_ai import get_plantillas_formatos1120                                                                jd_e = json.loads(doc.get('datos_json') or '{}')1121                                                                tpls = get_plantillas_formatos()1122                                                                match = next((x for x in tpls if x['id'] == doc.get('id_plantilla')), None)1123                                                                if match: await seleccionar_formato(match, doc['proyecto_id'], doc.get('actividad_id'), None, existing_data=jd_e, doc_id=doc['id'])1124                                                            1125                                                            with ui.menu_item(on_click=edit_p):1126                                                                with ui.row().classes('items-center gap-2 px-2'):1127                                                                    ui.icon('refresh', size='1rem', color='blue-9')1128                                                                    ui.label('RE-GENERAR').classes('text-[10px] font-black uppercase')1129                                                            1130                                                            async def del_p(doc_id=d['id']):1131                                                                opts_menu.close()1132                                                                from database.queries import db_execute1133                                                                db_execute("DELETE FROM formatos_sst WHERE id=?", (doc_id,))1134                                                                render_tabla_historial.refresh()1135                                                            1136                                                            with ui.menu_item(on_click=del_p):1137                                                                with ui.row().classes('items-center gap-2 px-2'):1138                                                                    ui.icon('delete', size='1rem', color='red-500')1139                                                                    ui.label('ELIMINAR').classes('text-[10px] font-black uppercase text-red-500')1140                        1141                        render_tabla_historial()1142 1143            # --- DIALOG CATÁLOGO GLOBAL (MODERNIZADO) ---1144            with ui.dialog().classes('w-full') as dialog_catalogo_global:1145                with ui.card().classes('p-12 w-full max-w-5xl rounded-[3rem] shadow-2xl border-none overflow-hidden'):1146                        @ui.refreshable1147                        def render_catalogo_wrapper():1148                            with ui.row().classes('w-full justify-between items-center mb-10 pt-6'):1149                                with ui.column().classes('gap-0'):1150                                    ui.label('CONFIGURACIÓN TÉCNICA').classes('text-[9px] font-black text-blue-900 tracking-[0.4em] uppercase opacity-40')1151                                    ui.label('Catálogo de Plantillas').classes('text-3xl font-black text-slate-800 tracking-tighter leading-none')1152                                with ui.row().classes('gap-4 items-center'):1153                                    ui.button('GUÍA DE MARCADORES', icon='help_outline', on_click=lambda: dialog_guia_marcadores.open()) \1154                                        .props('unelevated color=blue-50 text-color=blue-900 rounded-xl font-black text-[9px] size=sm') \1155                                        .classes('px-4 py-2.5 border border-blue-100 shadow-sm hover:bg-white transition-all')1156                                    ui.button(on_click=dialog_catalogo_global.close).props('flat round dense icon=close color=slate-400 size=md')1157 1158                            render_catalogo_plantillas_premium(1159                                state, 1160                                get_plantillas_formatos(), 1161                                update_callback=render_catalogo_wrapper.refresh,1162                                delete_callback=lambda r: proc_borrar(r, render_catalogo_wrapper.refresh),1163                                upload_trigger_fn=lambda r: (1164                                    setattr(dialog_subir, 'tipo_target', r['tipo_formato']), 1165                                    setattr(dialog_subir, 'on_success', render_catalogo_wrapper.refresh),1166                                    dialog_subir.open()1167                                ),1168                                get_abbr_fn=get_abbreviated_cat1169                            )1170                        render_catalogo_wrapper()1171 1172            # --- DIALOG GUÍA DE MARCADORES (REDISEÑO DE FOOTER FIJO) ---1173            with ui.dialog().classes('w-full') as dialog_guia_marcadores:1174                with ui.card().classes('p-6 w-full max-w-5xl rounded-[3rem] shadow-2xl border-none overflow-hidden'):1175                    render_guia_marcadores_compacta(state, get_nicer_label, ALL_METADATA)1176                    1177                    with ui.row().classes('w-full justify-center pt-2 shrink-0'):1178                        ui.button('CERRAR GUÍA', on_click=dialog_guia_marcadores.close).props('unelevated color=blue-9 rounded-xl font-black text-[10px] px-12 py-3 shadow-lg')1179 1180 1181            # --- LA "VENTANITA" PRE-FLIGHT ---1182            with ui.dialog().classes('w-full') as dialog_pre_flight:1183                with ui.card().classes('p-8 w-full max-w-md rounded-[2.5rem] shadow-2xl border-none ventanita-card'):1184                    with ui.row().classes('w-full justify-between items-center mb-6'):1185                        with ui.column().classes('gap-0'):1186                            ui.label('DILIGENCIAMIENTO').classes('text-xs font-black text-blue-900 tracking-[0.2em] uppercase opacity-40')1187                            ui.label('Selección de Formatos').classes('text-2xl font-black text-slate-800 tracking-tighter')1188                        ui.button(on_click=dialog_pre_flight.close).props('flat round dense icon=close color=slate-400')1189                    with ui.scroll_area().classes('w-full h-96'):1190                        @ui.refreshable1191                        def render_pre_flight_list():1192                            if not hasattr(dialog_pre_flight, 'active_sol'): return1193                            sol = dialog_pre_flight.active_sol1194                            formatos_req = parse_formats_from_details(sol['detalles'])1195                            p_db = get_plantillas_formatos()1196                            with ui.column().classes('w-full gap-3 p-1'):1197                                ui.label('PERMISOS REQUERIDOS SEGÚN ACTIVIDAD').classes('text-[10px] font-black text-blue-900 tracking-[0.2em] mb-2 opacity-60 uppercase')1198                                1199                                # Lista unificada de lo que se necesita1200                                for fn in (formatos_req if formatos_req else []):

Showing the first 1,200 of 1523 lines. Download the file for the rest.