Files
IPSExtractor/gui/view.py
T

1239 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Vista principal de la aplicación GUI con `customtkinter`.
Expone métodos para enlazar callbacks, leer entradas y escribir salida en un
área de texto, además de habilitar/deshabilitar controles durante el proceso.
"""
import os # Necesario para basename en update_res_table
import customtkinter as ctk
# matplotlib es opcional
try:
import matplotlib
matplotlib.use("Agg") # backend no interactivo para render en canvas
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
HAS_MPL = True
except Exception:
HAS_MPL = False
class InvoiceAppView(ctk.CTk):
"""Ventana principal con pestañas de Procesar y Configuración."""
def __init__(self):
"""Construye la ventana principal y sus pestañas/controles."""
super().__init__()
self.title("IPSExtractor - Procesador de Documentos")
self.geometry("1100x800")
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
# Fuentes personalizadas
self.font_title = ctk.CTkFont(family="Roboto", size=22, weight="bold")
self.font_section = ctk.CTkFont(family="Roboto", size=16, weight="bold")
self.font_ui = ctk.CTkFont(family="Roboto", size=13)
self.font_mono = ctk.CTkFont(family="Consolas", size=12)
# Layout principal
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
# TabView principal con estilo
self.tabs = ctk.CTkTabview(self, width=1000, height=700)
self.tabs.grid(row=0, column=0, sticky="nsew", padx=20, pady=20)
self.tab_main = self.tabs.add("Procesar Documentos")
self.tab_preclass = self.tabs.add("Preclasificación")
self.tab_results = self.tabs.add("Resultados")
self.tab_settings = self.tabs.add("Configuración")
self.tab_usage = self.tabs.add("Dashboard & Métricas")
# =========================================================================
# --- TAB: PROCESAR DOCUMENTOS ---
# =========================================================================
self.tab_main.grid_columnconfigure(0, weight=1)
self.tab_main.grid_rowconfigure(0, weight=1)
# Contenedor Scrollable Principal (Todo el contenido aquí)
self.scroll_container = ctk.CTkScrollableFrame(self.tab_main, fg_color="transparent")
self.scroll_container.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
self.scroll_container.grid_columnconfigure(0, weight=1)
# --- Sección Rutas ---
self.frame_paths = ctk.CTkFrame(self.scroll_container)
self.frame_paths.grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 2))
self.frame_paths.grid_columnconfigure(1, weight=1)
# Título sección
lbl_paths = ctk.CTkLabel(self.frame_paths, text="Rutas y Archivos", font=self.font_section)
lbl_paths.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(5, 2))
# Carpeta Raíz
self.root_label = ctk.CTkLabel(self.frame_paths, text="Carpeta Raíz:", font=self.font_ui)
self.root_label.grid(row=1, column=0, padx=15, pady=2, sticky="w")
self.root_entry = ctk.CTkEntry(self.frame_paths, placeholder_text="Selecciona la carpeta con los documentos...", font=self.font_ui)
self.root_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
self.root_button = ctk.CTkButton(self.frame_paths, text="Explorar...", font=self.font_ui, width=100)
self.root_button.grid(row=1, column=2, padx=15, pady=2)
# CSV Salida
self.csv_label = ctk.CTkLabel(self.frame_paths, text="Archivo CSV:", font=self.font_ui)
self.csv_label.grid(row=2, column=0, padx=15, pady=2, sticky="w")
self.csv_entry = ctk.CTkEntry(self.frame_paths, placeholder_text="Ruta para guardar el reporte CSV...", font=self.font_ui)
self.csv_entry.grid(row=2, column=1, padx=5, pady=2, sticky="ew")
self.csv_button = ctk.CTkButton(self.frame_paths, text="Guardar en...", font=self.font_ui, width=100, fg_color="transparent", border_width=1)
self.csv_button.grid(row=2, column=2, padx=15, pady=2)
# Selector de Modo (Radicados vs General)
self.mode_var = ctk.StringVar(value="radicados")
lbl_mode = ctk.CTkLabel(self.frame_paths, text="Estructura:", font=self.font_ui)
lbl_mode.grid(row=3, column=0, padx=15, pady=2, sticky="w")
self.frame_radios_main = ctk.CTkFrame(self.frame_paths, fg_color="transparent")
self.frame_radios_main.grid(row=3, column=1, columnspan=2, sticky="w", padx=5, pady=2)
self.radio_radicados_main = ctk.CTkRadioButton(
self.frame_radios_main,
text="Por Radicados (Subcarpetas)",
variable=self.mode_var,
value="radicados",
font=self.font_ui,
command=self._update_ui_for_mode
)
self.radio_radicados_main.pack(side="left", padx=(0, 20))
self.radio_general_main = ctk.CTkRadioButton(
self.frame_radios_main,
text="Carpeta General (Archivos Planos)",
variable=self.mode_var,
value="general",
font=self.font_ui,
command=self._update_ui_for_mode
)
self.radio_general_main.pack(side="left")
# --- Sección Configuración ---
self.frame_config = ctk.CTkFrame(self.scroll_container)
self.frame_config.grid(row=1, column=0, sticky="ew", padx=10, pady=2)
self.frame_config.grid_columnconfigure(0, weight=1)
# Keywords oculto (legacy)
self.kw_label = ctk.CTkLabel(self.frame_config, text="Keywords:")
self.kw_entry = ctk.CTkEntry(self.frame_config)
self.kw_entry.insert(0, "Factura_O_Documento_Equivalente,factura")
# Tipos de Documento
self.doc_types_container = ctk.CTkFrame(self.frame_config, fg_color="transparent")
self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2))
self.doc_types_container.grid_columnconfigure(0, weight=1)
self.doc_types_header = ctk.CTkFrame(self.doc_types_container, fg_color="transparent")
self.doc_types_header.grid(row=0, column=0, sticky="ew", pady=(0, 2))
self.doc_types_header.grid_columnconfigure(0, weight=1)
self.doc_types_label = ctk.CTkLabel(self.doc_types_header, text="Definición de Tipos de Documento", font=self.font_section)
self.doc_types_label.grid(row=0, column=0, sticky="w")
self.add_doc_type_btn = ctk.CTkButton(self.doc_types_header, text="+ Añadir Tipo", font=self.font_ui, width=120, height=28)
self.add_doc_type_btn.grid(row=0, column=1, sticky="e")
# Lista de Tipos: Usamos CTkFrame normal para que crezca y active el scroll del contenedor principal
self.doc_types_list = ctk.CTkFrame(self.doc_types_container, fg_color="transparent")
self.doc_types_list.grid(row=1, column=0, sticky="ew", pady=(2, 0))
self.doc_types_list.grid_columnconfigure(0, weight=1)
self._doc_type_rows = []
def _on_add_row():
self._add_doc_type_row()
self.add_doc_type_btn.configure(command=_on_add_row)
# Fila por defecto
self._add_doc_type_row(name="Factura", keyword="Factura_O_Documento_Equivalente")
# Configuración Avanzada (TIFF Range)
self.frame_advanced = ctk.CTkFrame(self.frame_config, fg_color="transparent")
self.frame_advanced.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 2))
self.tiff_start_label = ctk.CTkLabel(self.frame_advanced, text="Pág. Inicio TIFF:", font=self.font_ui)
self.tiff_start_entry = ctk.CTkEntry(self.frame_advanced, width=60)
self.tiff_start_entry.insert(0, "0")
self.tiff_end_label = ctk.CTkLabel(self.frame_advanced, text="Pág. Fin TIFF:", font=self.font_ui)
self.tiff_end_entry = ctk.CTkEntry(self.frame_advanced, width=60)
self.tiff_end_entry.insert(0, "0")
# Ocultar inicialmente
self.tiff_start_label.grid_forget()
self.tiff_start_entry.grid_forget()
self.tiff_end_label.grid_forget()
self.tiff_end_entry.grid_forget()
# Campos legacy ocultos
self.fields_entry = ctk.CTkEntry(self.tab_main)
self.fields_label = ctk.CTkLabel(self.tab_main, text="")
# --- Botón de Acción Principal ---
self.process_button = ctk.CTkButton(
self.scroll_container,
text="INICIAR PROCESAMIENTO",
font=ctk.CTkFont(size=16, weight="bold"),
height=35,
fg_color=("#106BA3", "#106BA3"),
hover_color=("#0D5582", "#0D5582")
)
self.process_button.grid(row=2, column=0, padx=15, pady=(10, 15), sticky="ew")
# --- Área de Logs / Salida (Integrada en Scroll) ---
self.log_frame = ctk.CTkFrame(self.scroll_container)
self.log_frame.grid(row=3, column=0, sticky="ew", padx=10, pady=(0, 15))
self.log_frame.grid_columnconfigure(0, weight=1)
lbl_logs = ctk.CTkLabel(self.log_frame, text="Registro de Actividad:", font=self.font_ui, text_color=("gray50", "gray70"))
lbl_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0))
self.output_text = ctk.CTkTextbox(self.log_frame, font=self.font_mono, activate_scrollbars=True, height=150)
self.output_text.grid(row=1, column=0, padx=5, pady=5, sticky="ew")
# =========================================================================
# --- TAB: RESULTADOS ---
# =========================================================================
self.tab_results.grid_columnconfigure(0, weight=1)
self.tab_results.grid_rowconfigure(0, weight=1)
self.tabs_res = ctk.CTkTabview(self.tab_results)
self.tabs_res.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
self.tab_res_extractor = self.tabs_res.add("Extractor (Próximamente)")
self.tab_res_preclass = self.tabs_res.add("Preclasificación")
# --- Sub-Tab: Extractor ---
lbl_const = ctk.CTkLabel(self.tab_res_extractor, text="En construcción...", font=ctk.CTkFont(size=20))
lbl_const.place(relx=0.5, rely=0.5, anchor="center")
# --- Sub-Tab: Preclasificación ---
self.tab_res_preclass.grid_columnconfigure(0, weight=1)
self.tab_res_preclass.grid_rowconfigure(1, weight=1)
# 1. Filtros
self.frame_res_filters = ctk.CTkFrame(self.tab_res_preclass)
self.frame_res_filters.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
ctk.CTkLabel(self.frame_res_filters, text="Run ID:").grid(row=0, column=0, padx=5, pady=5)
self.combo_res_run = ctk.CTkComboBox(self.frame_res_filters, values=["Todos"], width=150)
self.combo_res_run.grid(row=0, column=1, padx=5, pady=5)
ctk.CTkLabel(self.frame_res_filters, text="Estado:").grid(row=0, column=2, padx=5, pady=5)
self.combo_res_status = ctk.CTkComboBox(self.frame_res_filters, values=["Todos", "MATCH", "NO_MATCH", "ERROR", "NO_FILES"], width=120)
self.combo_res_status.grid(row=0, column=3, padx=5, pady=5)
self.entry_res_search = ctk.CTkEntry(self.frame_res_filters, placeholder_text="Buscar radicado/archivo...", width=200)
self.entry_res_search.grid(row=0, column=4, padx=5, pady=5)
self.btn_res_search = ctk.CTkButton(self.frame_res_filters, text="Buscar", width=100)
self.btn_res_search.grid(row=0, column=5, padx=10, pady=5)
# 2. Tabla (Treeview)
self.frame_res_table = ctk.CTkFrame(self.tab_res_preclass, fg_color="transparent")
self.frame_res_table.grid(row=1, column=0, sticky="nsew", padx=10, pady=5)
self.frame_res_table.grid_columnconfigure(0, weight=1)
self.frame_res_table.grid_rowconfigure(0, weight=3) # Peso para la tabla principal
self.frame_res_table.grid_rowconfigure(2, weight=2) # Peso para la tabla detalle
import tkinter.ttk as ttk
# Estilo básico para treeview
style = ttk.Style()
style.theme_use("clam")
style.configure("Treeview", rowheight=25)
# --- Tabla Principal ---
cols = ("ID", "Run", "Fecha", "Radicado", "Estado", "Archivo")
self.tree_results = ttk.Treeview(self.frame_res_table, columns=cols, show="headings", selectmode="browse")
for c in cols:
self.tree_results.heading(c, text=c)
self.tree_results.column(c, width=100)
self.tree_results.column("Archivo", width=300)
self.tree_results.column("ID", width=50)
self.tree_results.column("Run", width=50)
# Scrollbars principal
sb_y = ttk.Scrollbar(self.frame_res_table, orient="vertical", command=self.tree_results.yview)
sb_x = ttk.Scrollbar(self.frame_res_table, orient="horizontal", command=self.tree_results.xview)
self.tree_results.configure(yscrollcommand=sb_y.set, xscrollcommand=sb_x.set)
self.tree_results.grid(row=0, column=0, sticky="nsew")
sb_y.grid(row=0, column=1, sticky="ns")
sb_x.grid(row=1, column=0, sticky="ew")
# Separador / Título Detalle
lbl_detail = ctk.CTkLabel(self.frame_res_table, text="Detalle de Matches (Seleccionar fila arriba)", font=ctk.CTkFont(size=12, weight="bold"))
lbl_detail.grid(row=1, column=0, sticky="w", pady=(10, 5))
# --- Tabla Detalle ---
cols_detail = ("Key", "Valor Encontrado", "Existe", "Valor Esperado")
self.tree_matches = ttk.Treeview(self.frame_res_table, columns=cols_detail, show="headings", selectmode="browse", height=5)
for c in cols_detail:
self.tree_matches.heading(c, text=c)
self.tree_matches.column(c, width=150)
sb_detail_y = ttk.Scrollbar(self.frame_res_table, orient="vertical", command=self.tree_matches.yview)
self.tree_matches.configure(yscrollcommand=sb_detail_y.set)
self.tree_matches.grid(row=2, column=0, sticky="nsew")
sb_detail_y.grid(row=2, column=1, sticky="ns")
# 3. Acciones
self.frame_res_actions = ctk.CTkFrame(self.tab_res_preclass)
self.frame_res_actions.grid(row=2, column=0, sticky="ew", padx=10, pady=10)
self.btn_res_export = ctk.CTkButton(self.frame_res_actions, text="Exportar Vista a Excel", fg_color="green")
self.btn_res_export.pack(side="right", padx=10, pady=5)
self.btn_res_organize = ctk.CTkButton(self.frame_res_actions, text="Organizar Carpetas (Run)", fg_color="#D35400", hover_color="#A04000")
self.btn_res_organize.pack(side="right", padx=10, pady=5)
self.btn_res_refresh = ctk.CTkButton(self.frame_res_actions, text="Refrescar Runs")
self.btn_res_refresh.pack(side="left", padx=10, pady=5)
# =========================================================================
# --- TAB: CONFIGURACIÓN ---
# =========================================================================
self.tab_settings.grid_columnconfigure(0, weight=1)
self.tab_settings.grid_rowconfigure(0, weight=1)
self.settings_container = ctk.CTkFrame(self.tab_settings, fg_color="transparent")
self.settings_container.grid(row=0, column=0, sticky="nsew", padx=20, pady=20)
self.settings_container.grid_columnconfigure(0, weight=1)
# --- Sección API / Modelo ---
self.frame_api = ctk.CTkFrame(self.settings_container)
self.frame_api.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
self.frame_api.grid_columnconfigure(1, weight=1)
lbl_api_title = ctk.CTkLabel(self.frame_api, text="Configuración del Modelo LLM", font=self.font_section)
lbl_api_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10)
self.lbl_model = ctk.CTkLabel(self.frame_api, text="Modelo (Ollama):", font=self.font_ui)
self.lbl_model.grid(row=1, column=0, padx=15, pady=10, sticky="w")
self.combo_model = ctk.CTkComboBox(
self.frame_api,
values=["5.1", "4.1", "4.1 mini", "4.1 nano"],
width=250,
font=self.font_ui
)
self.combo_model.grid(row=1, column=1, padx=15, pady=10, sticky="w")
self.combo_model.set("4.1")
# Texto de ayuda para modelos
lbl_help = ctk.CTkLabel(
self.frame_api,
text="ℹ 4.1 mini: Muy rápido (recomendado)\nℹ 4.1: Estándar (balanceado)\nℹ 5.1: Lento pero mayor razonamiento",
font=ctk.CTkFont(size=12, slant="italic"),
text_color="gray60",
justify="left"
)
lbl_help.grid(row=2, column=1, padx=15, pady=(0, 10), sticky="w")
# --- Sección Procesamiento ---
self.frame_proc = ctk.CTkFrame(self.settings_container)
self.frame_proc.grid(row=1, column=0, sticky="ew", padx=5, pady=5)
self.frame_proc.grid_columnconfigure(1, weight=1)
lbl_proc_title = ctk.CTkLabel(self.frame_proc, text="Procesamiento y Rendimiento", font=self.font_section)
lbl_proc_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=15, pady=10)
lbl_conc = ctk.CTkLabel(self.frame_proc, text="Límite de Concurrencia:", font=self.font_ui)
lbl_conc.grid(row=1, column=0, padx=15, pady=10, sticky="w")
self.concurrency_var = ctk.StringVar(value="20")
self.concurrency_entry = ctk.CTkEntry(self.frame_proc, textvariable=self.concurrency_var, width=100, font=self.font_ui)
self.concurrency_entry.grid(row=1, column=1, padx=15, pady=10, sticky="w")
lbl_conc_warn = ctk.CTkLabel(
self.frame_proc,
text="⚠️ Advertencia: Entre mayor el número, mayor la probabilidad de Rate Limits (429).",
font=ctk.CTkFont(size=12, slant="italic"),
text_color="orange"
)
lbl_conc_warn.grid(row=2, column=0, columnspan=2, padx=15, pady=(0, 10), sticky="w")
# Botón Guardar (Fuera de los frames específicos, al final)
self.btn_save_settings = ctk.CTkButton(
self.settings_container,
text="Aplicar Configuración",
font=self.font_ui,
height=35
)
self.btn_save_settings.grid(row=2, column=0, padx=15, pady=15, sticky="ew")
# =========================================================================
# --- TAB: PRECLASIFICACIÓN ---
# =========================================================================
self.tab_preclass.grid_columnconfigure(0, weight=1)
self.tab_preclass.grid_rowconfigure(0, weight=1)
self.preclass_container = ctk.CTkScrollableFrame(self.tab_preclass, fg_color="transparent")
self.preclass_container.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
self.preclass_container.grid_columnconfigure(0, weight=1)
# En un ScrollableFrame, grid_rowconfigure con weight=1 hace que esa fila intente ocupar
# el espacio vertical disponible en el viewport.
# Row 3 es patterns, Row 5 es logs.
self.preclass_container.grid_rowconfigure(3, weight=1)
self.preclass_container.grid_rowconfigure(5, weight=1)
# --- Sección: Carpeta Raíz ---
self.preclass_frame_root = ctk.CTkFrame(self.preclass_container)
self.preclass_frame_root.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 10))
self.preclass_frame_root.grid_columnconfigure(1, weight=1)
lbl_pre_root_title = ctk.CTkLabel(self.preclass_frame_root, text="Carpeta Raíz (Preclasificación)", font=self.font_section)
lbl_pre_root_title.grid(row=0, column=0, columnspan=3, sticky="w", padx=15, pady=(10, 5))
self.preclass_root_label = ctk.CTkLabel(self.preclass_frame_root, text="Carpeta Raíz:", font=self.font_ui)
self.preclass_root_label.grid(row=1, column=0, padx=15, pady=(0, 10), sticky="w")
self.preclass_root_entry = ctk.CTkEntry(
self.preclass_frame_root,
placeholder_text="Selecciona la carpeta raíz para preclasificar...",
font=self.font_ui
)
self.preclass_root_entry.grid(row=1, column=1, padx=5, pady=(0, 10), sticky="ew")
self.preclass_root_button = ctk.CTkButton(self.preclass_frame_root, text="Explorar...", font=self.font_ui, width=100)
self.preclass_root_button.grid(row=1, column=2, padx=15, pady=(0, 10))
# Selector de Modo (Radicados vs General)
self.preclass_mode_var = ctk.StringVar(value="radicados")
lbl_mode = ctk.CTkLabel(self.preclass_frame_root, text="Estructura:", font=self.font_ui)
lbl_mode.grid(row=2, column=0, padx=15, pady=(0, 10), sticky="w")
self.frame_radios = ctk.CTkFrame(self.preclass_frame_root, fg_color="transparent")
self.frame_radios.grid(row=2, column=1, columnspan=2, sticky="w", padx=5, pady=(0, 10))
self.radio_radicados = ctk.CTkRadioButton(
self.frame_radios,
text="Por Radicados (Subcarpetas)",
variable=self.preclass_mode_var,
value="radicados",
font=self.font_ui
)
self.radio_radicados.pack(side="left", padx=(0, 20))
self.radio_general = ctk.CTkRadioButton(
self.frame_radios,
text="Carpeta General (Archivos Planos)",
variable=self.preclass_mode_var,
value="general",
font=self.font_ui
)
self.radio_general.pack(side="left")
# --- Sección: Opciones de Rango (Preclasificación) ---
self.preclass_frame_options = ctk.CTkFrame(self.preclass_container)
self.preclass_frame_options.grid(row=1, column=0, sticky="ew", padx=5, pady=(0, 10))
self.preclass_frame_options.grid_columnconfigure(1, weight=1)
self.preclass_range_var = ctk.BooleanVar(value=False)
self.preclass_range_cb = ctk.CTkCheckBox(
self.preclass_frame_options,
text="Habilitar rangos de página",
variable=self.preclass_range_var,
font=self.font_ui,
command=self._on_toggle_preclass_range
)
self.preclass_range_cb.grid(row=0, column=0, padx=15, pady=10, sticky="w")
self.preclass_range_inputs_frame = ctk.CTkFrame(self.preclass_frame_options, fg_color="transparent")
# Se mostrará solo si se habilita
lbl_desde = ctk.CTkLabel(self.preclass_range_inputs_frame, text="Desde:", font=self.font_ui)
lbl_desde.grid(row=0, column=0, padx=(10, 5), pady=5)
self.preclass_range_start_entry = ctk.CTkEntry(self.preclass_range_inputs_frame, width=60, font=self.font_ui)
self.preclass_range_start_entry.insert(0, "1")
self.preclass_range_start_entry.grid(row=0, column=1, padx=5, pady=5)
lbl_hasta = ctk.CTkLabel(self.preclass_range_inputs_frame, text="Hasta:", font=self.font_ui)
lbl_hasta.grid(row=0, column=2, padx=(10, 5), pady=5)
self.preclass_range_end_entry = ctk.CTkEntry(self.preclass_range_inputs_frame, width=60, font=self.font_ui)
self.preclass_range_end_entry.insert(0, "1")
self.preclass_range_end_entry.grid(row=0, column=3, padx=5, pady=5)
# --- Sección: Opciones de Archivo Objetivo (Preclasificación) ---
self.preclass_frame_target = ctk.CTkFrame(self.preclass_container)
self.preclass_frame_target.grid(row=2, column=0, sticky="ew", padx=5, pady=(0, 10))
self.preclass_frame_target.grid_columnconfigure(0, weight=1)
self.preclass_target_var = ctk.BooleanVar(value=False)
self.preclass_target_cb = ctk.CTkCheckBox(
self.preclass_frame_target,
text="Habilitar archivo objetivo",
variable=self.preclass_target_var,
font=self.font_ui,
command=self._on_toggle_preclass_target
)
self.preclass_target_cb.grid(row=0, column=0, padx=15, pady=10, sticky="w")
self.preclass_target_inputs_frame = ctk.CTkFrame(self.preclass_frame_target, fg_color="transparent")
# Se mostrará solo si se habilita (en row 1 para que aparezca ABAJO del checkbox)
lbl_target_def = ctk.CTkLabel(self.preclass_target_inputs_frame, text="Def. Tipo Doc:", font=self.font_ui)
lbl_target_def.grid(row=0, column=0, padx=(15, 5), pady=5, sticky="w")
self.preclass_target_def_entry = ctk.CTkEntry(self.preclass_target_inputs_frame, width=250, font=self.font_ui, placeholder_text="Ej: Factura de Venta")
self.preclass_target_def_entry.grid(row=0, column=1, padx=5, pady=5, sticky="w")
lbl_target_pattern = ctk.CTkLabel(self.preclass_target_inputs_frame, text="Patrón:", font=self.font_ui)
lbl_target_pattern.grid(row=0, column=2, padx=(20, 5), pady=5, sticky="w")
self.preclass_target_pattern_entry = ctk.CTkEntry(self.preclass_target_inputs_frame, width=200, font=self.font_ui, placeholder_text="Ej: *_FAC_*.pdf")
self.preclass_target_pattern_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w")
# --- Sección: Patrones ---
self.preclass_frame_patterns = ctk.CTkFrame(self.preclass_container)
self.preclass_frame_patterns.grid(row=3, column=0, sticky="nsew", padx=5, pady=5)
self.preclass_frame_patterns.grid_columnconfigure(0, weight=1)
self.preclass_patterns_header = ctk.CTkFrame(self.preclass_frame_patterns, fg_color="transparent")
self.preclass_patterns_header.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 2))
self.preclass_patterns_header.grid_columnconfigure(0, weight=1)
self.preclass_patterns_label = ctk.CTkLabel(self.preclass_patterns_header, text="Patrones", font=self.font_section)
self.preclass_patterns_label.grid(row=0, column=0, sticky="w")
self.add_preclass_pattern_btn = ctk.CTkButton(self.preclass_patterns_header, text="+ Añadir Patrón", font=self.font_ui, width=140, height=28)
self.add_preclass_pattern_btn.grid(row=0, column=1, sticky="e")
self.preclass_patterns_hint = ctk.CTkLabel(
self.preclass_frame_patterns,
text="Agrega patrones de texto. Cada patrón puede ser solo 'key' o 'key + value'.",
font=ctk.CTkFont(size=12, slant="italic"),
text_color="gray60",
justify="left"
)
self.preclass_patterns_hint.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 5))
self.preclass_patterns_list = ctk.CTkFrame(self.preclass_frame_patterns, fg_color="transparent")
self.preclass_patterns_list.grid(row=2, column=0, sticky="ew", padx=10, pady=(2, 10))
self.preclass_patterns_list.grid_columnconfigure(0, weight=1)
self._preclass_pattern_rows = []
def _on_add_preclass_row():
self._add_preclass_pattern_row()
self.add_preclass_pattern_btn.configure(command=_on_add_preclass_row)
# Fila por defecto (vacía)
self._add_preclass_pattern_row()
# --- Sección: Botón de Ejecución y Logs ---
self.preclass_action_frame = ctk.CTkFrame(self.preclass_container, fg_color="transparent")
self.preclass_action_frame.grid(row=4, column=0, sticky="ew", padx=5, pady=10)
self.preclass_action_frame.grid_columnconfigure(0, weight=1)
self.preclass_run_btn = ctk.CTkButton(
self.preclass_action_frame,
text="EJECUTAR PRECLASIFICACIÓN",
font=ctk.CTkFont(size=16, weight="bold"),
height=40,
fg_color=("#2E86C1", "#2E86C1"),
hover_color=("#21618C", "#21618C")
)
self.preclass_run_btn.pack(side="left", fill="x", expand=True, padx=10)
self.preclass_export_btn = ctk.CTkButton(
self.preclass_action_frame,
text="Exportar Excel",
font=self.font_ui,
height=40,
state="disabled",
fg_color=("#27AE60", "#27AE60"),
hover_color=("#1E8449", "#1E8449")
)
self.preclass_export_btn.pack(side="left", padx=(0, 10))
self.preclass_organize_btn = ctk.CTkButton(
self.preclass_action_frame,
text="Organizar Carpetas",
font=self.font_ui,
height=40,
state="disabled",
fg_color=("#D35400", "#D35400"),
hover_color=("#A04000", "#A04000")
)
self.preclass_organize_btn.pack(side="left", padx=(0, 10))
self.preclass_log_frame = ctk.CTkFrame(self.preclass_container)
self.preclass_log_frame.grid(row=5, column=0, sticky="nsew", padx=5, pady=(0, 10))
self.preclass_log_frame.grid_columnconfigure(0, weight=1)
self.preclass_log_frame.grid_rowconfigure(1, weight=1)
# Ajustar pesos del contenedor principal para que el log expanda
self.preclass_container.grid_rowconfigure(5, weight=1)
# Quitar peso a la fila de patrones si queremos que el log ocupe más espacio,
# o compartir. Actualmente row 3 (patrones) tiene weight.
# Vamos a dejar patterns con weight 1 y logs con weight 1 para compartir espacio.
self.preclass_container.grid_rowconfigure(3, weight=1)
lbl_pre_logs = ctk.CTkLabel(self.preclass_log_frame, text="Resultados:", font=self.font_ui, text_color=("gray50", "gray70"))
lbl_pre_logs.grid(row=0, column=0, sticky="w", padx=10, pady=(5, 0))
self.preclass_output = ctk.CTkTextbox(self.preclass_log_frame, font=self.font_mono, activate_scrollbars=True)
self.preclass_output.grid(row=1, column=0, padx=5, pady=5, sticky="nsew")
# =========================================================================
# --- TAB: DASHBOARD & MÉTRICAS ---
# =========================================================================
self.tab_usage.grid_columnconfigure(0, weight=1)
self.tab_usage.grid_rowconfigure(1, weight=1)
self.tab_usage.grid_rowconfigure(3, weight=1)
# Panel de KPIs (Top)
self.kpi_frame = ctk.CTkFrame(self.tab_usage)
self.kpi_frame.grid(row=0, column=0, sticky="ew", padx=15, pady=15)
self.kpi_frame.grid_columnconfigure((0, 1, 2), weight=1)
# Helper para crear cards KPI
def create_kpi_card(parent, title, row, col):
card = ctk.CTkFrame(parent)
card.grid(row=row, column=col, sticky="nsew", padx=5, pady=5)
lbl_title = ctk.CTkLabel(card, text=title, font=ctk.CTkFont(size=14, weight="bold"))
lbl_title.pack(pady=(10, 5), padx=10, anchor="w")
lbl_content = ctk.CTkLabel(card, text="...", font=self.font_mono, justify="left")
lbl_content.pack(pady=(0, 10), padx=10, anchor="w")
return lbl_content
self.kpi_overall = create_kpi_card(self.kpi_frame, "Total General", 0, 0)
self.kpi_extract = create_kpi_card(self.kpi_frame, "Fase: Extracción", 0, 1)
self.kpi_normalize = create_kpi_card(self.kpi_frame, "Fase: Normalización", 0, 2)
self.kpi_overall.configure(text="input: 0\ncached: 0\noutput: 0\nUSD: $0.00")
self.kpi_extract.configure(text="input: 0\ncached: 0\noutput: 0\nUSD: $0.00")
self.kpi_normalize.configure(text="input: 0\ncached: 0\noutput: 0\nUSD: $0.00")
self.usage_totals = ctk.CTkLabel(self.tab_usage, text="") # Legacy label hidden
# Panel Central (Gráficos)
self.charts_frame = ctk.CTkFrame(self.tab_usage)
self.charts_frame.grid(row=1, column=0, sticky="nsew", padx=15, pady=5)
self.charts_frame.grid_columnconfigure((0, 1), weight=1)
self.charts_frame.grid_rowconfigure(0, weight=1)
# Gráfico Línea
self._cost_line_points_x = []
self._cost_line_points_y = []
if HAS_MPL:
self.fig_line = Figure(figsize=(4, 3), dpi=100)
self.fig_line.patch.set_facecolor('#f0f0f0') # Match light theme approx or transparent
self.ax_line = self.fig_line.add_subplot(111)
self.ax_line.set_title("Costo Acumulado (USD)", fontsize=10)
self.ax_line.tick_params(labelsize=8)
self._line_plot, = self.ax_line.plot([], [], marker="o", color="#1f77b4")
self.canvas_line = FigureCanvasTkAgg(self.fig_line, master=self.charts_frame)
self.canvas_line.get_tk_widget().grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
else:
ctk.CTkLabel(self.charts_frame, text="Gráficos no disponibles").grid(row=0, column=0)
# Gráfico Barras
if HAS_MPL:
self.fig_bars = Figure(figsize=(4, 3), dpi=100)
self.fig_bars.patch.set_facecolor('#f0f0f0')
self.ax_bars = self.fig_bars.add_subplot(111)
self.ax_bars.set_title("Consumo de Tokens", fontsize=10)
self.ax_bars.tick_params(labelsize=8)
self.canvas_bars = FigureCanvasTkAgg(self.fig_bars, master=self.charts_frame)
self.canvas_bars.get_tk_widget().grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
# Panel Comparativo de Modelos
self.comparison_frame = ctk.CTkFrame(self.tab_usage)
self.comparison_frame.grid(row=2, column=0, sticky="ew", padx=15, pady=5)
self.comparison_frame.grid_columnconfigure(0, weight=1)
lbl_comp_title = ctk.CTkLabel(self.comparison_frame, text="Tarifas de Modelos (USD por 1M tokens)", font=ctk.CTkFont(size=14, weight="bold"))
lbl_comp_title.grid(row=0, column=0, sticky="w", padx=10, pady=5)
# Tabla de comparación
from tkinter import ttk
self.tree_comparison = ttk.Treeview(
self.comparison_frame,
columns=("model", "input", "cached", "output"),
show="headings",
height=4
)
self.tree_comparison.heading("model", text="Modelo")
self.tree_comparison.heading("input", text="Input / 1M")
self.tree_comparison.heading("cached", text="Cached / 1M")
self.tree_comparison.heading("output", text="Output / 1M")
self.tree_comparison.column("model", width=150, anchor="w")
self.tree_comparison.column("input", width=100, anchor="e")
self.tree_comparison.column("cached", width=100, anchor="e")
self.tree_comparison.column("output", width=100, anchor="e")
self.tree_comparison.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10))
# Panel Inferior (Detalles Texto)
self.usage_text = ctk.CTkTextbox(self.tab_usage, height=150, font=self.font_mono)
self.usage_text.grid(row=3, column=0, sticky="ew", padx=15, pady=15)
# ---------------------------------------------------------------------------
# MÉTODOS PÚBLICOS (API COMPATIBLE)
# ---------------------------------------------------------------------------
def set_on_select_root(self, callback):
self.root_button.configure(command=callback)
def set_on_select_preclass_root(self, callback):
self.preclass_root_button.configure(command=callback)
def set_on_process(self, callback):
self.process_button.configure(command=callback)
def set_on_select_csv(self, callback):
self.csv_button.configure(command=callback)
def set_on_save_settings(self, callback):
self.btn_save_settings.configure(command=callback)
def set_on_run_preclassification(self, callback):
self.preclass_run_btn.configure(command=callback)
def set_on_export_excel(self, callback):
self.preclass_export_btn.configure(command=callback)
def set_on_organize_files(self, callback):
self.preclass_organize_btn.configure(command=callback)
def enable_preclass_actions(self, enabled: bool):
state = "normal" if enabled else "disabled"
self.preclass_export_btn.configure(state=state)
self.preclass_organize_btn.configure(state=state)
# --- Métodos Resultados ---
def set_on_res_search(self, callback):
self.btn_res_search.configure(command=callback)
def set_on_res_export(self, callback):
self.btn_res_export.configure(command=callback)
def set_on_res_refresh(self, callback):
self.btn_res_refresh.configure(command=callback)
def set_on_res_organize(self, callback):
self.btn_res_organize.configure(command=callback)
def set_on_res_select(self, callback):
# Bind TreeviewSelect event
self.tree_results.bind("<<TreeviewSelect>>", callback)
def update_res_matches_table(self, rows):
# Limpiar
for item in self.tree_matches.get_children():
self.tree_matches.delete(item)
# Llenar
for r in rows:
# r: key, expected_value, found_value, exists_bool
exists_str = "✅" if r.get("exists_bool") else "❌"
vals = (r.get("key"), r.get("found_value"), exists_str, r.get("expected_value") or "-")
self.tree_matches.insert("", "end", values=vals)
def update_res_runs_combo(self, runs: list):
vals = ["Todos"] + [str(r) for r in runs]
self.combo_res_run.configure(values=vals)
self.combo_res_run.set("Todos")
def set_res_run(self, run_id: int | str):
try:
self.combo_res_run.set(str(run_id))
except Exception:
pass
def get_res_filters(self):
return {
"run_id": self.combo_res_run.get(),
"status": self.combo_res_status.get(),
"text": self.entry_res_search.get()
}
def update_res_table(self, rows):
# Limpiar
for item in self.tree_results.get_children():
self.tree_results.delete(item)
# Llenar
for r in rows:
# r dict: id, run_id, radicado, file_path, status, timestamp
vals = (r.get("id"), r.get("run_id"), r.get("timestamp"), r.get("radicado"), r.get("status"), os.path.basename(r.get("file_path") or ""))
self.tree_results.insert("", "end", values=vals)
def append_preclass_output(self, text: str) -> None:
self.preclass_output.insert("end", text + "\n")
self.preclass_output.see("end")
def append_preclass_output_safe(self, text: str) -> None:
self.after(0, lambda: self.append_preclass_output(text))
def get_selected_model(self) -> str:
return self.combo_model.get()
def set_selected_model(self, model: str) -> None:
self.combo_model.set(model)
def get_concurrency_limit(self) -> int:
try:
return int(self.concurrency_var.get())
except Exception:
return 20
def set_concurrency_limit(self, limit: int) -> None:
self.concurrency_var.set(str(limit))
def _update_ui_for_mode(self):
"""Actualiza la visibilidad de componentes según el modo seleccionado."""
mode = self.get_process_mode()
if mode == "general":
# Ocultar Tipos de Documento
self.doc_types_container.grid_forget()
# Mostrar Keywords (opcional, para filtrar)
self.kw_label.grid(row=1, column=0, padx=15, pady=2, sticky="w")
self.kw_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
# Si tiene el valor por defecto de facturas, limpiarlo para sugerir "todos"
curr = self.kw_entry.get()
if "Factura_O_Documento_Equivalente" in curr:
self.kw_entry.delete(0, "end")
else:
# Mostrar Tipos de Documento
self.doc_types_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(5, 2))
# Ocultar Keywords
self.kw_label.grid_forget()
self.kw_entry.grid_forget()
def get_root_path(self) -> str:
return self.root_entry.get().strip()
def get_process_mode(self) -> str:
"""Devuelve el modo de procesamiento: 'radicados' o 'general'."""
try:
v = (self.mode_var.get() or "").strip().lower()
return v if v in ("radicados", "general") else "radicados"
except Exception:
return "radicados"
def get_preclass_root_path(self) -> str:
return self.preclass_root_entry.get().strip()
def get_preclass_mode(self) -> str:
"""Devuelve el modo de preclasificación: 'radicados' o 'general'."""
try:
v = (self.preclass_mode_var.get() or "").strip().lower()
return v if v in ("radicados", "general") else "radicados"
except Exception:
return "radicados"
def get_preclass_range(self) -> tuple[bool, int, int]:
"""Devuelve (habilitado, desde, hasta) para preclasificación."""
enabled = self.preclass_range_var.get()
try:
start = int(self.preclass_range_start_entry.get().strip() or "1")
end = int(self.preclass_range_end_entry.get().strip() or "1")
except Exception:
start, end = 1, 1
return enabled, start, end
def get_preclass_target_info(self) -> dict:
"""Devuelve la configuración del archivo objetivo."""
return {
"enabled": self.preclass_target_var.get(),
"definition": self.preclass_target_def_entry.get().strip(),
"pattern": self.preclass_target_pattern_entry.get().strip()
}
def _on_toggle_preclass_range(self):
"""Muestra u oculta los inputs de rango según el checkbox."""
if self.preclass_range_var.get():
self.preclass_range_inputs_frame.grid(row=0, column=1, padx=10, pady=0, sticky="w")
else:
self.preclass_range_inputs_frame.grid_forget()
def _on_toggle_preclass_target(self):
"""Muestra u oculta los inputs de archivo objetivo según el checkbox."""
if self.preclass_target_var.get():
self.preclass_target_inputs_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="w")
else:
self.preclass_target_inputs_frame.grid_forget()
def get_keywords(self) -> str:
return self.kw_entry.get().strip()
def get_fields(self) -> str:
return self.fields_entry.get().strip()
def get_csv_path(self) -> str:
return self.csv_entry.get().strip()
def show_tiff_range_controls(self, show: bool) -> None:
if show:
self.tiff_start_label.grid(row=0, column=0, padx=5, pady=5)
self.tiff_start_entry.grid(row=0, column=1, padx=5, pady=5)
self.tiff_end_label.grid(row=0, column=2, padx=5, pady=5)
self.tiff_end_entry.grid(row=0, column=3, padx=5, pady=5)
else:
self.tiff_start_label.grid_forget()
self.tiff_start_entry.grid_forget()
self.tiff_end_label.grid_forget()
self.tiff_end_entry.grid_forget()
def get_tiff_start_page(self) -> int:
try:
value = self.tiff_start_entry.get().strip()
return int(value) if value else 0
except Exception:
return 0
def get_tiff_end_page(self) -> int:
try:
value = self.tiff_end_entry.get().strip()
return int(value) if value else 0
except Exception:
return 0
def append_output(self, text: str) -> None:
self.output_text.insert("end", text + "\n")
self.output_text.see("end")
def append_output_safe(self, text: str) -> None:
self.after(0, lambda: self.append_output(text))
def set_processing(self, processing: bool) -> None:
state = "disabled" if processing else "normal"
self.process_button.configure(state=state, text="PROCESANDO..." if processing else "INICIAR PROCESAMIENTO")
if processing:
self.process_button.configure(fg_color="gray")
else:
self.process_button.configure(fg_color=("#106BA3", "#106BA3"))
self.root_button.configure(state=state)
self.root_entry.configure(state=state)
self.csv_button.configure(state=state)
self.csv_entry.configure(state=state)
self.add_doc_type_btn.configure(state=state)
for row in self._doc_type_rows:
row["name_entry"].configure(state=state)
row["kw_entry"].configure(state=state)
row["remove_btn"].configure(state=state)
# --- TAB PRECLASIFICACIÓN ---
try:
self.preclass_root_button.configure(state=state)
self.preclass_root_entry.configure(state=state)
self.preclass_range_cb.configure(state=state)
self.preclass_range_start_entry.configure(state=state)
self.preclass_range_end_entry.configure(state=state)
self.preclass_target_cb.configure(state=state)
self.preclass_target_def_entry.configure(state=state)
self.preclass_target_pattern_entry.configure(state=state)
self.radio_radicados.configure(state=state)
self.radio_general.configure(state=state)
self.radio_radicados_main.configure(state=state)
self.radio_general_main.configure(state=state)
self.add_preclass_pattern_btn.configure(state=state)
self.preclass_run_btn.configure(state=state)
# No deshabilitamos los de exportación aquí para permitir exportar mientras se procesa otro (opcional),
# o mejor sí bloquear todo.
if processing:
self.preclass_export_btn.configure(state="disabled")
self.preclass_organize_btn.configure(state="disabled")
for row in self._preclass_pattern_rows:
row["key_entry"].configure(state=state)
row["mode_combo"].configure(state=state)
row["value_entry"].configure(state=state)
row["remove_btn"].configure(state=state)
except Exception:
pass
# --- TAB USO ---
def append_usage(self, text: str) -> None:
self.usage_text.insert("end", text + "\n")
self.usage_text.see("end")
def append_usage_safe(self, text: str) -> None:
self.after(0, lambda: self.append_usage(text))
def clear_usage(self) -> None:
self.usage_text.delete("1.0", "end")
try:
for item in self.tree_comparison.get_children():
self.tree_comparison.delete(item)
except Exception:
pass
# Reset KPIs
zero_txt = "input: 0\ncached: 0\noutput: 0\nUSD: $0.0000"
self.kpi_overall.configure(text=zero_txt)
self.kpi_extract.configure(text=zero_txt)
self.kpi_normalize.configure(text=zero_txt)
# Reset Charts
if HAS_MPL:
self._cost_line_points_x.clear()
self._cost_line_points_y.clear()
self._line_plot.set_data([], [])
self.ax_line.relim()
self.ax_line.autoscale_view()
self.canvas_line.draw_idle()
self.ax_bars.clear()
self.ax_bars.set_title("Consumo de Tokens")
self.canvas_bars.draw_idle()
def set_usage_totals(self, **kwargs):
pass # Legacy, handled by KPIs now
def update_kpis(self, overall: dict, extract_phase: dict, normalize_phase: dict) -> None:
def fmt(d):
return (
f"input: {d.get('input',0)}\n"
f"cached: {d.get('cached',0)}\n"
f"output: {d.get('output',0)}\n"
f"USD: ${d.get('usd',0.0):.4f}"
)
self.kpi_overall.configure(text=fmt(overall))
self.kpi_extract.configure(text=fmt(extract_phase))
self.kpi_normalize.configure(text=fmt(normalize_phase))
def update_cost_line(self, doc_index: int, total_cost_usd: float) -> None:
if not HAS_MPL: return
try:
self._cost_line_points_x.append(doc_index)
self._cost_line_points_y.append(total_cost_usd)
self._line_plot.set_data(self._cost_line_points_x, self._cost_line_points_y)
self.ax_line.relim()
self.ax_line.autoscale_view()
self.canvas_line.draw_idle()
except Exception:
pass
def update_token_bars(self, input_extract: int, cached_extract: int, output_extract: int, input_norm: int, cached_norm: int, output_norm: int) -> None:
if not HAS_MPL: return
try:
self.ax_bars.clear()
self.ax_bars.set_title("Tokens por Fase")
labels = ["Input", "Cached", "Output"]
extract_vals = [input_extract, cached_extract, output_extract]
norm_vals = [input_norm, cached_norm, output_norm]
import numpy as np
x = np.arange(len(labels))
width = 0.35
self.ax_bars.bar(x - width/2, extract_vals, width, label="Extracción", color="#1f77b4")
self.ax_bars.bar(x + width/2, norm_vals, width, label="Normalización", color="#ff7f0e")
self.ax_bars.set_xticks(x, labels)
self.ax_bars.legend()
self.canvas_bars.draw_idle()
except Exception:
pass
def update_comparison_table(self, comparisons: list[dict]) -> None:
"""Actualiza la tabla de tarifas de modelos con los datos proporcionados."""
try:
# Limpiar tabla
for item in self.tree_comparison.get_children():
self.tree_comparison.delete(item)
# Insertar filas
for c in comparisons:
self.tree_comparison.insert("", "end", values=(
c.get("model", "N/D"),
f"${c.get('input', 0.0):.2f}",
f"${c.get('cached_input', 0.0):.2f}",
f"${c.get('output', 0.0):.2f}"
))
except Exception:
pass
def append_usage_row(self, phase: str, radicado: str, usage: dict | None, cost: dict | None) -> None:
try:
usd = (cost or {}).get('total_usd', 0.0)
self.append_usage(f"[{phase:<10}] {radicado:<20} | cost=${usd:.4f} | usage={usage}")
except Exception:
pass
# --- HELPERS INTERNOS ---
def _add_doc_type_row(self, name: str = "", keyword: str = "") -> None:
row = ctk.CTkFrame(self.doc_types_list, fg_color="transparent")
row.pack(fill="x", pady=2)
grid_inner = ctk.CTkFrame(row, fg_color="transparent")
grid_inner.pack(fill="x", padx=5, pady=2)
grid_inner.grid_columnconfigure(0, weight=1)
grid_inner.grid_columnconfigure(1, weight=1)
name_entry = ctk.CTkEntry(grid_inner, placeholder_text="Nombre Lógico", height=28, font=self.font_ui)
if name: name_entry.insert(0, name)
name_entry.grid(row=0, column=0, padx=(0, 5), sticky="ew")
kw_entry = ctk.CTkEntry(grid_inner, placeholder_text="Patrón / Keyword", height=28, font=self.font_ui)
if keyword: kw_entry.insert(0, keyword)
kw_entry.grid(row=0, column=1, padx=(0, 5), sticky="ew")
def _remove():
if row in [r["frame"] for r in self._doc_type_rows]:
# Buscar y eliminar de la lista interna
for i, r_data in enumerate(self._doc_type_rows):
if r_data["frame"] == row:
self._doc_type_rows.pop(i)
break
row.destroy()
remove_btn = ctk.CTkButton(grid_inner, text="×", width=30, height=28, fg_color="#C0392B", hover_color="#A93226", command=_remove)
remove_btn.grid(row=0, column=2, sticky="e")
self._doc_type_rows.append({
"frame": row,
"name_entry": name_entry,
"kw_entry": kw_entry,
"remove_btn": remove_btn
})
def _add_preclass_pattern_row(self, key: str = "", mode: str = "key", value: str = "") -> None:
"""Añade una fila editable para un patrón de preclasificación."""
row = ctk.CTkFrame(self.preclass_patterns_list, fg_color="transparent")
row.pack(fill="x", pady=2)
grid_inner = ctk.CTkFrame(row, fg_color="transparent")
grid_inner.pack(fill="x", padx=5, pady=2)
grid_inner.grid_columnconfigure(0, weight=1)
grid_inner.grid_columnconfigure(2, weight=1)
key_entry = ctk.CTkEntry(grid_inner, placeholder_text="Patrón / Key", height=28, font=self.font_ui)
if key:
key_entry.insert(0, key)
key_entry.grid(row=0, column=0, padx=(0, 5), sticky="ew")
mode_combo = ctk.CTkComboBox(
grid_inner,
values=["key", "key+value"],
width=110,
font=self.font_ui
)
mode_combo.grid(row=0, column=1, padx=(0, 5), sticky="w")
value_entry = ctk.CTkEntry(grid_inner, placeholder_text="Value (si aplica)", height=28, font=self.font_ui)
if value:
value_entry.insert(0, value)
value_entry.grid(row=0, column=2, padx=(0, 5), sticky="ew")
def _apply_mode(m: str):
m_norm = (m or "").strip().lower()
if m_norm in ("key+value", "key + value", "key_value", "key-value"):
value_entry.configure(state="normal", placeholder_text="Value (requerido)")
else:
# modo key: el value no aplica
value_entry.delete(0, "end")
value_entry.configure(state="disabled", placeholder_text="Value (no aplica)")
def _on_mode_change(choice: str):
_apply_mode(choice)
# set inicial
try:
mode_combo.set(mode if mode in ("key", "key+value") else "key")
except Exception:
mode_combo.set("key")
mode_combo.configure(command=_on_mode_change)
_apply_mode(mode_combo.get())
def _remove():
try:
for i, r_data in enumerate(list(self._preclass_pattern_rows)):
if r_data.get("frame") == row:
self._preclass_pattern_rows.pop(i)
break
except Exception:
pass
row.destroy()
remove_btn = ctk.CTkButton(grid_inner, text="×", width=30, height=28, fg_color="#C0392B", hover_color="#A93226", command=_remove)
remove_btn.grid(row=0, column=3, sticky="e")
self._preclass_pattern_rows.append({
"frame": row,
"key_entry": key_entry,
"mode_combo": mode_combo,
"value_entry": value_entry,
"remove_btn": remove_btn,
})
def get_preclass_patterns(self) -> list[dict]:
"""Devuelve la lista de patrones de preclasificación definida en la pestaña."""
out = []
for it in getattr(self, "_preclass_pattern_rows", []):
try:
key = it["key_entry"].get().strip()
mode = (it["mode_combo"].get() or "").strip()
value = it["value_entry"].get().strip()
if not key:
continue
if mode == "key+value":
out.append({"key": key, "mode": "key+value", "value": value})
else:
out.append({"key": key, "mode": "key"})
except Exception:
pass
return out
def get_doc_types(self) -> list[dict]:
out = []
for it in self._doc_type_rows:
try:
name = it["name_entry"].get().strip()
kw = it["kw_entry"].get().strip()
if name and kw:
out.append({"name": name, "keyword": kw})
except: pass
return out
def set_doc_types(self, doc_types: list[dict]) -> None:
# Limpiar actuales
for it in self._doc_type_rows:
it["frame"].destroy()
self._doc_type_rows = []
if not doc_types:
self._add_doc_type_row("Factura", "Factura_O_Documento_Equivalente")
return
for dt in doc_types:
self._add_doc_type_row(str(dt.get("name","")), str(dt.get("keyword","")))
def validate_doc_types(self) -> tuple[bool, str | None]:
seen = set()
for dt in self.get_doc_types():
name = dt.get("name")
if name in seen:
return False, f"Nombre duplicado: {name}"
seen.add(name)
if not seen:
return False, "Debe haber al menos un tipo de documento."
return True, None