fmatituy/selectospromanager
0
1import sqlite3
2import os
3from datetime import datetime
4from nicegui import ui, run
5from database.db import DB_PATH
6from services.auth import require_auth
7from modules.layout import SelectosLayout
8from modules.sst.services_sst_ext import get_stats_sst
9from database.queries import get_projects
10
11@ui.page('/sst/visor-casos')
12@require_auth
13def sst_visor_casos_page():
14 # Estilos CSS específicos para scroll interno y evitar "scroll bubbling"
15 ui.add_head_html('''
16 <style>
17 .custom-scrollbar::-webkit-scrollbar {
18 width: 4px;
19 }
20 .custom-scrollbar::-webkit-scrollbar-track {
21 background: transparent;
22 }
23 .custom-scrollbar::-webkit-scrollbar-thumb {
24 background: #e2e8f0;
25 border-radius: 10px;
26 }
27 .custom-scrollbar::-webkit-scrollbar-thumb:hover {
28 background: #cbd5e1;
29 }
30 .kanban-column-scroll {
31 overscroll-behavior: contain;
32 -ms-overflow-style: none;
33 scrollbar-width: thin;
34 }
35 </style>
36 ''')
37
38 # Paleta de Colores Institucional
39 CORP_BLUE = '#1F3A5F'
40 CORP_GREEN = '#2E7D32'
41 CORP_RED = '#B71C1C'
42 CORP_ORANGE = '#F57C00'
43 CORP_GRAY = '#2C3E50'
44 CORP_BG = '#F4F7F9'
45
46 # State for UI
47 state = {
48 'view_mode': 'Lista', # 'Lista', 'Tabla', 'Kanban'
49 'search_term': '',
50 'filter_estado': 'Todos',
51 'filter_severidad': 'Todos',
52 'filter_sede': 'Todas',
53 'filter_tipo': 'Todos',
54 'loading': False
55 }
56
57 # Data fetching
58 def get_casos_full():
59 conn = sqlite3.connect(DB_PATH)
60 c = conn.cursor()
61
62 query = """
63 SELECT 'Accidente' as tipo_evento, a.id, a.descripcion, a.lugar, a.fecha_accidente as fecha_evento,
64 a.estado, p.nombre as proyecto_nombre, u.nombre as reportado_por, a.fecha_reporte,
65 a.gravedad as severidad, a.usuario_id,
66 COALESCE((SELECT COUNT(*) FROM sst_plan_accion pa JOIN sst_investigaciones inv ON pa.investigacion_id = inv.id WHERE inv.relacion_id = a.id AND inv.relacion_tipo = 'Accidente'), 0) as total_plan,
67 COALESCE((SELECT COUNT(*) FROM sst_plan_accion pa JOIN sst_investigaciones inv ON pa.investigacion_id = inv.id WHERE inv.relacion_id = a.id AND inv.relacion_tipo = 'Accidente' AND pa.estado = 'Cerrado'), 0) as cerrado_plan
68 FROM accidentes_laborales a
69 JOIN proyectos p ON a.proyecto_id = p.id
70 JOIN usuarios u ON a.usuario_id = u.id
71
72 UNION ALL
73
74 SELECT 'Incidente' as tipo_evento, i.id, i.descripcion, i.lugar, i.fecha_incidente as fecha_evento,
75 i.estado, p.nombre as proyecto_nombre, u.nombre as reportado_por, i.fecha_reporte,
76 'Baja' as severidad, i.usuario_id,
77 COALESCE((SELECT COUNT(*) FROM sst_plan_accion pa JOIN sst_investigaciones inv ON pa.investigacion_id = inv.id WHERE inv.relacion_id = i.id AND inv.relacion_tipo = 'Incidente'), 0) as total_plan,
78 COALESCE((SELECT COUNT(*) FROM sst_plan_accion pa JOIN sst_investigaciones inv ON pa.investigacion_id = inv.id WHERE inv.relacion_id = i.id AND inv.relacion_tipo = 'Incidente' AND pa.estado = 'Cerrado'), 0) as cerrado_plan
79 FROM sst_incidentes i
80 JOIN proyectos p ON i.proyecto_id = p.id
81 JOIN usuarios u ON i.usuario_id = u.id
82
83 ORDER BY fecha_reporte DESC
84 """
85 c.execute(query)
86 rows = [dict(zip([col[0] for col in c.description], row)) for row in c.fetchall()]
87 conn.close()
88
89 for r in rows:
90 r['progreso'] = (r['cerrado_plan'] / r['total_plan'] * 100) if r['total_plan'] > 0 else 0
91 r['codigo'] = f"{'ACC' if r['tipo_evento'] == 'Accidente' else 'INC'}-{r['id']:04d}"
92
93 fecha_fmt = "%Y-%m-%d"
94 try:
95 f_reporte = datetime.strptime(r['fecha_reporte'].split(' ')[0], fecha_fmt)
96 r['dias_abierto'] = (datetime.now() - f_reporte).days
97 except:
98 r['dias_abierto'] = 0
99
100 r['vencido'] = r['dias_abierto'] > 30 and r['estado'] != 'Cerrado'
101
102 return rows
103
104 def apply_filters(casos):
105 res = casos
106 if state['search_term']:
107 term = str(state['search_term']).lower()
108 res = [c for c in res if term in str(c.get('descripcion', '')).lower() or term in str(c.get('codigo', '')).lower()]
109 if state['filter_estado'] != 'Todos':
110 res = [c for c in res if c['estado'] == state['filter_estado']]
111 if state['filter_severidad'] != 'Todos':
112 res = [c for c in res if c['severidad'] == state['filter_severidad']]
113 if state['filter_sede'] != 'Todas':
114 res = [c for c in res if c['proyecto_nombre'] == state['filter_sede']]
115 if state['filter_tipo'] != 'Todos':
116 res = [c for c in res if c['tipo_evento'] == state['filter_tipo']]
117 return res
118
119 def refresh_ui():
120 casos_area.refresh()
121
122 def abrir_asistente_ia():
123 ui.run_javascript("document.getElementById('global-chat-trigger')?.dispatchEvent(new CustomEvent('openchat', {detail: 'Sara IA'}))")
124 # --- DIÁLOGO DE NUEVO CASO ---
125 def form_nuevo_caso_dialog():
126 dialog = ui.dialog().classes('p-0 !max-w-none')
127 evidencia_files = {'paths': []}
128
129 with dialog, ui.card().classes('w-[95vw] sm:w-full md:max-w-4xl p-0 rounded-xl overflow-hidden bg-[#F4F7F9] max-h-[90vh] shadow-2xl'):
130 with ui.row().classes('w-full bg-white p-6 justify-between items-center border-b-4').style(f'border-color: {CORP_BLUE}'):
131 with ui.column().classes('gap-0'):
132 ui.label('REGISTRO TÉCNICO DE EVENTO').classes('text-[10px] font-black tracking-widest text-slate-400')
133 ui.label('Nuevo Reporte SST/FURAT').classes(f'text-2xl font-bold text-[{CORP_GRAY}]')
134 ui.button(on_click=dialog.close, icon='close').props('flat round color=slate-400 size=sm')
135
136 with ui.column().classes('w-full p-8 gap-6 overflow-y-auto'):
137 with ui.row().classes('w-full grid grid-cols-1 md:grid-cols-3 gap-4'):
138 tipo_event_sel = ui.select(['Accidente', 'Incidente'], label='Tipo de Evento', value='Accidente').props('outlined dense borderless').classes('bg-white rounded-lg')
139 proyectos_data = get_projects()
140 sede_sel = ui.select({p['id']: p['nombre'] for p in proyectos_data}, label='Sede / Proyecto').props('outlined dense').classes('bg-white rounded-lg')
141 fecha_event_input = ui.input(label='Fecha del Evento').props('outlined dense').classes('bg-white rounded-lg')
142 menu_fecha = None
143 with fecha_event_input.add_slot('append'):
144 ui.icon('calendar_today').on('click', lambda: menu_fecha.open()).classes('cursor-pointer')
145 with ui.menu() as menu_fecha:
146 ui.date().bind_value(fecha_event_input)
147
148 ui.label('DATOS DEL TRABAJADOR').classes('text-[10px] font-black text-slate-400 tracking-widest mt-4 uppercase')
149 with ui.row().classes('w-full grid grid-cols-1 md:grid-cols-2 gap-4'):
150 conn_u = sqlite3.connect(DB_PATH)
151 c_u = conn_u.cursor()
152 c_u.execute("SELECT id, nombre FROM usuarios")
153 usuarios_list = {row[0]: row[1] for row in c_u.fetchall()}
154 conn_u.close()
155 trabajador_sel = ui.select(usuarios_list, label='Colaborador Involucrado').props('outlined dense filter use-input').classes('bg-white rounded-lg')
156 cargo_input = ui.input('Cargo Actual').props('outlined dense').classes('bg-white rounded-lg')
157
158 ui.label('DESCRIPCIÓN TÉCNICA Y EVIDENCIA').classes('text-[10px] font-black text-slate-400 tracking-widest mt-4 uppercase')
159 desc_input = ui.textarea('Relato detallado de los hechos (SITIO / MODO / TIEMPO)').props('outlined').classes('w-full h-32 bg-white rounded-lg')
160
161 with ui.row().classes('w-full grid grid-cols-1 md:grid-cols-3 gap-4'):
162 sev_sel = ui.select(['Baja', 'Media', 'Alta', 'Crítica'], label='Severidad', value='Baja').props('outlined dense').classes('bg-white rounded-lg')
163 lesion_sel = ui.select(['Golpe/Contusión', 'Corte/Herida', 'Fractura', 'Quemadura', 'Otro'], label='Lesión').props('outlined dense').classes('bg-white rounded-lg')
164 parte_sel = ui.select(['Cabeza', 'Tronco', 'Manos', 'Pies'], label='Anatomía').props('outlined dense').classes('bg-white rounded-lg')
165
166 with ui.column().classes('w-full p-6 bg-white rounded-xl border border-slate-200 items-center justify-center'):
167 ui.label('CARGA DE EVIDENCIA FOTOGRÁFICA').classes('text-[9px] font-black text-slate-400 mb-4 tracking-widest')
168
169 def handle_file(e):
170 os.makedirs('static/uploads/sst', exist_ok=True)
171 file_path = f'static/uploads/sst/{datetime.now().strftime("%Y%m%d_%H%M%S")}_{e.name}'
172 with open(file_path, 'wb') as f:
173 f.write(e.content.read())
174 evidencia_files['paths'].append(file_path)
175 ui.notify(f'Archivo adjunto: {e.name}', color='emerald')
176
177 ui.upload(on_upload=handle_file, label='Seleccionar Archivos', auto_upload=True).props('flat bordered color=slate-400 rounded-lg icon=upload accept="image/*" multiple').classes('w-full bg-slate-50')
178
179 with ui.row().classes('w-full justify-end gap-3 p-6 bg-white border-t items-center shrink-0'):
180 ui.button('DESCARTAR', on_click=dialog.close).props('flat color=slate-400').classes('font-bold text-[10px] tracking-widest')
181 ui.button('REGISTRAR CASO', icon='check_circle', on_click=lambda: ui.notify('Caso guardado con éxito')).props('unelevated rounded-lg').style(f'background-color: {CORP_BLUE}').classes('px-8 py-2 font-bold text-white text-[10px] tracking-widest')
182
183 dialog.open()
184
185 with SelectosLayout('Visor de Gestión SST'):
186 ui.query('body').style(f'background-color: {CORP_BG}')
187
188 with ui.column().classes('w-full max-w-7xl mx-auto gap-4 p-4 md:p-8'):
189
190 # --- HEADER INSTITUCIONAL ---
191 with ui.row().classes('w-full flex-col sm:flex-row justify-between items-center bg-white p-6 sm:p-8 rounded-xl shadow-sm border-b-4 mb-2').style(f'border-color: {CORP_BLUE}'):
192 with ui.column().classes('gap-1'):
193 with ui.row().classes('items-center gap-2'):
194 ui.icon('emergency_share', color='slate-600', size='sm')
195 ui.label('CENTRO DE CONTROL TÉCNICO').classes('text-[10px] font-black tracking-[0.3em] text-slate-500 uppercase')
196 ui.label('Gestión y Visor de Casos').classes(f'text-3xl font-bold text-[{CORP_GRAY}] tracking-tight')
197 ui.label('Monitoreo normativo de accidentes e incidentes.').classes('text-xs text-slate-400 font-medium')
198
199 @ui.refreshable
200 def header_buttons():
201 with ui.row().classes('gap-3'):
202 modes = [('Lista', 'view_agenda'), ('Tabla', 'table_chart'), ('Kanban', 'view_kanban')]
203 for mode_name, icon_name in modes:
204 active = state['view_mode'] == mode_name
205 ui.button(None, icon=icon_name, on_click=lambda m=mode_name: (state.update({'view_mode': m}), header_buttons.refresh(), refresh_ui())) \
206 .props(f'unelevated rounded-lg size=md {"color=primary" if active else "bg-slate-50 text-slate-400"}').classes('shadow-none px-4')
207
208 async def download_consolidated():
209 ui.notify('Generando reporte consolidado de casos...', type='ongoing', spin=True)
210 from services.pdf_generator import generar_reporte_dashboard
211 from database.queries import get_projects
212 stats = get_stats_sst()
213 proyectos = get_projects()
214 pdf_url = await ui.run_javascript(f'window.open("{await run.io_bound(generar_reporte_dashboard, proyectos, stats)}", "_blank")', respond=False)
215 # Actually use ui.download for better reliability
216 pdf_url = await run.io_bound(generar_reporte_dashboard, proyectos, stats)
217 ui.download(pdf_url)
218 ui.notify('Reporte consolidado descargado exitosamente', type='positive')
219
220 ui.button('INFORME CONSOLIDADO', icon='summarize', on_click=download_consolidated) \
221 .props('flat color=slate-400 font-bold rounded-lg').classes('text-[10px] tracking-wider')
222
223 ui.button('REPORTE NUEVO', icon='add', on_click=lambda: form_nuevo_caso_dialog()) \
224 .props('unelevated rounded-lg').style(f'background-color: {CORP_BLUE}').classes('px-6 font-bold text-white text-[10px] tracking-widest ml-1')
225
226 header_buttons()
227
228 # KPIs TÉCNICOS
229 stats_raw = get_stats_sst()
230 kpi_data = [
231 ('Eventos Totales', sum(stats_raw['accidentes_estados'].values()), 'inventory_2', CORP_BLUE),
232 ('Casos Activos', sum(v for k,v in stats_raw['accidentes_estados'].items() if k != 'Cerrado'), 'pending_actions', CORP_ORANGE),
233 ('En Investigación', stats_raw['accidentes_estados'].get('Investigando', 0), 'biotech', CORP_GRAY),
234 ('% Cumplimiento', '92%', 'verified', CORP_GREEN)
235 ]
236
237 with ui.row().classes('w-full grid grid-cols-2 md:grid-cols-4 gap-4 py-2'):
238 for label, val, icon, color in kpi_data:
239 with ui.card().classes('p-6 rounded-xl bg-white border border-slate-200 shadow-sm flex flex-col h-full'):
240 with ui.row().classes('w-full justify-between items-start'):
241 ui.label(label).classes('text-[9px] font-black text-slate-400 uppercase tracking-widest')
242 ui.icon(icon, size='18px', color='slate-300')
243 ui.label(str(val)).classes(f'text-3xl font-bold text-[{CORP_GRAY}] tracking-tighter mt-2')
244 ui.separator().classes('my-2 opacity-30')
245 ui.label('REGISTRO OFICIAL').classes('text-[8px] font-bold text-slate-300 tracking-tighter')
246
247 # --- FILTROS SOBRIOS ---
248 proy_list = get_projects()
249 filter_sedes = ['Todas'] + [p['nombre'] for p in proy_list]
250
251 with ui.row().classes('w-full gap-4 items-center bg-white p-6 rounded-xl border border-slate-200 shadow-sm'):
252 search = ui.input(placeholder='Buscar por código o descripción...').props('outlined dense').classes('flex-1 bg-slate-50 rounded-lg text-xs')
253 search.bind_value(state, 'search_term').on('update:value', refresh_ui)
254
255 ui.select(['Todos', 'Reportado', 'Investigando', 'Cerrado'], label='Estado').bind_value(state, 'filter_estado').on_value_change(refresh_ui).props('outlined dense').classes('w-32 bg-slate-50 rounded-lg text-xs')
256 ui.select(filter_sedes, label='Sede').bind_value(state, 'filter_sede').on_value_change(refresh_ui).props('outlined dense').classes('w-40 bg-slate-50 rounded-lg text-xs')
257 ui.button(icon='refresh', on_click=refresh_ui).props('flat round color=slate-400')
258
259 # --- LISTADO CONTENIDO ---
260 @ui.refreshable
261 def casos_area():
262 casos = apply_filters(get_casos_full())
263
264 if not casos:
265 with ui.column().classes('w-full items-center py-20 bg-white border border-dashed border-slate-200 rounded-xl'):
266 ui.icon('search_off', size='48px', color='slate-100')
267 ui.label('Sin registros encontrados').classes('text-slate-300 font-bold mt-4 uppercase tracking-[0.2em] text-[10px]')
268 return
269
270 if state['view_mode'] == 'Lista':
271 with ui.column().classes('w-full gap-3'):
272 for c in casos:
273 color_sev = {
274 'Crítica': CORP_RED, 'Alta': CORP_ORANGE, 'Media': '#FFD600', 'Baja': CORP_GREEN
275 }.get(c['severidad'], 'slate-200')
276
277 with ui.card().classes('w-full p-0 rounded-xl border border-slate-200 bg-white hover:border-slate-400 transition-all overflow-hidden relative'):
278 ui.element('div').classes('absolute top-0 left-0 w-2 h-full').style(f'background-color: {color_sev}')
279
280 with ui.row().classes('w-full items-center p-6 gap-6'):
281 with ui.column().classes('w-32 gap-0 border-r border-slate-50'):
282 ui.label(c['codigo']).classes('text-[10px] font-black tracking-widest').style(f'color: {CORP_BLUE}')
283 ui.badge(c['tipo_evento']).props('unelevated color=slate-100 text-color=slate-500').classes('text-[8px] font-bold px-1 rounded')
284 ui.label(c['fecha_evento']).classes('text-[9px] text-slate-400 font-bold mt-1')
285
286 with ui.column().classes('flex-1 gap-1'):
287 ui.label(c['descripcion'][:150] + "...").classes(f'text-sm font-bold text-[{CORP_GRAY}] tracking-tight leading-tight')
288 with ui.row().classes('items-center gap-3'):
289 ui.label(c['proyecto_nombre']).classes('text-[9px] font-black text-slate-300 uppercase tracking-widest')
290 ui.label(f"POR: {c['reportado_por']}").classes('text-[9px] font-black text-slate-300 uppercase tracking-widest')
291
292 with ui.column().classes('w-44 gap-1 items-end'):
293 ui.badge(c['estado']).props('unelevated color=slate-50 text-color=slate-600').classes('text-[9px] font-black border border-slate-200 px-3')
294 ui.button('VER EXPEDIENTE', icon='visibility').props('flat size=sm color=primary').classes('text-[9px] font-black') \
295 .on('click', lambda e, cid=c['id'], t=c['tipo_evento']: ui.navigate.to(f'/sst/{"accidentes" if t=="Accidente" else "incidentes"}/{cid}'))
296
297 elif state['view_mode'] == 'Tabla':
298 cols = [
299 {'name': 'codigo', 'label': 'CÓDIGO', 'field': 'codigo', 'align': 'left'},
300 {'name': 'severidad', 'label': 'SEVERIDAD', 'field': 'severidad', 'align': 'center'},
301 {'name': 'estado', 'label': 'ESTADO', 'field': 'estado', 'align': 'center'},
302 {'name': 'sede', 'label': 'SEDE', 'field': 'proyecto_nombre', 'align': 'left'},
303 ]
304 with ui.card().classes('w-full p-4 rounded-xl bg-white border border-slate-200 shadow-sm'):
305 ui.table(columns=cols, rows=casos).classes('w-full border-none shadow-none').props('flat dense size=sm')
306
307 elif state['view_mode'] == 'Kanban':
308 cols_k = ['Reportado', 'Investigando', 'Plan de acción', 'Cerrado']
309 with ui.row().classes('w-full gap-6 overflow-x-auto flex-nowrap items-start py-2'):
310 for ck in cols_k:
311 ck_casos = [c for c in casos if c['estado'] == ck]
312 with ui.column().classes('w-[320px] shrink-0 bg-slate-100/50 p-4 rounded-xl border border-slate-200'):
313 ui.label(ck.upper()).classes('text-[10px] font-black text-slate-400 tracking-widest mb-4 px-2')
314 with ui.column().classes('w-full gap-4'):
315 for c in ck_casos:
316 with ui.card().classes('w-full p-4 rounded-lg bg-white border border-slate-200 shadow-sm hover:border-primary transition-all cursor-pointer') \
317 .on('click', lambda e, cid=c['id']: ui.navigate.to(f'/sst/investigar/{cid}')):
318 ui.label(c['codigo']).classes('text-[9px] font-black text-primary')
319 ui.label(c['descripcion']).classes('text-xs font-bold text-slate-700 line-clamp-2 mt-1')
320 ui.label(c['proyecto_nombre']).classes('text-[8px] font-black text-slate-300 mt-2')
321
322 casos_area()
323
324 # --- BOTÓN ASISTENTE MODERNO ---
325 with ui.row().classes('w-full justify-center mt-12'):
326 ui.button('ANÁLISIS AUTOMATIZADO DE CUMPLIMIENTO', icon='psychology', on_click=abrir_asistente_ia) \
327 .props('unelevated rounded-xl').style(f'background-color: {CORP_GRAY}') \
328 .classes('px-12 py-4 font-bold text-white text-xs tracking-widest shadow-lg')
329
330
331
332 