CoolFace
Apppublic

fmatituy/selectospromanager

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
visor_casos.py332 linesDownload Raw Back to sst
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