Files
IPSExtractor/gui/tabs/cleaning_tab.py
T

1212 lines
42 KiB
Python

"""Pestaña de Proceso de Limpieza de CSV.
Permite al usuario cargar un CSV, ver un resumen inteligente por columna y
aplicar reglas de limpieza seleccionables antes de exportar el resultado.
"""
from __future__ import annotations
from dataclasses import dataclass
from tkinter import ttk
from typing import Optional
import customtkinter as ctk
import pandas as pd
from application.csv_cleaning import (
CLEANING_RULES,
ColumnSummary,
applicable_rule_ids_for_type,
)
from gui.styles import AppStyles
_PREVIEW_MAX_ROWS = 50
_PREVIEW_MAX_CELL_LEN = 80
_TYPE_LABELS = {
"integer": "Entero",
"decimal": "Decimal",
"date": "Fecha",
"boolean": "Booleano",
"categorical": "Categórico",
"text": "Texto",
"mixed": "Mixto",
"empty": "Vacío",
}
# Color por tipo (badge). Tuplas (light, dark) compatibles con CTk.
_TYPE_COLORS: dict[str, tuple[str, str]] = {
"integer": ("#1F6FEB", "#1F6FEB"),
"decimal": ("#0EA5E9", "#0EA5E9"),
"date": ("#D97706", "#D97706"),
"boolean": ("#7C3AED", "#7C3AED"),
"categorical": ("#16A34A", "#16A34A"),
"text": ("#475569", "#64748B"),
"mixed": ("#DC2626", "#DC2626"),
"empty": ("#6B7280", "#6B7280"),
}
# Orden fijo en el desplegable de anulación manual
_USER_EDITABLE_TYPE_IDS: tuple[str, ...] = (
"integer", "decimal", "date", "boolean", "categorical", "text", "mixed", "empty",
)
_TYPE_MENU_VALUES: list[str] = [
_TYPE_LABELS.get(t, t) for t in _USER_EDITABLE_TYPE_IDS
]
_RULE_SHORT_LABELS: dict[str, str] = {
"strip_special_spaces": "Sin especiales/espacios (no antes de fecha+hora)",
"strip_special_keep_date_seps": "Símbolos: conserva /, -, :, espacios",
"strip_spaces": "Sin espacios",
"to_integer": "A entero",
"date_to_ddmmyyyy": "Normalizar fecha → DD/MM/AAAA",
"date_numeric_to_ddmmyyyy": "Fecha numérica → DD/MM/AAAA",
"date_alpha_to_ddmmyyyy": "Fecha alfanumérica → DD/MM/AAAA",
"date_messy_text_to_ddmmyyyy": "Fecha con ruido (texto) → DD/MM/AAAA",
"round_decimals_custom": "Aprox. decimales (>0.5 sube)",
"doc_id_split_tipo_numero": "Dividir doc (tipo + cifras)",
"doc_type_homologate": "Homologar tipo doc",
}
def _fmt_number(x) -> str:
"""Formatea un número para mostrar en chip (entero limpio o 2 decimales)."""
if x is None:
return "—"
try:
f = float(x)
except (TypeError, ValueError):
return str(x)
if f.is_integer():
return f"{int(f):,}"
return f"{f:,.2f}"
@dataclass
class _ColumnUI:
"""Referencias a los widgets de una tarjeta de columna."""
card: ctk.CTkFrame
body: ctk.CTkFrame
toggle_btn: ctk.CTkButton
type_menu: ctk.CTkOptionMenu
count_label: ctk.CTkLabel
stats_frame: ctk.CTkFrame
rules_frame: ctk.CTkFrame
rule_vars: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]]
summary: ColumnSummary
expanded: bool = True
class CleaningTab:
"""UI de la pestaña Proceso de Limpieza."""
def __init__(self, parent_tab):
self.tab = parent_tab
self.fonts = AppStyles.get_fonts()
self.colors = AppStyles.get_colors()
self.spacing = AppStyles.get_spacing()
self._column_uis: dict[str, _ColumnUI] = {}
self._search_var: Optional[ctk.StringVar] = None
# Tipo de dato forzado por el usuario (id interno) por columna; vacío = inferido
self._type_overrides: dict[str, str] = {}
self._syncing_rule_vars = False
self._setup_layout()
self._create_file_section()
self._create_preview_section()
self._create_bulk_actions_section()
self._create_columns_section()
self._create_action_section()
self._create_result_section()
# -- Layout ------------------------------------------------------------------
def _setup_layout(self):
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=1)
self.scroll = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
self.scroll.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
self.scroll.grid_columnconfigure(0, weight=1)
# -- Sección archivo ---------------------------------------------------------
def _create_file_section(self):
frame = ctk.CTkFrame(self.scroll, corner_radius=8)
frame.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 4))
frame.grid_columnconfigure(2, weight=1)
lbl_title = ctk.CTkLabel(
frame, text="Archivo CSV", font=self.fonts["section"],
)
lbl_title.grid(row=0, column=0, columnspan=4, sticky="w", padx=15, pady=(10, 4))
self.btn_select_csv = ctk.CTkButton(
frame, text="📂 Seleccionar CSV", font=self.fonts["ui"], width=170,
fg_color=self.colors["primary_button"], hover_color=self.colors["primary_hover"],
)
self.btn_select_csv.grid(row=1, column=0, padx=(15, 8), pady=(0, 12), sticky="w")
self.lbl_csv_path = ctk.CTkLabel(
frame, text="Ningún archivo seleccionado",
font=self.fonts["italic_small"], text_color=self.colors["gray_light"],
anchor="w",
)
self.lbl_csv_path.grid(row=1, column=1, columnspan=2, padx=4, pady=(0, 12), sticky="ew")
# Chips informativos: filas, columnas, delimitador
chips_frame = ctk.CTkFrame(frame, fg_color="transparent")
chips_frame.grid(row=1, column=3, padx=(8, 15), pady=(0, 12), sticky="e")
self.chip_rows = self._make_chip(chips_frame, "Filas: —")
self.chip_rows.pack(side="left", padx=(0, 6))
self.chip_cols = self._make_chip(chips_frame, "Columnas: —")
self.chip_cols.pack(side="left", padx=(0, 6))
self.chip_delim = self._make_chip(chips_frame, "Delim: —")
self.chip_delim.pack(side="left")
self.loading_frame = ctk.CTkFrame(frame, fg_color="transparent")
self.loading_frame.grid(
row=2, column=0, columnspan=4, sticky="ew", padx=15, pady=(0, 12)
)
self.loading_frame.grid_columnconfigure(1, weight=1)
self.loading_label = ctk.CTkLabel(
self.loading_frame,
text="",
font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
anchor="w",
)
self.loading_label.grid(row=0, column=0, sticky="w", padx=(0, 10))
self.loading_progress = ctk.CTkProgressBar(
self.loading_frame,
mode="indeterminate",
height=8,
)
self.loading_progress.grid(row=0, column=1, sticky="ew")
self.loading_frame.grid_remove()
def _make_chip(self, parent, text: str) -> ctk.CTkLabel:
return ctk.CTkLabel(
parent, text=text, font=self.fonts["italic_small"],
fg_color=("gray85", "gray25"), corner_radius=12,
padx=10, pady=2,
)
# -- Vista previa ------------------------------------------------------------
def _create_preview_section(self):
frame = ctk.CTkFrame(self.scroll, corner_radius=8)
frame.grid(row=1, column=0, sticky="ew", padx=5, pady=4)
frame.grid_columnconfigure(0, weight=1)
header = ctk.CTkFrame(frame, fg_color="transparent")
header.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 4))
header.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(header, text="Vista previa", font=self.fonts["section"])
lbl.pack(side="left")
self.lbl_preview_info = ctk.CTkLabel(
header, text="(sin datos)", font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
)
self.lbl_preview_info.pack(side="left", padx=(12, 0))
self.btn_toggle_preview = ctk.CTkButton(
header, text="Ocultar", width=80, height=24,
font=self.fonts["italic_small"],
fg_color="transparent", border_width=1,
command=self._on_toggle_preview,
)
self.btn_toggle_preview.pack(side="right")
self.preview_table_holder = ctk.CTkFrame(
frame, fg_color=("gray92", "gray18"), corner_radius=6, height=220,
)
self.preview_table_holder.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
self.preview_table_holder.grid_propagate(False)
self.preview_table_holder.grid_columnconfigure(0, weight=1)
self.preview_table_holder.grid_rowconfigure(0, weight=1)
self._preview_tree: Optional[ttk.Treeview] = None
self._preview_visible = True
self._render_table_placeholder(
self.preview_table_holder, "Selecciona un CSV para ver la vista previa.",
)
def _on_toggle_preview(self):
if self._preview_visible:
self.preview_table_holder.grid_remove()
self.btn_toggle_preview.configure(text="Mostrar")
else:
self.preview_table_holder.grid()
self.btn_toggle_preview.configure(text="Ocultar")
self._preview_visible = not self._preview_visible
# -- Acciones masivas --------------------------------------------------------
def _create_bulk_actions_section(self):
frame = ctk.CTkFrame(self.scroll, corner_radius=8)
frame.grid(row=2, column=0, sticky="ew", padx=5, pady=4)
frame.grid_columnconfigure(0, weight=1)
header = ctk.CTkFrame(frame, fg_color="transparent")
header.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 4))
header.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(
header, text="Acciones rápidas", font=self.fonts["section"],
)
lbl.pack(side="left")
body = ctk.CTkFrame(frame, fg_color="transparent")
body.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
body.grid_columnconfigure(2, weight=1)
# Buscador
lbl_search = ctk.CTkLabel(body, text="🔍 Buscar columna:", font=self.fonts["ui"])
lbl_search.grid(row=0, column=0, padx=(0, 6), pady=4, sticky="w")
self._search_var = ctk.StringVar(value="")
self._search_var.trace_add("write", lambda *_: self._apply_search_filter())
self.entry_search = ctk.CTkEntry(
body, textvariable=self._search_var, width=240,
placeholder_text="nombre de columna...",
)
self.entry_search.grid(row=0, column=1, padx=(0, 12), pady=4, sticky="w")
# Botones colapsar/expandir todas
btns_right = ctk.CTkFrame(body, fg_color="transparent")
btns_right.grid(row=0, column=2, sticky="e")
self.btn_expand_all = ctk.CTkButton(
btns_right, text="Expandir todas", width=130, height=28,
font=self.fonts["ui"], fg_color="transparent", border_width=1,
command=lambda: self._set_all_expanded(True),
)
self.btn_expand_all.pack(side="left", padx=4)
self.btn_collapse_all = ctk.CTkButton(
btns_right, text="Colapsar todas", width=130, height=28,
font=self.fonts["ui"], fg_color="transparent", border_width=1,
command=lambda: self._set_all_expanded(False),
)
self.btn_collapse_all.pack(side="left", padx=4)
self.btn_clear_rules = ctk.CTkButton(
btns_right, text="Limpiar selección", width=140, height=28,
font=self.fonts["ui"],
fg_color=self.colors["danger"], hover_color=self.colors["danger_hover"],
command=self._clear_all_rules,
)
self.btn_clear_rules.pack(side="left", padx=4)
# Aplicar regla a todas las de un tipo
bulk_row = ctk.CTkFrame(body, fg_color="transparent")
bulk_row.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(8, 0))
lbl_bulk = ctk.CTkLabel(
bulk_row, text="Aplicar regla a todas las columnas de tipo:",
font=self.fonts["ui"],
)
lbl_bulk.pack(side="left", padx=(0, 6))
type_options = list(_TYPE_LABELS.values())
self._bulk_type_var = ctk.StringVar(value=type_options[0])
self.bulk_type_menu = ctk.CTkOptionMenu(
bulk_row, values=type_options, variable=self._bulk_type_var,
width=130, font=self.fonts["ui"],
)
self.bulk_type_menu.pack(side="left", padx=(0, 6))
self._bulk_rule_var = ctk.StringVar(value=CLEANING_RULES[0]["name"])
self.bulk_rule_menu = ctk.CTkOptionMenu(
bulk_row, values=[r["name"] for r in CLEANING_RULES], variable=self._bulk_rule_var,
width=380, font=self.fonts["ui"],
)
self.bulk_rule_menu.pack(side="left", padx=(0, 6))
self._sync_bulk_rule_menu()
self._bulk_type_var.trace_add("write", lambda *_: self._sync_bulk_rule_menu())
self.btn_bulk_apply = ctk.CTkButton(
bulk_row, text="Aplicar a todas", width=140, height=28,
font=self.fonts["ui"],
fg_color=self.colors["secondary_button"],
hover_color=self.colors["secondary_hover"],
command=self._on_bulk_apply,
)
self.btn_bulk_apply.pack(side="left", padx=4)
# -- Columnas y reglas -------------------------------------------------------
def _create_columns_section(self):
frame = ctk.CTkFrame(self.scroll, corner_radius=8)
frame.grid(row=3, column=0, sticky="ew", padx=5, pady=4)
frame.grid_columnconfigure(0, weight=1)
header = ctk.CTkFrame(frame, fg_color="transparent")
header.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 4))
header.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(
header, text="Resumen y reglas por columna", font=self.fonts["section"],
)
lbl.pack(side="left")
self.lbl_global_count = ctk.CTkLabel(
header, text="0 reglas seleccionadas",
font=self.fonts["italic_small"], text_color=self.colors["gray_light"],
)
self.lbl_global_count.pack(side="right")
# Frame plano: el scroll único es el de `self.scroll` (evita doble rueda del ratón)
self.columns_container = ctk.CTkFrame(
frame, fg_color="transparent",
)
self.columns_container.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10))
self.columns_container.grid_columnconfigure(0, weight=1)
self._empty_label = ctk.CTkLabel(
self.columns_container,
text="Selecciona un CSV para ver el resumen de columnas.",
font=self.fonts["italic_small"], text_color=self.colors["gray_light"],
)
self._empty_label.grid(row=0, column=0, sticky="w", padx=10, pady=20)
# -- Acción ------------------------------------------------------------------
def _create_action_section(self):
frame = ctk.CTkFrame(self.scroll, corner_radius=8)
frame.grid(row=4, column=0, sticky="ew", padx=5, pady=4)
frame.grid_columnconfigure(1, weight=1)
self.btn_apply = ctk.CTkButton(
frame, text="✨ Aplicar limpieza", font=self.fonts["button_large"],
fg_color=self.colors["primary_button"], hover_color=self.colors["primary_hover"],
height=42, width=220,
)
self.btn_apply.grid(row=0, column=0, padx=15, pady=12, sticky="w")
self.lbl_status = ctk.CTkLabel(
frame, text="", font=self.fonts["italic_small"],
text_color=self.colors["gray_light"], anchor="w",
)
self.lbl_status.grid(row=0, column=1, padx=10, pady=12, sticky="ew")
# -- Resultado ---------------------------------------------------------------
def _create_result_section(self):
frame = ctk.CTkFrame(self.scroll, corner_radius=8)
frame.grid(row=5, column=0, sticky="ew", padx=5, pady=(4, 6))
frame.grid_columnconfigure(0, weight=1)
top = ctk.CTkFrame(frame, fg_color="transparent")
top.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 4))
top.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(
top, text="Vista previa del resultado", font=self.fonts["section"],
)
lbl.pack(side="left")
self.lbl_result_info = ctk.CTkLabel(
top, text="", font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
)
self.lbl_result_info.pack(side="left", padx=(12, 0))
self.btn_save = ctk.CTkButton(
top, text="💾 Guardar CSV", font=self.fonts["ui"], width=160, height=32,
fg_color=self.colors["success"], hover_color=self.colors["success_hover"],
state="disabled",
)
self.btn_save.pack(side="right")
self.result_table_holder = ctk.CTkFrame(
frame, fg_color=("gray92", "gray18"), corner_radius=6, height=260,
)
self.result_table_holder.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10))
self.result_table_holder.grid_propagate(False)
self.result_table_holder.grid_columnconfigure(0, weight=1)
self.result_table_holder.grid_rowconfigure(0, weight=1)
self._result_tree: Optional[ttk.Treeview] = None
self._render_table_placeholder(
self.result_table_holder, "Aplica reglas para ver el resultado.",
)
# ===========================================================================
# API pública
# ===========================================================================
def set_on_select_csv(self, callback):
self.btn_select_csv.configure(command=callback)
def set_on_apply(self, callback):
self.btn_apply.configure(command=callback)
def set_on_save(self, callback):
self.btn_save.configure(command=callback)
def set_csv_path(self, path: str):
self.lbl_csv_path.configure(text=path or "Ningún archivo seleccionado")
def set_status(self, text: str):
self.lbl_status.configure(text=text)
def set_loading(self, loading: bool, text: str = ""):
"""Muestra u oculta una barra indeterminada para operaciones de CSV."""
if loading:
self.loading_label.configure(text=text or "Procesando CSV...")
self.loading_frame.grid()
try:
self.loading_progress.start()
except Exception:
pass
else:
try:
self.loading_progress.stop()
except Exception:
pass
self.loading_label.configure(text="")
self.loading_frame.grid_remove()
def set_preview(self, df: Optional[pd.DataFrame]):
"""Renderiza el DataFrame original en la tabla de vista previa."""
if df is None or len(df) == 0:
self._render_table_placeholder(
self.preview_table_holder, "(sin datos)",
)
self.lbl_preview_info.configure(text="(sin datos)")
self._preview_tree = None
return
info = (
f"{len(df):,} filas · {len(df.columns)} columnas · "
f"mostrando primeras {min(_PREVIEW_MAX_ROWS, len(df))}"
)
self.lbl_preview_info.configure(text=info)
self._preview_tree = self._render_dataframe_table(
self.preview_table_holder, df,
)
self.chip_rows.configure(text=f"Filas: {len(df):,}")
self.chip_cols.configure(text=f"Columnas: {len(df.columns)}")
def set_result(self, df: Optional[pd.DataFrame], info_text: str = ""):
"""Renderiza el DataFrame resultante en la tabla de resultado."""
if df is None or len(df) == 0:
self._render_table_placeholder(
self.result_table_holder, "(sin datos)",
)
self.lbl_result_info.configure(text=info_text)
self._result_tree = None
return
summary = (
f"{len(df):,} filas · {len(df.columns)} columnas · "
f"mostrando primeras {min(_PREVIEW_MAX_ROWS, len(df))}"
)
if info_text:
summary = f"{summary} · {info_text}"
self.lbl_result_info.configure(text=summary)
self._result_tree = self._render_dataframe_table(
self.result_table_holder, df,
)
def enable_save(self, enabled: bool):
self.btn_save.configure(state="normal" if enabled else "disabled")
def enable_controls(self, enabled: bool):
state = "normal" if enabled else "disabled"
self.btn_select_csv.configure(state=state)
self.btn_apply.configure(state=state)
self.btn_bulk_apply.configure(state=state)
self.btn_clear_rules.configure(state=state)
self.btn_expand_all.configure(state=state)
self.btn_collapse_all.configure(state=state)
def _sync_bulk_rule_menu(self) -> None:
"""Acota las reglas del desplegable según el tipo (p. ej. entero: sin a entero/approx)."""
if not hasattr(self, "bulk_rule_menu") or not hasattr(
self, "_bulk_type_var",
):
return
type_label = self._bulk_type_var.get()
type_id = next(
(k for k, v in _TYPE_LABELS.items() if v == type_label), "text"
)
want = set(applicable_rule_ids_for_type(type_id))
names = [r["name"] for r in CLEANING_RULES if r["id"] in want]
if not names:
return
cur = self._bulk_rule_var.get()
self.bulk_rule_menu.configure(values=names)
if cur in names:
self._bulk_rule_var.set(cur)
else:
self._bulk_rule_var.set(names[0])
def populate_columns(self, summaries: list[ColumnSummary]):
"""Renderiza la lista de columnas con sus reglas aplicables."""
for child in list(self.columns_container.winfo_children()):
child.destroy()
self._column_uis.clear()
self._type_overrides.clear()
if self._search_var is not None:
try:
self._search_var.set("")
except Exception:
pass
if not summaries:
empty = ctk.CTkLabel(
self.columns_container,
text="El CSV no tiene columnas.",
font=self.fonts["italic_small"], text_color=self.colors["gray_light"],
)
empty.grid(row=0, column=0, sticky="w", padx=10, pady=20)
self._update_global_count()
self.chip_cols.configure(text=f"Columnas: 0")
return
rules_by_id = {r["id"]: r for r in CLEANING_RULES}
for row_idx, summary in enumerate(summaries):
ui = self._build_column_card(row_idx, summary, rules_by_id)
self._column_uis[summary.name] = ui
self.chip_cols.configure(text=f"Columnas: {len(summaries)}")
self._update_global_count()
def get_selected_rules(self) -> dict[str, list[str]]:
"""Devuelve un mapa columna -> lista de rule_ids en el orden visual."""
result: dict[str, list[str]] = {}
for col, ui in self._column_uis.items():
selected = [rid for rid, _chk, var in ui.rule_vars if bool(var.get())]
if selected:
result[col] = selected
return result
# ===========================================================================
# Construcción de tarjeta de columna
# ===========================================================================
def _build_column_card(
self, row_idx: int, summary: ColumnSummary, rules_by_id: dict,
) -> _ColumnUI:
card = ctk.CTkFrame(self.columns_container, corner_radius=8, border_width=1)
card.grid(row=row_idx, column=0, sticky="ew", padx=2, pady=4)
card.grid_columnconfigure(0, weight=1)
# ---- Header ----
header = ctk.CTkFrame(card, fg_color="transparent")
header.grid(row=0, column=0, sticky="ew", padx=10, pady=(8, 4))
header.grid_columnconfigure(2, weight=1)
toggle_btn = ctk.CTkButton(
header, text="▼", width=28, height=28,
font=self.fonts["ui"], fg_color="transparent",
border_width=0, hover=True,
)
toggle_btn.grid(row=0, column=0, padx=(0, 6), sticky="w")
eff0 = self._type_overrides.get(summary.name) or summary.inferred_type
if eff0 not in _TYPE_LABELS:
eff0 = "text"
tcol = _TYPE_COLORS.get(eff0, ("#64748B", "#64748B"))
tmenu = ctk.CTkOptionMenu(
header,
values=_TYPE_MENU_VALUES,
width=150,
height=28,
font=self.fonts["ui"],
corner_radius=10,
button_color=tcol[0] if tcol[0] else tcol[1],
button_hover_color=tcol[0],
fg_color=tcol[0] if tcol[0] else tcol[1],
)
tmenu.set(_TYPE_LABELS.get(eff0, _TYPE_LABELS["text"]))
tmenu.grid(row=0, column=1, padx=(0, 6), sticky="w")
tmenu.configure(
command=lambda v, c=summary.name, m=tmenu: self._on_user_column_type_change(
c, m, v
)
)
name_lbl = ctk.CTkLabel(
header, text=summary.name, font=self.fonts["bold_large"], anchor="w",
)
name_lbl.grid(row=0, column=2, sticky="w")
count_lbl = ctk.CTkLabel(
header, text="0 reglas",
font=self.fonts["italic_small"], text_color=self.colors["gray_light"],
)
count_lbl.grid(row=0, column=3, padx=(8, 0), sticky="e")
# ---- Body (info + reglas) ----
body = ctk.CTkFrame(card, fg_color="transparent")
body.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10))
body.grid_columnconfigure(0, weight=1)
example = summary.example or "(sin datos)"
if len(example) > 100:
example = example[:97] + "..."
lbl_example = ctk.CTkLabel(
body, text=f'Ejemplo: "{example}"',
font=self.fonts["mono_small"], anchor="w",
text_color=self.colors["gray_dark"], justify="left",
)
lbl_example.grid(row=0, column=0, sticky="ew", padx=4, pady=(0, 4))
stats_frame = self._build_stats_chips(body, summary, eff0)
stats_frame.grid(
row=1, column=0, sticky="ew", padx=4, pady=(0, 4),
)
if summary.top_values:
self._build_top_values_panel(body, summary.top_values).grid(
row=2, column=0, sticky="ew", padx=4, pady=(0, 6),
)
rules_frame = ctk.CTkFrame(body, fg_color=("gray92", "gray18"), corner_radius=6)
rules_frame.grid(row=3, column=0, sticky="ew", padx=0, pady=(2, 0))
rules_frame.grid_columnconfigure((0, 1), weight=1, uniform="rules")
eff_rules = self._type_overrides.get(summary.name) or summary.inferred_type
if eff_rules not in _TYPE_LABELS:
eff_rules = "text"
applic = set(applicable_rule_ids_for_type(eff_rules))
ordered_ids = [r["id"] for r in CLEANING_RULES if r["id"] in applic]
vars_list: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]] = []
col_in_row = 0
row_in_rules = 0
for rid in ordered_ids:
rule = rules_by_id.get(rid)
if rule is None:
continue
var = ctk.BooleanVar(value=False)
var.trace_add(
"write",
lambda *_a, c=summary.name, r=rid: self._on_rule_toggle(c, r),
)
label = _RULE_SHORT_LABELS.get(rid, rule["name"])
chk = ctk.CTkCheckBox(
rules_frame, text=label, variable=var,
font=self.fonts["ui"], checkbox_width=18, checkbox_height=18,
)
chk.grid(row=row_in_rules, column=col_in_row, sticky="w", padx=10, pady=4)
vars_list.append((rid, chk, var))
col_in_row += 1
if col_in_row >= 2:
col_in_row = 0
row_in_rules += 1
ui = _ColumnUI(
card=card,
body=body,
toggle_btn=toggle_btn,
type_menu=tmenu,
count_label=count_lbl,
stats_frame=stats_frame,
rules_frame=rules_frame,
rule_vars=vars_list,
summary=summary,
)
toggle_btn.configure(command=lambda c=summary.name: self._toggle_column(c))
return ui
# ===========================================================================
# Stats visuales por columna
# ===========================================================================
def _build_stats_chips(
self, parent, summary: ColumnSummary, display_type: Optional[str] = None
) -> ctk.CTkFrame:
"""Construye una fila de chips con la información clave de la columna."""
row = ctk.CTkFrame(parent, fg_color="transparent")
dtype = (display_type or summary.inferred_type) or "text"
if dtype not in _TYPE_LABELS:
dtype = "text"
# Sin datos
nulls_color = (
("#FEE2E2", "#7F1D1D") if summary.null_count > 0
else ("gray88", "gray22")
)
nulls_text_color = (
("#991B1B", "#FECACA") if summary.null_count > 0
else ("gray40", "gray70")
)
self._make_stat_chip(
row,
label="Sin datos",
value=f"{summary.null_count} ({summary.null_pct:.0f}%)",
bg=nulls_color,
text_color=nulls_text_color,
).pack(side="left", padx=(0, 6), pady=2)
# Únicos
self._make_stat_chip(
row,
label="Únicos",
value=f"{summary.unique_count}",
bg=("#DBEAFE", "#1E3A8A"),
text_color=("#1E40AF", "#BFDBFE"),
).pack(side="left", padx=(0, 6), pady=2)
# Stats por tipo
if dtype in ("integer", "decimal"):
if summary.min is not None:
self._make_stat_chip(
row, label="Mín", value=_fmt_number(summary.min),
bg=("#DCFCE7", "#14532D"), text_color=("#166534", "#BBF7D0"),
).pack(side="left", padx=(0, 6), pady=2)
if summary.max is not None:
self._make_stat_chip(
row, label="Máx", value=_fmt_number(summary.max),
bg=("#DCFCE7", "#14532D"), text_color=("#166534", "#BBF7D0"),
).pack(side="left", padx=(0, 6), pady=2)
if summary.mean is not None:
self._make_stat_chip(
row, label="Promedio", value=_fmt_number(summary.mean),
bg=("#DCFCE7", "#14532D"), text_color=("#166534", "#BBF7D0"),
).pack(side="left", padx=(0, 6), pady=2)
elif dtype == "date":
if summary.detected_date_format:
self._make_stat_chip(
row, label="Formato detectado",
value=summary.detected_date_format,
bg=("#FFEDD5", "#7C2D12"),
text_color=("#9A3412", "#FED7AA"),
).pack(side="left", padx=(0, 6), pady=2)
elif dtype in ("text", "categorical"):
if summary.avg_length is not None:
self._make_stat_chip(
row, label="Longitud promedio",
value=f"{summary.avg_length:.0f} caracteres",
bg=("gray88", "gray22"), text_color=("gray35", "gray75"),
).pack(side="left", padx=(0, 6), pady=2)
return row
def _make_stat_chip(
self,
parent,
*,
label: str,
value: str,
bg,
text_color,
) -> ctk.CTkFrame:
"""Chip con etiqueta pequeña + valor en negrita."""
chip = ctk.CTkFrame(parent, fg_color=bg, corner_radius=10)
inner = ctk.CTkFrame(chip, fg_color="transparent")
inner.pack(padx=8, pady=2)
ctk.CTkLabel(
inner, text=label, font=ctk.CTkFont(size=10),
text_color=text_color,
).pack(side="left", padx=(0, 4))
ctk.CTkLabel(
inner, text=value, font=ctk.CTkFont(size=11, weight="bold"),
text_color=text_color,
).pack(side="left")
return chip
def _build_top_values_panel(
self, parent, top_values: list[tuple[str, int]],
) -> ctk.CTkFrame:
"""Panel con los valores más frecuentes en formato chip."""
container = ctk.CTkFrame(parent, fg_color="transparent")
container.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(
container, text="Más frecuentes:",
font=ctk.CTkFont(size=11, weight="bold"),
text_color=self.colors["text_muted"], anchor="w",
).grid(row=0, column=0, padx=(0, 8), sticky="nw")
chips_row = ctk.CTkFrame(container, fg_color="transparent")
chips_row.grid(row=0, column=1, sticky="ew")
for value, count in top_values[:5]:
display = str(value)
if len(display) > 60:
display = display[:57] + "..."
chip = ctk.CTkFrame(
chips_row, fg_color=("gray90", "gray20"), corner_radius=10,
)
chip.pack(side="left", padx=(0, 6), pady=2)
ctk.CTkLabel(
chip, text=f'"{display}"',
font=ctk.CTkFont(size=11),
text_color=("gray25", "gray85"),
).pack(side="left", padx=(8, 4), pady=2)
ctk.CTkLabel(
chip, text=f"x{count}",
font=ctk.CTkFont(size=10, weight="bold"),
text_color=self.colors["primary_button"],
).pack(side="left", padx=(0, 8), pady=2)
return container
# ===========================================================================
# Lógica interna
# ===========================================================================
def _effective_inferred_type(self, ui: _ColumnUI) -> str:
"""Tipo mostrado para la columna: anulación del usuario o inferido."""
t = self._type_overrides.get(ui.summary.name) or ui.summary.inferred_type
return t if t in _TYPE_LABELS else "text"
def _on_user_column_type_change(
self, col_name: str, type_menu: ctk.CTkOptionMenu, spanish_label: str
) -> None:
type_id = next(
(k for k, v in _TYPE_LABELS.items() if v == spanish_label), "text"
)
ui = self._column_uis.get(col_name)
if ui is None:
return
s = ui.summary
if type_id == s.inferred_type:
self._type_overrides.pop(col_name, None)
else:
self._type_overrides[col_name] = type_id
eff = self._effective_inferred_type(ui)
tcol = _TYPE_COLORS.get(eff, ("#64748B", "#64748B"))
type_menu.configure(
button_color=tcol[0] if tcol[0] else tcol[1],
button_hover_color=tcol[0],
fg_color=tcol[0] if tcol[0] else tcol[1],
)
ui.stats_frame.destroy()
new_stats = self._build_stats_chips(ui.body, s, eff)
new_stats.grid(row=1, column=0, sticky="ew", padx=4, pady=(0, 4))
ui.stats_frame = new_stats
for w in list(ui.rules_frame.winfo_children()):
w.destroy()
applic = set(applicable_rule_ids_for_type(eff))
new_vars: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]] = []
col_in_row = 0
row_in_rules = 0
rmap = {r["id"]: r for r in CLEANING_RULES}
for rid in (r["id"] for r in CLEANING_RULES if r["id"] in applic):
rule = rmap.get(rid)
if rule is None:
continue
var = ctk.BooleanVar(value=False)
var.trace_add(
"write",
lambda *_a, c=col_name, r=rid: self._on_rule_toggle(c, r),
)
label = _RULE_SHORT_LABELS.get(rid, rule["name"])
chk = ctk.CTkCheckBox(
ui.rules_frame, text=label, variable=var,
font=self.fonts["ui"], checkbox_width=18, checkbox_height=18,
)
chk.grid(row=row_in_rules, column=col_in_row, sticky="w", padx=10, pady=4)
new_vars.append((rid, chk, var))
col_in_row += 1
if col_in_row >= 2:
col_in_row = 0
row_in_rules += 1
ui.rule_vars = new_vars
self.set_status(f"Columna «{col_name}»: tipo «{_TYPE_LABELS.get(eff, eff)}».")
self._on_rule_toggle(col_name)
def _toggle_column(self, col_name: str):
ui = self._column_uis.get(col_name)
if ui is None:
return
if ui.expanded:
ui.body.grid_remove()
ui.toggle_btn.configure(text="▶")
else:
ui.body.grid()
ui.toggle_btn.configure(text="▼")
ui.expanded = not ui.expanded
def _set_all_expanded(self, expanded: bool):
for col_name, ui in self._column_uis.items():
if ui.expanded != expanded:
self._toggle_column(col_name)
def _on_rule_toggle(self, col_name: str, changed_rule_id: Optional[str] = None):
ui = self._column_uis.get(col_name)
if ui is None:
return
if (
changed_rule_id == "doc_id_split_tipo_numero"
and not self._syncing_rule_vars
):
split_selected = any(
rid == "doc_id_split_tipo_numero" and bool(var.get())
for rid, _chk, var in ui.rule_vars
)
if split_selected:
for rid, _chk, var in ui.rule_vars:
if rid == "doc_type_homologate" and not bool(var.get()):
self._syncing_rule_vars = True
try:
var.set(True)
finally:
self._syncing_rule_vars = False
self.set_status(
f"Columna «{col_name}»: se activó homologación de tipo de documento."
)
break
selected = sum(1 for _rid, _chk, var in ui.rule_vars if bool(var.get()))
text = f"{selected} regla{'s' if selected != 1 else ''}"
color = self.colors["success"] if selected else self.colors["gray_light"]
ui.count_label.configure(text=text, text_color=color)
self._update_global_count()
def _update_global_count(self):
cols_with_rules = 0
total_rules = 0
for ui in self._column_uis.values():
sel = sum(1 for _rid, _chk, var in ui.rule_vars if bool(var.get()))
if sel:
cols_with_rules += 1
total_rules += sel
if total_rules == 0:
self.lbl_global_count.configure(
text="0 reglas seleccionadas",
text_color=self.colors["gray_light"],
)
else:
self.lbl_global_count.configure(
text=f"{total_rules} reglas en {cols_with_rules} columnas",
text_color=self.colors["success"],
)
def _clear_all_rules(self):
for ui in self._column_uis.values():
for _rid, _chk, var in ui.rule_vars:
if bool(var.get()):
var.set(False)
def _on_bulk_apply(self):
"""Aplica la regla seleccionada a todas las columnas del tipo elegido."""
type_label = self._bulk_type_var.get()
rule_name = self._bulk_rule_var.get()
type_id = next(
(k for k, v in _TYPE_LABELS.items() if v == type_label), None,
)
rule_id = next(
(r["id"] for r in CLEANING_RULES if r["name"] == rule_name), None,
)
if not type_id or not rule_id:
return
matched = 0
for ui in self._column_uis.values():
if self._effective_inferred_type(ui) != type_id:
continue
for rid, _chk, var in ui.rule_vars:
if rid == rule_id:
var.set(True)
matched += 1
break
self.set_status(
f"Regla aplicada a {matched} columna(s) de tipo {type_label}."
)
def _apply_search_filter(self):
if self._search_var is None:
return
query = (self._search_var.get() or "").strip().lower()
for col_name, ui in self._column_uis.items():
if not query or query in col_name.lower():
ui.card.grid()
else:
ui.card.grid_remove()
def set_delimiter_chip(self, delimiter: str):
"""Actualiza el chip del delimitador detectado."""
label = {"\t": "TAB", " ": "SPACE"}.get(delimiter, delimiter)
self.chip_delim.configure(text=f"Delim: {label}")
# ===========================================================================
# Renderizado de tablas
# ===========================================================================
def _render_table_placeholder(self, holder: ctk.CTkFrame, text: str):
"""Limpia el holder y muestra un mensaje centrado."""
for child in list(holder.winfo_children()):
child.destroy()
placeholder = ctk.CTkLabel(
holder, text=text, font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
)
placeholder.grid(row=0, column=0, padx=20, pady=20, sticky="nsew")
def _render_dataframe_table(
self, holder: ctk.CTkFrame, df: pd.DataFrame,
) -> ttk.Treeview:
"""Renderiza un DataFrame como ttk.Treeview con scroll y estilo pulido."""
for child in list(holder.winfo_children()):
child.destroy()
palette = self._table_palette()
style = ttk.Style()
try:
style.theme_use("clam")
except Exception:
pass
style.configure(
"Cleaning.Treeview",
rowheight=30,
font=("Consolas", 10),
background=palette["row_even"],
fieldbackground=palette["row_even"],
foreground=palette["text"],
borderwidth=0,
relief="flat",
)
style.configure(
"Cleaning.Treeview.Heading",
font=("Roboto", 10, "bold"),
background=palette["header_bg"],
foreground=palette["header_fg"],
relief="flat",
padding=(12, 10),
borderwidth=0,
)
# Header con separadores verticales (el borde aparece al poner relief="solid")
style.map(
"Cleaning.Treeview.Heading",
background=[("active", palette["header_hover"])],
relief=[("active", "flat"), ("!active", "flat")],
)
style.map(
"Cleaning.Treeview",
background=[("selected", palette["selected_bg"])],
foreground=[("selected", palette["selected_fg"])],
)
# Truco: el layout de la celda le añade un separador visible ajustando
# el padding lateral para que la zebra se "vea" entre columnas.
try:
style.layout(
"Cleaning.Treeview.Heading",
[
("Treeheading.cell", {"sticky": "nswe"}),
("Treeheading.border", {
"sticky": "nswe", "children": [
("Treeheading.padding", {
"sticky": "nswe", "children": [
("Treeheading.image", {"side": "right", "sticky": ""}),
("Treeheading.text", {"sticky": "we"}),
],
}),
],
}),
],
)
except Exception:
pass
# Wrapper con borde sutil para resaltar la tabla
outer = ctk.CTkFrame(
holder, fg_color=palette["outer_bg"],
border_width=1, border_color=palette["border"],
corner_radius=6,
)
outer.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
outer.grid_columnconfigure(0, weight=1)
outer.grid_rowconfigure(0, weight=1)
cols = [str(c) for c in df.columns]
tree = ttk.Treeview(
outer, columns=cols, show="headings", style="Cleaning.Treeview",
selectmode="browse",
)
for i, col in enumerate(cols):
tree.heading(col, text=f" {col} ", anchor="w")
width = self._estimate_column_width(df[col], col)
anchor = "w"
tree.column(col, width=width, minwidth=80, anchor=anchor, stretch=False)
# Zebra striping + primera columna con acento sutil
tree.tag_configure(
"odd", background=palette["row_odd"], foreground=palette["text"],
)
tree.tag_configure(
"even", background=palette["row_even"], foreground=palette["text"],
)
visible = df.head(_PREVIEW_MAX_ROWS)
for idx, (_, row) in enumerate(visible.iterrows()):
# Espacios laterales en cada celda para separar visualmente las columnas
values = [f" {self._truncate_cell(v)}" for v in row.tolist()]
tag = "odd" if idx % 2 else "even"
tree.insert("", "end", values=values, tags=(tag,))
sb_y = ttk.Scrollbar(outer, orient="vertical", command=tree.yview)
sb_x = ttk.Scrollbar(outer, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=sb_y.set, xscrollcommand=sb_x.set)
tree.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")
holder.grid_columnconfigure(0, weight=1)
holder.grid_rowconfigure(0, weight=1)
return tree
def _table_palette(self) -> dict[str, str]:
"""Paleta de colores adaptada al tema claro/oscuro activo."""
try:
mode = ctk.get_appearance_mode()
except Exception:
mode = "Light"
if mode == "Dark":
return {
"outer_bg": "#1F2937",
"border": "#374151",
"header_bg": "#111827",
"header_fg": "#F9FAFB",
"header_hover": "#1F2937",
"row_even": "#1F2937",
"row_odd": "#273142",
"text": "#E5E7EB",
"selected_bg": "#2563EB",
"selected_fg": "#FFFFFF",
}
return {
"outer_bg": "#FFFFFF",
"border": "#D1D5DB",
"header_bg": "#1F2937",
"header_fg": "#FFFFFF",
"header_hover": "#374151",
"row_even": "#FFFFFF",
"row_odd": "#F3F4F6",
"text": "#111827",
"selected_bg": "#2563EB",
"selected_fg": "#FFFFFF",
}
@staticmethod
def _truncate_cell(value) -> str:
if value is None:
return ""
if isinstance(value, float) and pd.isna(value):
return ""
s = str(value)
if len(s) > _PREVIEW_MAX_CELL_LEN:
return s[: _PREVIEW_MAX_CELL_LEN - 3] + "..."
return s
@staticmethod
def _estimate_column_width(series: pd.Series, col_name: str) -> int:
"""Estima el ancho de una columna en pixeles a partir del contenido."""
char_w = 8
# Muestreo más amplio para evitar cortar valores largos.
sample = series.head(40).astype(str).tolist()
max_len = max([len(col_name)] + [len(s) for s in sample] + [6])
max_len = min(max_len, _PREVIEW_MAX_CELL_LEN)
# Padding extra para los espacios laterales agregados en cada celda
# (formato " valor ") que mejoran la separación visual entre columnas.
return max(110, min(460, max_len * char_w + 40))