fmatituy/selectospromanager
0
1from nicegui import ui, app
2import os
3import asyncio
4import base64
5from datetime import datetime
6from services.auth import require_auth, get_current_user
7from modules.layout import SelectosLayout
8from services.document_engine import DocumentEngine
9from .services_sst import save_plantilla
10
11# Montar ruta estática específica para el escáner
12os.makedirs('static/temp_scans', exist_ok=True)
13app.add_static_files('/static_scans', 'static/temp_scans')
14
15@ui.page('/sst/scan-designer')
16@require_auth
17def scan_designer_page():
18 user = get_current_user()
19
20 # UI State
21 class ScanState:
22 def __init__(self):
23 self.image_b64 = None
24 self.extracted_fields = []
25 self.processing = False
26 self.template_name = ""
27 self.template_type = "ARO"
28 self.engineering_mode = False
29
30 state = ScanState()
31
32 with SelectosLayout('CamScanner Pro - Extractor de Formatos'):
33 # Styles for a premium look
34 ui.add_head_html("""
35 <style>
36 .glass-card {
37 background: rgba(255, 255, 255, 0.7);
38 backdrop-filter: blur(15px);
39 border: 1px solid rgba(255, 255, 255, 0.4);
40 border-radius: 2.5rem;
41 }
42 .scanner-line {
43 position: absolute;
44 width: 100%;
45 height: 6px;
46 background: linear-gradient(90deg, transparent, #3b82f6, transparent);
47 box-shadow: 0 0 25px #3b82f6;
48 top: 0;
49 left: 0;
50 animation: scan 4s infinite linear;
51 z-index: 100;
52 display: none;
53 }
54 @keyframes scan {
55 0% { top: 0; }
56 100% { top: 100%; }
57 }
58 .btn-premium {
59 background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%);
60 color: white;
61 border-radius: 1.5rem;
62 font-weight: 900;
63 letter-spacing: 0.15em;
64 text-transform: uppercase;
65 box-shadow: 0 15px 30px -10px rgba(59, 130, 246, 0.5);
66 transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
67 }
68 .btn-premium:hover {
69 transform: translateY(-4px) scale(1.02);
70 box-shadow: 0 20px 40px -10px rgba(59, 130, 246, 0.7);
71 }
72 .field-card {
73 transition: all 0.3s ease;
74 }
75 .field-card:hover {
76 box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
77 transform: translateX(4px);
78 }
79 </style>
80 """)
81
82 with ui.column().classes('w-full items-center p-8 gap-8 bg-slate-50 min-h-screen'):
83 # Header
84 with ui.row().classes('w-full max-w-7xl justify-between items-center bg-white p-6 rounded-3xl shadow-sm border border-slate-100'):
85 with ui.row().classes('items-center gap-6'):
86 with ui.element('div').classes('p-4 bg-blue-600 rounded-2xl shadow-lg shadow-blue-200'):
87 ui.icon('document_scanner', color='white', size='2rem')
88 with ui.column().classes('gap-0'):
89 ui.label('SST ARTIFICIAL INTELLIGENCE').classes('text-blue-600 font-black tracking-[0.3em] text-[10px]')
90 ui.label('Scanner 3.6 RELOADED').classes('text-4xl font-black text-slate-800 tracking-tighter')
91
92 with ui.row().classes('gap-3 items-center'):
93 ui.switch('Modo Ingeniería').bind_value(state, 'engineering_mode').classes('mr-4 font-bold text-xs text-slate-600')
94 ui.button('HUB SST', on_click=lambda: ui.navigate.to('/sst/operacion')).props('flat color=slate-400 font-bold')
95 ui.button('CONSTRUCTOR', on_click=lambda: ui.navigate.to('/sst/constructor')).props('outline color=blue rounded-xl font-bold')
96
97 with ui.row().classes('w-full max-w-7xl gap-10 items-start mt-4'):
98 # Left Side: Scanner Preview
99 with ui.column().classes('flex-1 gap-8'):
100 with ui.card().classes('w-full p-0 overflow-hidden glass-card shadow-2xl relative border-none'):
101 # El contenedor tiene posición relativa y fondo blanco para contraste
102 scanner_container = ui.column().classes('w-full h-[75vh] min-h-[600px] items-center justify-center bg-white cursor-pointer relative') \
103 .on('click', lambda: upload.run_method('pickFiles'))
104
105 with scanner_container:
106 # 1. Imagen de previsualización (Z-INDEX ALTO)
107 # Usamos fit=contain para responsividad perfecta sin deformar y un ligero padding (p-4)
108 preview_img = ui.image(visible=False).classes('w-full h-full absolute inset-0 z-40 p-4').props('fit=contain no-spinner').style('background: transparent;')
109
110 # NUEVO: Capa de previsualización interactiva sobre el mismo frame (Z-INDEX MAXIMO)
111 preview_layer = ui.column().classes('w-full h-full absolute inset-0 z-50 bg-white/95 p-8 overflow-y-auto custom-scrollbar hidden items-center')
112 with preview_layer:
113 with ui.row().classes('w-full max-w-3xl items-center justify-between mb-8 pb-4 border-b border-slate-200'):
114 with ui.column().classes('gap-0'):
115 ui.label('VERSIÓN INTERACTIVA').classes('text-blue-600 font-black tracking-[0.3em] text-[10px]')
116 ui.label('Así quedó tu formato').classes('text-2xl font-black text-slate-800')
117 ui.button('VOLVER A LA IMAGEN', icon='image', on_click=lambda: toggle_preview_layer(False)) \
118 .props('unelevated color=slate-900 rounded-xl').classes('font-bold shadow-lg')
119
120 # Aquí se insertan los campos dinámicamente simulando un documento A4
121 interactive_container = ui.column().classes('w-full max-w-3xl gap-5 bg-white p-10 rounded-3xl shadow-2xl border border-slate-100 mb-20')
122
123 # 2. Placeholder (Z-INDEX MEDIO)
124 upload_placeholder = ui.column().classes('items-center z-10 w-full justify-center')
125 with upload_placeholder:
126 ui.icon('cloud_upload', size='160px', color='blue-300').classes('mb-6 opacity-40')
127 ui.label('CARGA TU FORMATO ANÁLOGO').classes('text-slate-500 font-black text-xs tracking-[0.4em]')
128 ui.label('Soportamos PDF, PNG de alta resolución y JPEG').classes('text-slate-400 text-[10px] font-bold mt-2')
129
130 # 3. Línea de escaneo (Z-INDEX OVERLAY)
131 scanner_line = ui.element('div').classes('scanner-line').style('z-index: 100;')
132
133 # 4. CAPA DE PROGRESO IN-PLACE (Oculta por defecto)
134 progress_layer = ui.column().classes('absolute inset-0 z-[150] bg-slate-900/90 backdrop-blur-md items-center justify-center flex-col hidden')
135 with progress_layer:
136 progress_circle = ui.circular_progress(min=0, max=100, value=0, show_value=True, size='160px', color='blue-500').props('thickness=0.1').classes('text-3xl font-black text-white m-4')
137 progress_label = ui.label('INICIALIZANDO MOTOR ESTRUCTURAL...').classes('text-blue-400 font-bold tracking-widest text-[10px] mt-4 uppercase text-center w-full block')
138
139 upload = ui.upload(on_upload=lambda e: handle_upload(e), auto_upload=True).classes('hidden')
140
141 # Right Side: Extracted Structure
142 with ui.column().classes('w-[450px] gap-8'):
143 with ui.card().classes('w-full p-10 glass-card bg-white/80 shadow-2xl border-none'):
144 with ui.row().classes('items-center gap-3 mb-8'):
145 ui.icon('settings_suggest', color='blue-600', size='sm')
146 ui.label('MOTOR DE DIGITALIZACIÓN').classes('text-[10px] font-black text-slate-400 tracking-[0.3em] uppercase')
147
148 ui.input('Nombre de la Plantilla').bind_value(state, 'template_name').props('outlined rounded-2xl bg-white shadow-inner').classes('w-full mb-6 font-bold')
149
150 with ui.row().classes('w-full grid grid-cols-1 mb-10'):
151 ui.select(['ARO', 'JSA', 'Inspección', 'Permiso'], label='Categoría Documental').bind_value(state, 'template_type').props('outlined rounded-2xl bg-white shadow-inner').classes('w-full')
152
153 with ui.row().classes('w-full justify-between items-center mb-6 pt-4 border-t border-slate-100'):
154 ui.label('CAMPOS DINÁMICOS DETECTADOS').classes('text-[10px] font-black text-slate-400 tracking-[0.3em] uppercase')
155 ui.badge('0', color='blue-600').bind_text_from(state, 'extracted_fields', lambda x: str(len(x)))
156
157 field_list = ui.column().classes('w-full gap-4 max-h-[450px] overflow-y-auto pr-3 custom-scrollbar')
158
159 with field_list:
160 with ui.column().classes('w-full items-center justify-center py-20 bg-slate-50/50 rounded-3xl border-2 border-dashed border-slate-200'):
161 ui.icon('precision_manufacturing', size='4rem', color='blue-100')
162 ui.label('Esperando arquitectura...').classes('text-slate-300 font-bold mt-4 text-xs tracking-widest')
163
164 ui.button('AGREGAR CAMPO MANUAL', icon='add', on_click=lambda: add_manual_field()) \
165 .props('flat color=blue-600').classes('w-full mt-4 font-bold text-xs')
166
167 with ui.row().classes('w-full gap-3 mt-6'):
168 ui.button('VER FORMATO DIGITAL', icon='preview', on_click=lambda: toggle_preview_layer(True)) \
169 .props('outline color=slate-600').classes('flex-1 font-bold rounded-2xl h-14')
170
171 ui.button('PUBLICAR PLANTILLA MAESTRA', on_click=lambda: save_scan()) \
172 .classes('flex-1 h-14 btn-premium rounded-2xl') \
173 .props('icon=rocket_launch')
174
175 def toggle_preview_layer(show: bool):
176 if show:
177 if not state.extracted_fields:
178 ui.notify('Aún no hay coordenadas mapeadas', type='warning')
179 return
180
181 # Diseño de Blueprint UX:
182 # Si es modo Ingeniería -> Fondo escala de grises / Blueprint Técnico.
183 # Si es Operativo -> Capa limpia transparente
184 if state.engineering_mode:
185 preview_img.style('filter: grayscale(1) invert(0.1) sepia(1) hue-rotate(180deg) opacity(0.8); transition: filter 0.5s;')
186 preview_layer.classes(remove='hidden bg-white/95 bg-slate-900/50 mb-20 gap-5', add='bg-slate-900/80')
187 else:
188 preview_img.style('filter: none; transition: filter 0.5s;')
189 preview_layer.classes(remove='hidden bg-white/95 bg-slate-900/50 mb-20 gap-5', add='bg-transparent')
190
191 interactive_container.clear()
192 interactive_container.classes(remove='bg-white p-10 shadow-2xl rounded-3xl border border-slate-100 p-8', add='relative w-full h-[80vh] p-4 bg-transparent')
193
194 with interactive_container:
195 for i, field in enumerate(state.extracted_fields):
196 tipo = field['tipo']
197 label = field['label'] or 'Campo OCR'
198 x, y = field.get('x', 0), field.get('y', 0)
199 w, h = field.get('w', 30), field.get('h', 5)
200
201 # Reload 3.6-T: Posicionamiento absoluto preciso
202 pos_style = f"position: absolute; left: {x}%; top: {y}%; width: {w}%; height: {h}%; transform: translateY(-50%);"
203
204 if state.engineering_mode:
205 # 3.6-T ENGINEERING MESH
206 with ui.element('div').style(pos_style).classes('z-50 group hover:z-[60]'):
207 with ui.column().classes('w-full h-full border border-cyan-500/50 backdrop-blur-sm bg-slate-900/60 p-0 hover:border-cyan-400 transition-colors'):
208 with ui.row().classes('w-full bg-cyan-500/20 items-center justify-between px-1 h-3'):
209 ui.label(f"ID:{i} | {tipo}").classes('text-[7px] text-cyan-400 font-mono font-bold leading-none uppercase')
210 ui.label(f"{x:.1f}% x {y:.1f}%").classes('text-[6px] text-slate-500 font-mono hidden group-hover:block')
211 with ui.row().classes('w-full flex-1 px-1 pb-1 -mt-1 items-center relative'):
212 ui.input(value=label, on_change=lambda e, idx=i: update_field(idx, 'label', e.value)) \
213 .props('borderless dense').classes('w-full text-[10px] text-white bg-transparent h-full font-mono font-semibold')
214 else:
215 # 3.6-T CORPORATE OVERLAY
216 # Colores basados en tipo para jerarquía industrial
217 border_color = 'emerald' if tipo == 'Firma' else 'blue' if tipo == 'Titulo' else 'slate'
218 with ui.element('div').style(pos_style).classes(f'z-50 shadow-sm rounded-md bg-white/95 border border-{border_color}-500/50 hover:shadow-md transition-all p-[2px]'):
219 if tipo == 'Firma':
220 with ui.row().classes('w-full h-full items-center justify-center bg-emerald-50/50 rounded-sm'):
221 ui.icon('draw', size='1rem', color='emerald-300')
222 ui.label(label).classes('text-[8px] text-emerald-600 font-bold uppercase truncate px-1')
223 elif tipo == 'Checklist':
224 with ui.row().classes('w-full h-full items-center px-2 gap-2'):
225 ui.element('div').classes('w-3 h-3 border border-slate-300 rounded-sm bg-white')
226 ui.label(label).classes('text-[9px] text-slate-600 font-bold truncate')
227 else:
228 ui.input(value=label, on_change=lambda e, idx=i: update_field(idx, 'label', e.value)) \
229 .props('borderless dense').classes('w-full h-full text-[11px] text-slate-800 font-bold px-1')
230 else:
231 preview_layer.classes('hidden bg-white/95', remove='bg-slate-900/80 bg-transparent')
232 preview_img.style('filter: none;')
233
234 async def handle_upload(e):
235 try:
236 # 1. LECTURA INMEDIATA
237 ui.notify('Procesando archivo en memoria...', type='info', position='top')
238 content = await e.file.read()
239 filename_orig = e.file.name
240
241 if not content:
242 ui.notify('Archivo vacío o lectura fallida', type='negative')
243 return
244
245 # Identificar MIME
246 mime = "image/png"
247 if filename_orig.lower().endswith((".jpg", ".jpeg")): mime = "image/jpeg"
248 elif filename_orig.lower().endswith(".pdf"): mime = "application/pdf"
249
250 # Guardado Físico
251 filename = f"scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename_orig}"
252 temp_dir = 'static/temp_scans'
253 os.makedirs(temp_dir, exist_ok=True)
254 temp_path = os.path.join(temp_dir, filename)
255
256 with open(temp_path, 'wb') as f:
257 f.write(content)
258
259 # 2. CARGA VISUAL (Base64 garantizado para no fallar por rutas web)
260 b64 = base64.b64encode(content).decode('utf-8')
261
262 # Limpiar estado superpuesto anterior
263 preview_layer.classes('hidden')
264 preview_img.classes(remove='hidden')
265
266 if mime == "application/pdf":
267 ui.notify('PDF recibido. Extrayendo imagen...', type='info')
268 # No podemos poner PDF en ui.image, pero el motor lo convertirá.
269 else:
270 preview_img.set_source(f'data:{mime};base64,{b64}')
271 preview_img.set_visibility(True)
272 upload_placeholder.set_visibility(False)
273 scanner_container.classes(remove='bg-white', add='bg-slate-900 shadow-inner')
274
275 # Pausa visual
276 await asyncio.sleep(0.5)
277
278 # 3. ANIMACIÓN Y ESCANEO TÉCNICO VÍA PIPELINE
279 state.processing = True
280 scanner_line.style('display: block;')
281
282 # Mostrar UI de Progreso in-place
283 progress_layer.classes(remove='hidden', add='flex')
284 progress_circle.value = 0
285 progress_label.set_text('GEOMETRIC ENGINE: EXTRAYENDO TOPOLOGÍA')
286
287 # Tarea asíncrona que simula el progreso heurístico del Motor de Documentos
288 async def update_progress():
289 try:
290 for i in range(1, 95):
291 if not state.processing: break
292 progress_circle.value = i
293 if i == 25: progress_label.set_text('STRUCTURAL ENGINE: EJECUTANDO DBSCAN')
294 if i == 50: progress_label.set_text('STRUCTURAL ENGINE: MAPEO DE TABLAS')
295 if i == 75: progress_label.set_text('SEMANTIC ENGINE: CLASIFICACIÓN NLP')
296 await asyncio.sleep(0.12) # Simula un barrido
297 except asyncio.CancelledError:
298 pass
299
300 prog_task = asyncio.create_task(update_progress())
301
302 # 4. PROCESAMIENTO OCR ASÍNCRONO MULTI-MOTOR
303 loop = asyncio.get_event_loop()
304 result = await loop.run_in_executor(None, DocumentEngine.process_document, temp_path)
305
306 # Finalizar Proceso y limpiar el Task
307 prog_task.cancel()
308 progress_circle.value = 100
309 progress_label.set_text('ENSAMBLANDO ÁRBOL JERÁRQUICO')
310 await asyncio.sleep(0.6) # Breve pausa para que el usuario aprecie el 100%
311 progress_layer.classes(remove='flex', add='hidden')
312
313 if result.get('success'):
314 state.extracted_fields = result.get('structure', [])
315 update_field_list()
316
317 # No sobreescribimos la imagen original para que el usuario siempre vea su fuente limpia
318 # Solo usamos la lógica si necesitábamos rescatar un thumbnail de un PDF
319 if mime == "application/pdf" and result.get('processed_image') and os.path.exists(result['processed_image']):
320 with open(result['processed_image'], 'rb') as fp:
321 b64_p = base64.b64encode(fp.read()).decode('utf-8')
322 preview_img.set_source(f'data:image/png;base64,{b64_p}')
323 preview_img.set_visibility(True)
324 upload_placeholder.set_visibility(False)
325
326 ui.notify('¡Estructura mapeada con éxito!', type='positive', icon='fact_check', position='top')
327 if not state.template_name:
328 state.template_name = f"Formato_Digital_{datetime.now().strftime('%H%M%S')}"
329 else:
330 ui.notify(f"Error Técnico: {result.get('error')}", type='negative', position='top', multi_line=True)
331
332 scanner_line.style('display: none;')
333 state.processing = False
334
335 except Exception as exc:
336 import traceback
337 err_msg = traceback.format_exc()
338 print(f"ERROR CRÍTICO EN UPLOAD: {err_msg}")
339 ui.notify(f"Fallo grave procesando imagen: {str(exc)}", type='negative', multi_line=True)
340 scanner_line.style('display: none;')
341 state.processing = False
342 def update_field_list():
343 field_list.clear()
344 if not state.extracted_fields:
345 with field_list:
346 with ui.column().classes('w-full items-center justify-center py-20 bg-slate-50/50 rounded-3xl border-2 border-dashed border-slate-200'):
347 ui.icon('grid_on', size='4rem', color='slate-200')
348 ui.label('ESPERANDO ESCANEO INDUSTRIAL...').classes('text-slate-400 font-bold mt-4 text-[10px] tracking-widest')
349 return
350
351 with field_list:
352 # TABLA DINÁMICA DE ARQUITECTURA (Antigravity Style)
353 columns = [
354 {'name': 'label', 'label': 'NOMBRE DEL CAMPO', 'field': 'label', 'align': 'left'},
355 {'name': 'tipo', 'label': 'TIPO', 'field': 'tipo', 'align': 'center'},
356 {'name': 'actions', 'label': '', 'field': 'label', 'align': 'right'}
357 ]
358
359 with ui.column().classes('w-full gap-2'):
360 for i, field in enumerate(state.extracted_fields):
361 with ui.row().classes('w-full items-center bg-white p-3 rounded-2xl border border-slate-100 shadow-sm hover:translate-x-2 transition-transform'):
362 # Icono por tipo
363 icon_map = {'Titulo': 'title', 'Texto': 'short_text', 'Fecha': 'event', 'Firma': 'draw', 'Checklist': 'fact_check', 'Area': 'notes'}
364 ui.icon(icon_map.get(field['tipo'], 'label'), color='blue-600').classes('bg-blue-50 p-2 rounded-lg')
365
366 with ui.column().classes('flex-1 gap-0'):
367 ui.input(value=field['label'], on_change=lambda e, idx=i: update_field(idx, 'label', e.value)) \
368 .props('borderless dense input-style="font-weight: 700; color: #1e293b; font-size: 11px;"').classes('w-full')
369
370 with ui.row().classes('items-center gap-2'):
371 ui.label(f"COORD: {field['x']:.1f}% , {field['y']:.1f}%").classes('text-[8px] text-slate-400 font-mono uppercase')
372 ui.badge(field['tipo'], color='blue-100').classes('text-blue-700 text-[8px] font-black rounded-md shadow-none px-2 py-0')
373
374 ui.button(icon='delete', on_click=lambda idx=i: remove_field(idx)).props('flat round size=sm color=rose-300')
375
376 def update_field(idx, key, value):
377 state.extracted_fields[idx][key] = value
378 # Sincronización inmediata con el overlay interactivo si está abierto
379 if preview_layer.visible:
380 toggle_preview_layer(True)
381
382 def add_manual_field():
383 new_f = {"tipo": "Texto", "label": "NUEVO CAMPO", "x": 0, "y": 0, "w": 50, "obligatorio": False}
384 state.extracted_fields.append(new_f)
385 update_field_list()
386
387 def remove_field(idx):
388 state.extracted_fields.pop(idx)
389 update_field_list()
390
391 def save_scan():
392 if not state.template_name:
393 ui.notify('Asigna un nombre técnico a la plantilla', type='warning', icon='warning', position='top')
394 return
395 if not state.extracted_fields:
396 ui.notify('No se han detectado campos para exportar', type='warning', icon='error', position='top')
397 return
398
399 # CONVERSIÓN DE FORMATO: ScanDesigner(%) -> Constructor(PX en Y)
400 # El constructor usa un lienzo de ~1400px de alto para su previsualización A4.
401 # Para que se vea IGUAL al ejemplo, convertimos los porcentajes de Y a esa escala.
402 A4_HEIGHT = 1400
403 compatible_fields = []
404
405 for field in state.extracted_fields:
406 f_copy = field.copy()
407 # Convertimos Y y H de % a PX absolutos (Constructor standard)
408 f_copy['y'] = round((field['y'] / 100) * A4_HEIGHT)
409 f_copy['h'] = round((field['h'] / 100) * A4_HEIGHT)
410 # El constructor usa 'opciones' para Checklist, aseguramos que existan
411 if f_copy['tipo'] == 'Checklist' and 'opciones' not in f_copy:
412 f_copy['opciones'] = "SI, NO, N/A"
413 compatible_fields.append(f_copy)
414
415 save_plantilla(state.template_name, state.template_type, compatible_fields, user.get('nombre', 'Admin'))
416 ui.notify('PLANTILLA PUBLICADA EXITOSAMENTE EN GOBERNANZA', type='positive', icon='verified', position='top')
417
418 # Redirigir al constructor para ver la arquitectura terminada
419 ui.navigate.to('/sst/constructor')
420
421 