fmatituy/selectospromanager
0
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 []):