From 3ea6891cd36beb284b6f79de336763651516e7ff Mon Sep 17 00:00:00 2001 From: alumnos_ti Date: Wed, 1 Jul 2026 20:40:09 +0000 Subject: [PATCH] Subir archivos a "/" --- main.py | 91 ++ migrate_db.py | 118 ++ modelos.py | 81 ++ seguridad.py | 49 + vistas.py | 3199 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 3538 insertions(+) create mode 100644 main.py create mode 100644 migrate_db.py create mode 100644 modelos.py create mode 100644 seguridad.py create mode 100644 vistas.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..41191f1 --- /dev/null +++ b/main.py @@ -0,0 +1,91 @@ +import tkinter as tk +from tkinter import messagebox, filedialog +from typing import Dict, Type, Any +import gestor_pdf +import vistas +import config +from logger import get_logger +from controlador import Controlador + +logger = get_logger(__name__) + +class Aplicacion(tk.Tk): + def __init__(self) -> None: + super().__init__() + self.controlador = Controlador(self) + + self.title(config.TITULO_APP) + # 1. Aplicamos minsize para evitar el colapso responsivo + self.minsize(width=900, height=650) + + try: + self.iconbitmap(config.ICONO_PATH) + except Exception: + logger.warning("No se pudo cargar el icono de la aplicación.") + self.geometry(config.GEOMETRIA) + + + # BARRA DE MENÚ NATIVA (nace oculta hasta el login exitoso) + self.barra_menus = tk.Menu(self) + self.menu_archivo = tk.Menu(self.barra_menus, tearoff=0) + self.menu_archivo.add_command(label="Nueva plantilla", command=self.nueva_plantilla) + self.menu_archivo.add_command(label="Importar desde Excel...", command=self._abrir_filedialog_excel) + self.menu_archivo.add_separator() + self.menu_archivo.add_command(label="Volver al Home", command=lambda: self.mostrar_frame(vistas.PaginaTareas)) + self.menu_archivo.add_separator() + self.menu_archivo.add_command(label="Cerrar Sesión", command=self.controlador.cerrar_sesion) + + self.barra_menus.add_cascade(label="Archivo", menu=self.menu_archivo) + + # Menú "Herramientas" — Backup BD + self.menu_herramientas = tk.Menu(self.barra_menus, tearoff=0) + self.menu_herramientas.add_command(label="Generar Backup de Base de Datos", command=self.controlador.crear_backup_bd) + self.barra_menus.add_cascade(label="Herramientas", menu=self.menu_herramientas) + + # Botón directo "Manual de Usuario" en la barra superior + self.barra_menus.add_command(label="Manual de Usuario", command=lambda: self.mostrar_frame(vistas.PaginaManual)) + + # CONTENEDOR + self.contenedor = tk.Frame(self, bg="white") + self.contenedor.pack(side="top", fill="both", expand=True) + + self.contenedor.columnconfigure(0, weight=1) # Permite que la columna 0 crezca + self.contenedor.rowconfigure(0, weight=1) # Permite que la fila 0 crezca + + self.frames: Dict[Type[tk.Frame], tk.Frame] = {} + for Pagina in (vistas.PaginaLogin, vistas.PaginaTareas, vistas.PaginaRegistroUsuario, vistas.Plantilla, vistas.PaginaBuscadorHistorico, vistas.PaginaManual): + frame = Pagina(self.contenedor, self.controlador) + self.frames[Pagina] = frame + frame.grid(row=0, column=0, sticky="nsew") + + self.mostrar_frame(vistas.PaginaLogin) + + # FUNCIONES DE NAVEGACIÓN + def mostrar_frame(self, nombre_clase: Type[tk.Frame]) -> None: + frame = self.frames[nombre_clase] + frame.tkraise() + # Forzar actualización de la vista al mostrarla (Corrige Bug de RBAC en Tkinter) + if hasattr(frame, "actualizar_vista"): + frame.actualizar_vista() + + def nueva_plantilla(self) -> None: + self.controlador.nueva_plantilla() + + def abrir_archivo(self) -> None: + self.controlador.abrir_archivo() + + def guardar_archivo(self) -> bool: + return self.controlador.guardar_archivo() + + def _abrir_filedialog_excel(self) -> None: + """Abre un diálogo para seleccionar un archivo Excel e invoca la importación.""" + ruta = filedialog.askopenfilename( + title="Seleccionar archivo Excel", + filetypes=(("Archivos Excel", "*.xlsx *.xlsm"), ("Todos los archivos", "*.*")) + ) + if ruta: + self.controlador.importar_desde_excel(ruta) + +if __name__ == "__main__": + app = Aplicacion() + app.mainloop() \ No newline at end of file diff --git a/migrate_db.py b/migrate_db.py new file mode 100644 index 0000000..9e0f9c3 --- /dev/null +++ b/migrate_db.py @@ -0,0 +1,118 @@ +import pyodbc +import config +from logger import get_logger + +logger = get_logger(__name__) + +def run_migration() -> bool: + """ + Ejecuta la migración de base de datos para agregar las columnas necesarias a SQL Server. + """ + conn_str = ( + f"DRIVER={config.DB_DRIVER};" + f"SERVER={config.DB_SERVER};" + f"DATABASE={config.DB_NAME};" + f"Trusted_Connection=yes;" + ) + conn = None + try: + conn = pyodbc.connect(conn_str) + cursor = conn.cursor() + + # 1. Add 'tipo' column to Recursos if it doesn't exist + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'Recursos' AND column_name = 'tipo'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE Recursos ADD tipo NVARCHAR(50) NOT NULL DEFAULT 'MAQUINA'") + logger.info("Columna 'tipo' agregada a la tabla 'Recursos'.") + print("Agregada columna 'tipo' a 'Recursos'.") + + # 2. Add 'id_hm' column to Relevamientos if it doesn't exist + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'Relevamientos' AND column_name = 'id_hm'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE Relevamientos ADD id_hm INT NULL CONSTRAINT FK_Rel_HM FOREIGN KEY (id_hm) REFERENCES Recursos(id_recurso)") + logger.info("Columna 'id_hm' agregada a la tabla 'Relevamientos'.") + print("Agregada columna 'id_hm' a 'Relevamientos'.") + + # 3. Add 'piezas_por_golpe' column to Relevamientos if it doesn't exist + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'Relevamientos' AND column_name = 'piezas_por_golpe'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE Relevamientos ADD piezas_por_golpe INT NOT NULL DEFAULT 1") + logger.info("Columna 'piezas_por_golpe' agregada a la tabla 'Relevamientos'.") + print("Agregada columna 'piezas_por_golpe' a 'Relevamientos'.") + + # 4. Add 'articulos_por_golpe' column to Relevamientos if it doesn't exist + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'Relevamientos' AND column_name = 'articulos_por_golpe'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE Relevamientos ADD articulos_por_golpe INT NOT NULL DEFAULT 1") + logger.info("Columna 'articulos_por_golpe' agregada a la tabla 'Relevamientos'.") + print("Agregada columna 'articulos_por_golpe' a 'Relevamientos'.") + + # 5. Add 'operarios_maquina' column to Relevamientos if it doesn't exist + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'Relevamientos' AND column_name = 'operarios_maquina'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE Relevamientos ADD operarios_maquina INT NOT NULL DEFAULT 1") + logger.info("Columna 'operarios_maquina' agregada a la tabla 'Relevamientos'.") + print("Agregada columna 'operarios_maquina' a 'Relevamientos'.") + + # 6. Add 'id_tarea' column to Relevamientos if it doesn't exist + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'Relevamientos' AND column_name = 'id_tarea'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE Relevamientos ADD id_tarea INT NULL CONSTRAINT FK_Rel_Tarea FOREIGN KEY (id_tarea) REFERENCES Tareas(id_tarea)") + logger.info("Columna 'id_tarea' agregada a la tabla 'Relevamientos'.") + print("Agregada columna 'id_tarea' a 'Relevamientos'.") + + # 7. Add 'elementos_medidos' column to TiemposElemento if it doesn't exist + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'TiemposElemento' AND column_name = 'elementos_medidos'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE TiemposElemento ADD elementos_medidos INT NOT NULL DEFAULT 1") + logger.info("Columna 'elementos_medidos' agregada a la tabla 'TiemposElemento'.") + print("Agregada columna 'elementos_medidos' a 'TiemposElemento'.") + + # 8. Add 'lote_frecuencial' column to TiemposElemento if it doesn't exist + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'TiemposElemento' AND column_name = 'lote_frecuencial'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE TiemposElemento ADD lote_frecuencial FLOAT NOT NULL DEFAULT 1.0") + logger.info("Columna 'lote_frecuencial' agregada a la tabla 'TiemposElemento'.") + print("Agregada columna 'lote_frecuencial' a 'TiemposElemento'.") + + # 9. Add 'suplemento' column to TiemposElemento if it doesn't exist + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'TiemposElemento' AND column_name = 'suplemento'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE TiemposElemento ADD suplemento FLOAT NOT NULL DEFAULT 0.0") + logger.info("Columna 'suplemento' agregada a la tabla 'TiemposElemento'.") + print("Agregada columna 'suplemento' a 'TiemposElemento'.") + + # 10. ALTER COLUMN minutos de INT a FLOAT en TiemposElemento (BUG 1: Truncamiento SQL) + cursor.execute(""" + SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'TiemposElemento' AND COLUMN_NAME = 'minutos' + """) + col_type_row = cursor.fetchone() + if col_type_row and col_type_row[0].lower() != 'float': + cursor.execute("ALTER TABLE TiemposElemento ALTER COLUMN minutos FLOAT") + logger.info("Columna 'minutos' de TiemposElemento migrada de INT a FLOAT.") + print("Migrada columna 'minutos' a FLOAT en 'TiemposElemento'.") + + # 11. ADD columna 'codigo' a Articulos (BUG 2: Desfase de Artículo) + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'Articulos' AND column_name = 'codigo'") + if not cursor.fetchone(): + cursor.execute("ALTER TABLE Articulos ADD codigo NVARCHAR(50) NULL") + logger.info("Columna 'codigo' agregada a la tabla 'Articulos'.") + print("Agregada columna 'codigo' a 'Articulos'.") + + conn.commit() + logger.info("Migración de base de datos completada exitosamente.") + print("Migración completada exitosamente.") + return True + except Exception as e: + if conn: + conn.rollback() + logger.exception("Error crítico durante la migración de base de datos.") + print(f"Error de migración: {e}") + return False + finally: + if conn: + conn.close() + +if __name__ == "__main__": + run_migration() diff --git a/modelos.py b/modelos.py new file mode 100644 index 0000000..b4f18c0 --- /dev/null +++ b/modelos.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass, field +from datetime import date +from typing import Optional, List + +@dataclass +class Articulo: + id_articulo: str + codigo: str + descripcion: str + +@dataclass +class Recurso: + id_recurso: str + nombre: str + tipo: str # e.g., 'MAQUINA', 'HM' + +@dataclass +class Operario: + id_operario: str + nombre: str + legajo: str + +@dataclass +class TiemposElemento: + elemento: str + valoracion: float + minutos: float + segundos: float + elementos_medidos: int = 1 + lote_frecuencial: float = 1.0 + suplemento: float = 0.0 + +@dataclass +class Relevamiento: + id_relevamiento: Optional[str] = None + id_tarea: Optional[int] = None + articulo: Optional[Articulo] = None + operacion: str = "" + recurso: Optional[Recurso] = None + hm: Optional[Recurso] = None + operario: Optional[Operario] = None + fecha: date = field(default_factory=date.today) + postura: str = "pie" # pie o sentado + piezas_por_golpe: int = 1 + articulos_por_golpe: int = 1 + operarios_maquina: int = 1 + tiempos: List[TiemposElemento] = field(default_factory=list) + +@dataclass +class Informe: + id_informe: Optional[str] = None + relevamiento: Optional[Relevamiento] = None + tiempo_estandar_op: float = 0.0 + cadencia_ph: float = 0.0 + cadencia_h1000: float = 0.0 + estandar_epicor: float = 0.0 + dotacion_epicor: int = 1 + +@dataclass +class Usuario: + """ + Representa un usuario del sistema para autenticación y RBAC. + """ + id_usuario: int + username: str + password_hash: str + rol: str # "METODISTA" o "JEFE/GERENTE" + nombre: str + +@dataclass +class Tarea: + """ + Representa una tarea o solicitud de relevamiento a mostrar en el Dashboard. + """ + id_tarea: int + titulo: str + descripcion: str + estado: str # "Pendiente", "En Proceso", "Completado", "Finalizado", "Cerrado" + fecha: str + informe_url: str + id_usuario_asignado: Optional[int] = None diff --git a/seguridad.py b/seguridad.py new file mode 100644 index 0000000..b03c014 --- /dev/null +++ b/seguridad.py @@ -0,0 +1,49 @@ +import bcrypt +from logger import get_logger +from dao import AuditoriaDAO + +logger = get_logger(__name__) + +class Seguridad: + """ + Clase utilitaria para el manejo de la criptografía de contraseñas + y auditoría de la aplicación. + """ + + @staticmethod + def generar_hash(password_plano: str) -> str: + """ + Genera un hash seguro utilizando bcrypt. + """ + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password_plano.encode('utf-8'), salt) + return hashed.decode('utf-8') + + @staticmethod + def verificar_password(password_plano: str, password_hash: str) -> bool: + """ + Verifica una contraseña en texto plano contra su hash almacenado. + """ + try: + return bcrypt.checkpw(password_plano.encode('utf-8'), password_hash.encode('utf-8')) + except Exception: + logger.exception("Error al verificar hash bcrypt (posible formato inválido).") + return False + + @staticmethod + def registrar_auditoria(id_usuario: int, accion: str) -> bool: + """ + Registra una acción en la tabla LogsAuditoria. + + Args: + id_usuario: ID del usuario que realiza la acción. + accion: Descripción de la acción realizada. + + Returns: + bool: True si el registro fue exitoso. + """ + try: + return AuditoriaDAO.insertar_log(id_usuario, accion) + except Exception: + logger.exception("Fallo al registrar auditoría en la capa de seguridad.") + return False diff --git a/vistas.py b/vistas.py new file mode 100644 index 0000000..6747688 --- /dev/null +++ b/vistas.py @@ -0,0 +1,3199 @@ +import tkinter as tk +from tkinter import ttk +import datetime +import os +from tkinter import messagebox +from typing import Any, Dict, List, Tuple, Optional +import config +from logger import get_logger + +logger = get_logger(__name__) + + +def centrar_ventana(toplevel: tk.Toplevel, ancho: int, alto: int) -> None: + """Centra dinámicamente una ventana Toplevel en la pantalla del usuario. + + Calcula las coordenadas x e y necesarias para posicionar la ventana + en el centro exacto de la pantalla, basándose en la resolución actual + del monitor. + + Args: + toplevel: Instancia de tk.Toplevel a centrar. + ancho: Ancho deseado de la ventana en píxeles. + alto: Alto deseado de la ventana en píxeles. + """ + pantalla_ancho = toplevel.winfo_screenwidth() + pantalla_alto = toplevel.winfo_screenheight() + x = (pantalla_ancho - ancho) // 2 + y = (pantalla_alto - alto) // 2 + toplevel.geometry(f"{ancho}x{alto}+{x}+{y}") + + +class PaginaLogin(tk.Frame): + def __init__(self, parent: tk.Widget, controlador: Any) -> None: + super().__init__(parent, bg="white") + self.controlador = controlador + + base_dir = os.path.dirname(os.path.abspath(__file__)) + + # --- Logo Longvie: centrado arriba, tamaño grande (subsample 2) --- + try: + ruta_longvie = os.path.join(base_dir, "longvie.png") + self._img_longvie = tk.PhotoImage(file=ruta_longvie).subsample(2, 2) + tk.Label(self, image=self._img_longvie, bg="white").pack(side="top", pady=(40, 20)) + except Exception: + logger.warning("No se pudo cargar el logo 'longvie.png'. Verifique que el archivo exista en %s.", base_dir) + + # --- Frame central de login (tema oscuro original conservado) --- + frame_login = tk.Frame(self, bg="#34495e", padx=40, pady=40, bd=2, relief="groove") + frame_login.pack(expand=True) + + # Registrar comandos de validación para los campos de login + vcmd_user = (self.register(self.validar_usuario), '%P') + vcmd_pass = (self.register(self.validar_password), '%P') + + tk.Label(frame_login, text="Inicie Sesión", font=("Arial", 16, "bold"), bg="#34495e", fg="white").pack(pady=(0, 20)) + tk.Label(frame_login, text="Usuario", font=("Arial", 12), bg="#34495e", fg="white", anchor="w").pack(fill="x") + self.entry_user = tk.Entry(frame_login, font=("Arial", 12), + validate="key", validatecommand=vcmd_user) + self.entry_user.pack(fill="x", pady=(0, 15)) + self.entry_user.bind("", lambda e: self.intentar_login()) + + tk.Label(frame_login, text="Contraseña", font=("Arial", 12), bg="#34495e", fg="white", anchor="w").pack(fill="x") + self.entry_pass = tk.Entry(frame_login, font=("Arial", 12), show="*", + validate="key", validatecommand=vcmd_pass) + self.entry_pass.pack(fill="x", pady=(0, 20)) + self.entry_pass.bind("", lambda e: self.intentar_login()) + tk.Button(frame_login, text="Ingresar", bg="#34495e", fg="white", font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", command=self.intentar_login).pack(fill="x", pady=(0, 10)) + tk.Button(frame_login, text="Crear usuario", bg="#34495e", fg="white", font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", command=lambda: self.controlador.root.mostrar_frame(PaginaRegistroUsuario)).pack(fill="x") + + # --- Enlace discreto de recuperación de credenciales --- + tk.Button( + frame_login, text="¿Olvidó su usuario/contraseña?", + bg="#34495e", fg="#f1c40f", activeforeground="#f39c12", + activebackground="#34495e", font=("Arial", 9, "underline"), + relief="flat", bd=0, cursor="hand2", + command=self.abrir_modal_recuperacion + ).pack(pady=(15, 0)) + + # --- Logo MT: esquina inferior derecha, posicionado con .place() --- + try: + ruta_mt = os.path.join(base_dir, "mt.png") + self._img_mt = tk.PhotoImage(file=ruta_mt).subsample(4, 4) + tk.Label(self, image=self._img_mt, bg="white").place(relx=1.0, rely=1.0, anchor="se", x=-20, y=-20) + except Exception: + logger.warning("No se pudo cargar el logo 'mt.png'. Verifique que el archivo exista en %s.", base_dir) + + # --- Botón "Leer Manual de Usuario" en la esquina inferior izquierda --- + tk.Button( + self, text="Leer Manual de Usuario", + font=("Arial", 9), fg="#2980b9", bg="white", + bd=0, cursor="hand2", relief="flat", + command=lambda: self.controlador.root.mostrar_frame(PaginaManual) + ).place(relx=0.0, rely=1.0, anchor="sw", x=20, y=-20) + + def validar_usuario(self, P: str) -> bool: + """Valida que el usuario contenga solo caracteres alfanuméricos, máximo 20. + + Permite cadena vacía (campo borrado). Rechaza espacios, signos + de puntuación y cualquier carácter especial. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si el valor cumple las restricciones, False en caso contrario. + """ + if P == "": + return True + return len(P) <= 20 and P.isalnum() + + def validar_password(self, P: str) -> bool: + """Valida que la contraseña contenga solo caracteres alfanuméricos, máximo 20. + + Permite cadena vacía (campo borrado). Rechaza caracteres especiales + como signos de puntuación, símbolos y espacios. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si todos los caracteres son alfanuméricos, False en caso contrario. + """ + if P == "": + return True + return len(P) <= 20 and P.isalnum() + + def intentar_login(self) -> None: + """Intenta autenticar al usuario con las credenciales ingresadas.""" + if not self.controlador.verificar_credenciales(self.entry_user.get(), self.entry_pass.get()): + messagebox.showerror("Error", "Credenciales incorrectas.\n\nSi olvidó su usuario o contraseña contacte con el administrador del sistema.") + + def validar_nombre_completo(self, P: str) -> bool: + """Valida que el nombre contenga solo letras y espacios, máximo 30 caracteres. + + Permite cadena vacía (campo borrado). Rechaza números, signos de + puntuación y cualquier carácter especial. Los espacios son permitidos + para nombres compuestos (ej. 'Juan Carlos López'). + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si el valor cumple las restricciones, False en caso contrario. + """ + if P == "": + return True + return len(P) <= 30 and all(c.isalpha() or c == " " for c in P) + + def validar_alfanumerico_30(self, P: str) -> bool: + """Valida que el texto contenga solo caracteres alfanuméricos, máximo 30. + + Permite cadena vacía (campo borrado). Rechaza espacios, signos de + puntuación y cualquier carácter especial. Diseñado para los campos + de contraseña y clave maestra del modal de recuperación. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si todos los caracteres son alfanuméricos, False en caso contrario. + """ + if P == "": + return True + return len(P) <= 30 and P.isalnum() + + def abrir_modal_recuperacion(self) -> None: + """ + Abre una ventana modal para la recuperación/blanqueo de credenciales. + + Presenta un formulario con tres campos: Nombre Completo, Nueva Contraseña + y Clave Maestra de Autorización. Al confirmar, delega la lógica de + validación y actualización al método ``controlador.blanquear_credenciales``. + + La ventana es modal (grab_set + transient) para bloquear la interacción + con la ventana principal hasta que se cierre. + """ + modal = tk.Toplevel(self) + modal.title("Recuperación de Credenciales") + modal.configure(bg="#2c3e50") + modal.resizable(False, False) + centrar_ventana(modal, 350, 350) + modal.transient(self.winfo_toplevel()) + modal.grab_set() + + # --- Registrar comandos de validación para los campos del modal --- + vcmd_nombre = (self.register(self.validar_nombre_completo), '%P') + vcmd_alnum30 = (self.register(self.validar_alfanumerico_30), '%P') + + # --- Título del modal --- + tk.Label( + modal, text="Recuperar Usuario / Contraseña", + font=("Arial", 13, "bold"), bg="#2c3e50", fg="white" + ).pack(pady=(20, 15)) + + # --- Campo: Nombre Completo (letras y espacios, máx 30) --- + tk.Label( + modal, text="Su Nombre Completo", + font=("Arial", 10), bg="#2c3e50", fg="white", anchor="w" + ).pack(fill="x", padx=30) + entry_nombre = tk.Entry( + modal, font=("Arial", 11), + validate="key", validatecommand=vcmd_nombre + ) + entry_nombre.pack(fill="x", padx=30, pady=(0, 10)) + + # --- Campo: Nueva Contraseña (alfanumérico, máx 30) --- + tk.Label( + modal, text="Nueva Contraseña", + font=("Arial", 10), bg="#2c3e50", fg="white", anchor="w" + ).pack(fill="x", padx=30) + entry_nueva_pwd = tk.Entry( + modal, font=("Arial", 11), show="*", + validate="key", validatecommand=vcmd_alnum30 + ) + entry_nueva_pwd.pack(fill="x", padx=30, pady=(0, 10)) + + # --- Campo: Clave Maestra (alfanumérico, máx 30, resaltado visual) --- + tk.Label( + modal, text="Clave Maestra de Autorización", + font=("Arial", 10, "bold"), bg="#2c3e50", fg="#f1c40f", anchor="w" + ).pack(fill="x", padx=30) + entry_clave_maestra = tk.Entry( + modal, font=("Arial", 11), show="*", bg="#ffeaa7", + validate="key", validatecommand=vcmd_alnum30 + ) + entry_clave_maestra.pack(fill="x", padx=30, pady=(0, 20)) + + def ejecutar_blanqueo() -> None: + """Extrae los campos del formulario y delega al controlador.""" + nombre: str = entry_nombre.get().strip() + nueva_pwd: str = entry_nueva_pwd.get().strip() + clave: str = entry_clave_maestra.get().strip() + + if not nombre or not nueva_pwd or not clave: + messagebox.showwarning( + "Campos Incompletos", + "Debe completar todos los campos para continuar.", + parent=modal + ) + return + + exito: bool = self.controlador.blanquear_credenciales( + nombre, clave, nueva_pwd + ) + if exito: + modal.destroy() + + # --- Botón de acción --- + tk.Button( + modal, text="Restablecer Credenciales", + font=("Arial", 10, "bold"), bg="#27ae60", fg="white", + activebackground="#2ecc71", relief="groove", bd=2, cursor="hand2", + command=ejecutar_blanqueo + ).pack(fill="x", padx=30, pady=(0, 10)) + + # --- Botón cancelar --- + tk.Button( + modal, text="Cancelar", + font=("Arial", 9), bg="#7f8c8d", fg="white", + relief="flat", bd=0, cursor="hand2", + command=modal.destroy + ).pack(pady=(0, 15)) + +class PaginaTareas(tk.Frame): + def __init__(self, parent: tk.Widget, controlador: Any) -> None: + super().__init__(parent, bg="#ecf0f1") + self.controlador = controlador + + self.notebook = ttk.Notebook(self) + self.notebook.pack(fill="both", expand=True, padx=10, pady=10) + + self.tab_inicio = tk.Frame(self.notebook, bg="white") + self.notebook.add(self.tab_inicio, text="Inicio") + + # --- Header: saludo a la izquierda, botón Buscar a la derecha --- + header_inicio = tk.Frame(self.tab_inicio, bg="white", bd=1, relief="groove") + header_inicio.pack(side="top", fill="x", padx=10, pady=(10, 5)) + + self.lbl_bienvenida = tk.Label( + header_inicio, text="Bienvenido", + font=("Arial", 20, "bold"), bg="white", fg="#2c3e50" + ) + self.lbl_bienvenida.pack(side="left", padx=15, pady=10) + + btn_buscar = tk.Button( + header_inicio, text="Buscar", + font=("Arial", 10, "bold"), relief="groove", bd=2, + bg="#34495e", fg="white", cursor="hand2", + command=lambda: self.controlador.root.mostrar_frame(PaginaBuscadorHistorico) + ) + btn_buscar.pack(side="right", padx=15, pady=10) + + # --- Subtítulo --- + tk.Label( + self.tab_inicio, text="Últimos trabajos consultados", + font=("Arial", 11, "bold"), bg="white", fg="#34495e", anchor="w" + ).pack(side="top", fill="x", padx=15, pady=(5, 2)) + + # --- Treeview de trabajos recientes --- + frame_tree_recientes = tk.Frame(self.tab_inicio, bg="white") + frame_tree_recientes.pack(side="top", fill="both", expand=True, padx=10, pady=(0, 10)) + + cols_recientes = ("ID", "Fecha", "Artículo", "Operación") + self.tree_recientes = ttk.Treeview( + frame_tree_recientes, columns=cols_recientes, show="headings", height=10 + ) + for col in cols_recientes: + self.tree_recientes.heading(col, text=col) + self.tree_recientes.column("ID", width=60, anchor="center") + self.tree_recientes.column("Fecha", width=100, anchor="center") + self.tree_recientes.column("Artículo", width=250, anchor="center") + self.tree_recientes.column("Operación", width=250, anchor="w") + self.tree_recientes["displaycolumns"] = ("Fecha", "Artículo", "Operación") + + scroll_recientes = ttk.Scrollbar( + frame_tree_recientes, orient="vertical", + command=self.tree_recientes.yview + ) + self.tree_recientes.configure(yscrollcommand=scroll_recientes.set) + self.tree_recientes.pack(side="left", fill="both", expand=True) + scroll_recientes.pack(side="right", fill="y") + + self.tree_recientes.bind("", self.on_double_click_recientes) + + self.tab_tareas = tk.Frame(self.notebook, bg="white") + self.notebook.add(self.tab_tareas, text="Tareas") + + self.construir_ui_tareas() + # El bind de se eliminó porque tkraise() no lo dispara fiablemente. + # En su lugar, main.py llama explícitamente a actualizar_vista() + + def actualizar_vista(self) -> None: + usuario = self.controlador.usuario_actual + if usuario: + self.lbl_bienvenida.config(text=f"Bienvenido {usuario.nombre}") + self.aplicar_tema(usuario.rol) + self.configurar_rbac() + self.cargar_datos() + self._poblar_recientes() + + def aplicar_tema(self, rol: str) -> None: + """Aplica tematización dinámica de colores según el rol del usuario. + + Define un esquema oscuro para JEFE/GERENTE (#2c3e50 con texto blanco) + y un esquema claro para METODISTA (#ecf0f1 con texto oscuro). Recorre + recursivamente todos los widgets hijos usando winfo_children() y aplica + los colores solo a tk.Frame y tk.Label, ignorando explícitamente + tk.Button, tk.Entry y todos los widgets ttk para preservar su estilo. + + Args: + rol: Rol del usuario actual ('JEFE/GERENTE' o 'METODISTA'). + """ + if rol == "JEFE/GERENTE": + bg_color = "#2c3e50" + fg_color = "white" + else: + bg_color = "#ecf0f1" + fg_color = "#2c3e50" + + def _aplicar_recursivo(widget: tk.Widget) -> None: + """Recorre recursivamente los hijos del widget y aplica colores. + + Ignora tk.Button, tk.Entry y cualquier widget de la librería ttk + para no arruinar botones, campos de texto ni componentes temáticos. + + Args: + widget: Widget raíz desde el cual iniciar el recorrido. + """ + for hijo in widget.winfo_children(): + if isinstance(hijo, (tk.Button, tk.Entry)): + _aplicar_recursivo(hijo) + continue + if isinstance(hijo, ttk.Widget): + _aplicar_recursivo(hijo) + continue + try: + hijo.configure(bg=bg_color) + if isinstance(hijo, tk.Label): + hijo.configure(fg=fg_color) + except tk.TclError: + pass + _aplicar_recursivo(hijo) + + _aplicar_recursivo(self) + + def _poblar_recientes(self) -> None: + """Limpia y puebla el Treeview de últimos trabajos consultados. + + Obtiene el resumen completo de relevamientos desde el controlador + y muestra solo los 10 más recientes (ya vienen ordenados DESC desde el DAO). + """ + for item in self.tree_recientes.get_children(): + self.tree_recientes.delete(item) + try: + resumen = self.controlador.obtener_resumen_relevamientos() + for fila in resumen[:5]: + # fila = (ID, Fecha, Artículo_codigo, Operación, Operario) + self.tree_recientes.insert( + "", "end", iid=str(fila[0]), + values=(fila[0], fila[1], fila[2], fila[3]) + ) + except Exception: + logger.exception("Error al poblar la tabla de trabajos recientes.") + + def construir_ui_tareas(self) -> None: + self.tab_tareas.columnconfigure(0, weight=1) + self.tab_tareas.rowconfigure(0, weight=1) + + columnas = ("Titulo", "Descripcion", "Estado", "Fecha", "Informe") + self.tree = ttk.Treeview(self.tab_tareas, columns=columnas, show="headings") + for col in columnas: + self.tree.heading(col, text=col) + self.tree.grid(row=0, column=0, sticky="nsew", padx=10, pady=10) + self.tree.column("Estado", anchor="center") + self.tree.column("Fecha", anchor="center") + self.tree["displaycolumns"] = ("Titulo", "Descripcion", "Estado", "Fecha") + + scroll = ttk.Scrollbar(self.tab_tareas, orient="vertical", command=self.tree.yview) + scroll.grid(row=0, column=1, sticky="ns", pady=10) + self.tree.configure(yscrollcommand=scroll.set) + + self.tree.bind("<>", self.on_tree_select) + self.tree.bind("", self.on_double_click) + + self.tooltip_win = None + self.celda_actual_tooltip = ("", "") + self.tree.bind("", self.manejar_tooltip_tabla) + self.tree.bind("", self.ocultar_tooltip_tabla) + + self.panel_botones = tk.Frame(self.tab_tareas, bg="white") + self.panel_botones.grid(row=1, column=0, columnspan=2, pady=(0, 10), sticky="ew") + + # Estilo unificado de oficina para botones de acción + _btn_style = {"font": ("Arial", 10, "bold"), "relief": "groove", "bd": 2, "cursor": "hand2", "fg": "white", "bg": "#34495e"} + + # Conexión MVC al nuevo modal de creación de tareas + self.btn_crear = tk.Button(self.panel_botones, text="Cargar nueva tarea", command=self.abrir_modal_nueva_tarea, **_btn_style) + self.btn_crear.pack(side="left", padx=10) + + self.btn_estado = tk.Button(self.panel_botones, text="Cambiar Estado", state="disabled", command=self.cambiar_estado, **_btn_style) + self.btn_estado.pack(side="left", padx=10) + + self.btn_cronometraje = tk.Button(self.panel_botones, text="Realizar Cronometraje", state="disabled", command=self.ejecutar_cronometraje_tarea, **_btn_style) + + self.btn_borrar = tk.Button(self.panel_botones, text="Borrar tarea", state="disabled", command=self.accion_borrar_tarea, **_btn_style) + self.btn_borrar.pack(side="left", padx=10) + + def ocultar_tooltip_tabla(self, event=None) -> None: + if self.tooltip_win: + self.tooltip_win.destroy() + self.tooltip_win = None + self.celda_actual_tooltip = ("", "") + + def manejar_tooltip_tabla(self, event) -> None: + item = self.tree.identify_row(event.y) + col = self.tree.identify_column(event.x) + + if not item: + self.ocultar_tooltip_tabla() + return + + if col not in ("#1", "#2"): + self.ocultar_tooltip_tabla() + return + + valores = self.tree.item(item, 'values') + idx = 0 if col == "#1" else 1 + texto = str(valores[idx]) + + if not texto: + self.ocultar_tooltip_tabla() + return + + if (item, col) == self.celda_actual_tooltip: + return + + self.ocultar_tooltip_tabla() + self.celda_actual_tooltip = (item, col) + + self.tooltip_win = tk.Toplevel(self) + self.tooltip_win.wm_overrideredirect(True) + self.tooltip_win.wm_geometry(f"+{event.x_root + 15}+{event.y_root + 10}") + + lbl = tk.Label(self.tooltip_win, text=texto, justify="left", background="#ffffe0", relief="solid", borderwidth=1, wraplength=300) + lbl.pack() + + def configurar_rbac(self) -> None: + usuario = self.controlador.usuario_actual + if not usuario: return + + if usuario.rol == "METODISTA": + self.btn_crear.pack_forget() + self.btn_borrar.pack_forget() + self.btn_cronometraje.pack(side="left", padx=10) + else: + self.btn_cronometraje.pack_forget() + self.btn_crear.pack(side="left", padx=10) + self.btn_borrar.pack(side="left", padx=10) + + def cargar_datos(self) -> None: + for item in self.tree.get_children(): + self.tree.delete(item) + try: + for t in self.controlador.obtener_tareas_dashboard(): + self.tree.insert("", "end", iid=str(t.id_tarea), values=(t.titulo, t.descripcion, t.estado, t.fecha, t.informe_url)) + except Exception: + logger.exception("Error al cargar datos en la tabla.") + + def on_tree_select(self, event: Any) -> None: + seleccion = self.tree.selection() + if seleccion: + self.btn_estado.config(state="normal") + usuario = self.controlador.usuario_actual + if usuario and usuario.rol == "JEFE/GERENTE": + self.btn_borrar.config(state="normal") + elif usuario and usuario.rol == "METODISTA": + self.btn_cronometraje.config(state="normal") + else: + self.btn_estado.config(state="disabled") + self.btn_borrar.config(state="disabled") + if hasattr(self, 'btn_cronometraje'): + self.btn_cronometraje.config(state="disabled") + + def on_double_click(self, event: Any) -> None: + seleccion = self.tree.selection() + if seleccion: + try: + id_tarea = int(seleccion[0]) + self.controlador.abrir_relevamiento_desde_tarea(id_tarea) + except Exception: + logger.exception("Error al capturar el doble clic en Treeview de tareas.") + + def on_double_click_recientes(self, event: Any) -> None: + """Captura el doble clic en la tabla de trabajos recientes y abre el relevamiento. + + Extrae el ID del relevamiento desde el iid de la fila seleccionada + y delega la carga al controlador vía abrir_relevamiento_desde_id. + + Args: + event: Evento de Tkinter generado por el doble clic. + """ + seleccion = self.tree_recientes.selection() + if seleccion: + try: + id_rel = int(seleccion[0]) + self.controlador.abrir_relevamiento_desde_id(id_rel) + except Exception: + logger.exception("Error al abrir relevamiento desde la tabla de recientes.") + + def ejecutar_cronometraje_tarea(self) -> None: + seleccion = self.tree.selection() + if seleccion: + try: + id_tarea = int(seleccion[0]) + self.controlador.abrir_relevamiento_desde_tarea(id_tarea) + except Exception: + logger.exception("Error al abrir cronometraje desde el botón.") + + def cambiar_estado(self) -> None: + seleccion = self.tree.selection() + if not seleccion: return + try: + id_tarea = int(seleccion[0]) + tarea_val = self.tree.item(id_tarea, "values") + estado_actual = tarea_val[2] + + usuario = self.controlador.usuario_actual + if not usuario: return + + # Bloqueo de Seguridad para Metodistas + if usuario.rol == "METODISTA" and estado_actual == "Cerrado": + messagebox.showwarning("Acceso Denegado", "No tienes permisos para modificar una tarea cerrada.") + return + + opciones = self.controlador.obtener_opciones_estado() + + modal = tk.Toplevel(self) + modal.title("Cambiar Estado") + centrar_ventana(modal, 300, 200) + modal.grab_set() + + tk.Label(modal, text=f"Estado actual: {estado_actual}", font=("Arial", 10)).pack(pady=(15, 5)) + + tk.Label(modal, text="Nuevo Estado:", font=("Arial", 10, "bold")).pack(pady=(10, 0)) + combo_estado = ttk.Combobox(modal, values=opciones, state="readonly", font=("Arial", 10)) + combo_estado.pack(pady=5) + if estado_actual in opciones: + combo_estado.set(estado_actual) + elif opciones: + combo_estado.set(opciones[0]) + + def guardar(): + nuevo_estado = combo_estado.get() + if not nuevo_estado: + messagebox.showerror("Error", "Debe seleccionar un estado.", parent=modal) + return + + if nuevo_estado == estado_actual: + modal.destroy() + return + + if self.controlador.cambiar_estado_tarea(id_tarea, nuevo_estado): + self.cargar_datos() + self.btn_estado.config(state="disabled") + self.btn_borrar.config(state="disabled") + if hasattr(self, 'btn_cronometraje'): + self.btn_cronometraje.config(state="disabled") + modal.destroy() + else: + messagebox.showerror("Error", "Fallo al actualizar el estado.", parent=modal) + + tk.Button(modal, text="Guardar", bg="#34495e", fg="white", font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", command=guardar).pack(pady=15) + + except Exception: + logger.exception("Fallo al cambiar estado.") + + def abrir_modal_nueva_tarea(self) -> None: + modal = tk.Toplevel(self) + modal.title("Nueva Tarea") + centrar_ventana(modal, 400, 380) + modal.grab_set() + + tk.Label(modal, text="Título de la Tarea:", font=("Arial", 10, "bold")).pack(pady=(10,0), anchor="w", padx=20) + vcmd_titulo = (modal.register(lambda P: len(P) <= 50), '%P') + entry_titulo = tk.Entry(modal, font=("Arial", 11), validate="key", validatecommand=vcmd_titulo) + entry_titulo.pack(fill="x", padx=20, pady=5) + + tk.Label(modal, text="Descripción:", font=("Arial", 10, "bold")).pack(pady=(10,0), anchor="w", padx=20) + text_desc = tk.Text(modal, font=("Arial", 10), height=4) + text_desc.pack(fill="x", padx=20, pady=5) + + def limitar_texto_desc(event): + if event.keysym in ('BackSpace', 'Delete', 'Left', 'Right', 'Up', 'Down', 'Tab'): + return + if event.char and len(text_desc.get("1.0", "end-1c")) >= 100: + return "break" + text_desc.bind("", limitar_texto_desc) + + tk.Label(modal, text="Metodista Asignado:", font=("Arial", 10, "bold")).pack(pady=(10,0), anchor="w", padx=20) + metodistas = self.controlador.obtener_metodistas() + nombres = [f"{m.id_usuario} - {m.nombre}" for m in metodistas] + combo_metodista = ttk.Combobox(modal, values=nombres, state="readonly", font=("Arial", 10)) + combo_metodista.pack(fill="x", padx=20, pady=5) + if nombres: + combo_metodista.set(nombres[0]) + + def guardar(): + tit = entry_titulo.get().strip() + desc = text_desc.get("1.0", tk.END).strip() + sel = combo_metodista.get() + if not tit or not desc or not sel: + messagebox.showerror("Error", "Todos los campos son requeridos.", parent=modal) + return + id_asig = int(sel.split(" - ")[0]) + if self.controlador.crear_tarea(tit, desc, id_asig): + messagebox.showinfo("Éxito", "Tarea creada correctamente.", parent=modal) + self.cargar_datos() + modal.destroy() + else: + messagebox.showerror("Error", "Fallo al crear tarea.", parent=modal) + + tk.Button(modal, text="Guardar Tarea", bg="#34495e", fg="white", font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", command=guardar).pack(pady=20) + + def accion_borrar_tarea(self) -> None: + seleccion = self.tree.selection() + if not seleccion: return + id_tarea = int(seleccion[0]) + if messagebox.askyesno("Confirmar", f"¿Desea borrar permanentemente la tarea ID {id_tarea}?"): + if self.controlador.borrar_tarea(id_tarea): + self.cargar_datos() + self.btn_estado.config(state="disabled") + self.btn_borrar.config(state="disabled") + if hasattr(self, 'btn_cronometraje'): + self.btn_cronometraje.config(state="disabled") + else: + messagebox.showerror("Error", "No se pudo borrar la tarea.") + +class PaginaRegistroUsuario(tk.Frame): + def __init__(self, parent: tk.Widget, controlador: Any) -> None: + super().__init__(parent, bg="#ecf0f1") + self.controlador = controlador + + frame_form = tk.Frame(self, bg="white", padx=40, pady=40, bd=1, relief="solid") + frame_form.pack(expand=True) + + # Registrar comandos de validación para los campos del formulario + vcmd_nom = (self.register(self.validar_nombre), '%P') + vcmd_user = (self.register(self.validar_usuario), '%P') + vcmd_pass = (self.register(self.validar_password), '%P') + + tk.Label(frame_form, text="Registro de Usuarios", font=("Arial", 16, "bold"), bg="white", fg="#2c3e50").pack(pady=(0, 20)) + + # Nombre Completo + tk.Label(frame_form, text="Nombre Completo", font=("Arial", 10, "bold"), bg="white", anchor="w").pack(fill="x") + self.entry_nombre = tk.Entry(frame_form, font=("Arial", 12), + validate="key", validatecommand=vcmd_nom) + self.entry_nombre.pack(fill="x", pady=(0, 10)) + self.entry_nombre.bind("", lambda e: self.intentar_registro()) + + # Username + tk.Label(frame_form, text="Nombre de Usuario (Username)", font=("Arial", 10, "bold"), bg="white", anchor="w").pack(fill="x") + self.entry_user = tk.Entry(frame_form, font=("Arial", 12), + validate="key", validatecommand=vcmd_user) + self.entry_user.pack(fill="x", pady=(0, 10)) + self.entry_user.bind("", lambda e: self.intentar_registro()) + + # Rol + tk.Label(frame_form, text="Rol del Sistema", font=("Arial", 10, "bold"), bg="white", anchor="w").pack(fill="x") + self.combo_rol = ttk.Combobox(frame_form, values=["METODISTA", "JEFE/GERENTE"], state="readonly", font=("Arial", 11)) + self.combo_rol.set("METODISTA") + self.combo_rol.pack(fill="x", pady=(0, 10)) + self.combo_rol.bind("", lambda e: self.intentar_registro()) + + # Contraseña + tk.Label(frame_form, text="Contraseña", font=("Arial", 10, "bold"), bg="white", anchor="w").pack(fill="x") + self.entry_pass = tk.Entry(frame_form, font=("Arial", 12), show="*", + validate="key", validatecommand=vcmd_pass) + self.entry_pass.pack(fill="x", pady=(0, 20)) + self.entry_pass.bind("", lambda e: self.intentar_registro()) + + # Botones + tk.Button(frame_form, text="Crear Usuario", bg="#34495e", fg="white", font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", command=self.intentar_registro).pack(fill="x", pady=(0, 10)) + tk.Button(frame_form, text="Volver al Login", bg="#34495e", fg="white", font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", command=lambda: self.controlador.root.mostrar_frame(PaginaLogin)).pack(fill="x") + + def validar_nombre(self, P: str) -> bool: + """Valida que el nombre contenga solo letras y espacios, máximo 30 caracteres. + + Permite cadena vacía (campo borrado). Rechaza dígitos, signos + y cualquier carácter que no sea alfabético ni espacio. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si el valor cumple las restricciones, False en caso contrario. + """ + if P == "": + return True + return len(P) <= 30 and all(c.isalpha() or c.isspace() for c in P) + + def validar_usuario(self, P: str) -> bool: + """Valida que el usuario contenga solo caracteres alfanuméricos, máximo 20. + + Permite cadena vacía (campo borrado). Rechaza espacios, signos + de puntuación y cualquier carácter especial. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si el valor cumple las restricciones, False en caso contrario. + """ + if P == "": + return True + return len(P) <= 20 and P.isalnum() + + def validar_password(self, P: str) -> bool: + """Valida que la contraseña contenga solo caracteres alfanuméricos, máximo 20. + + Permite cadena vacía (campo borrado). Rechaza caracteres especiales + como signos de puntuación, símbolos y espacios. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si todos los caracteres son alfanuméricos, False en caso contrario. + """ + if P == "": + return True + return len(P) <= 20 and P.isalnum() + + def intentar_registro(self) -> None: + nombre = self.entry_nombre.get().strip() + user = self.entry_user.get().strip() + rol = self.combo_rol.get().strip() + pwd = self.entry_pass.get().strip() + + if not nombre or not user or not rol or not pwd: + messagebox.showerror("Error", "Todos los campos son obligatorios.") + return + + self.controlador.registrar_nuevo_usuario(nombre, user, rol, pwd) + + def limpiar_form(self) -> None: + self.entry_nombre.delete(0, tk.END) + self.entry_user.delete(0, tk.END) + self.entry_pass.delete(0, tk.END) + self.combo_rol.set("METODISTA") + + def actualizar_vista(self) -> None: + self.limpiar_form() + +class Plantilla(tk.Frame): + def __init__(self, parent, controlador): + super().__init__(parent, bg="white") + + self.controlador = controlador + + # --- VARIABLES DE CONTROL --- + self.num_elementos: int = 0 + self.nombres_elementos: List[tk.Entry] = [] + self.entradas_tabla: List[List[tk.Entry]] = [] + self.matriz_bloques: List[List[List[tk.Entry]]] = [] + self.vars_conteo: List[tk.StringVar] = [] + + self.unidades_salida: List[ttk.Combobox] = [] + self.unidades_entrada: List[ttk.Combobox] = [] + + self.max_filas: int = 0 + self.widgets_resultados_columnas: List[List[tk.Widget]] = [] + + self.guardado_recientemente = False + self.id_tarea_actual = None + self.id_relevamiento_actual = None + + self.var_de_pie = tk.BooleanVar() + self.var_sentado = tk.BooleanVar() + self.var_maquina = tk.StringVar() + self.var_hm = tk.StringVar() + self.var_operario = tk.StringVar() + self.var_fecha = tk.StringVar(value=datetime.date.today().strftime("%d/%m/%Y")) + self.var_id_crono = tk.StringVar(value="AUTO-001") + + # Variables para el Footer + self.var_piezas_golpe = tk.StringVar(value="1") + self.var_articulos_golpe = tk.StringVar(value="1") + self.var_operarios_maq = tk.StringVar(value="1") + self.var_tiempo_estandar_op = tk.StringVar() + self.var_cadencia_ph = tk.StringVar() + self.var_cadencia_h1000 = tk.StringVar() + self.var_estandar_epicor = tk.StringVar() + self.var_dotacion_epicor = tk.StringVar() + + # Traces para recálculo automático al cambiar variables en el footer + self.var_piezas_golpe.trace_add("write", lambda *args: self.recalcular_todo()) + self.var_articulos_golpe.trace_add("write", lambda *args: self.recalcular_todo()) + self.var_operarios_maq.trace_add("write", lambda *args: self.recalcular_todo()) + self.var_dotacion_epicor.trace_add("write", lambda *args: self.recalcular_todo()) + + # --- DISEÑO DE LA INTERFAZ --- + btn_container = tk.Frame(self, bg="white") + btn_container.pack(side="top", fill="x", padx=10) + + # Botón Abrir removido (ahora está en el menú global nativo) + + tk.Button(btn_container, text="Agregar nueva columna", bg="#34495e", fg="white", + font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", command=self.ventana_datos_columna).pack(side="right", pady=5) + + self.header_frame = tk.Frame(self, bg="white", highlightbackground="black", highlightthickness=1) + self.header_frame.pack(side="top", fill="x", padx=10, pady=5) + self.setup_header_widgets() + + # Empaquetamos PRIMERO los elementos inferiores con side="bottom" para anclarlos. + # Botón Finalizar anclado a la derecha abajo + tk.Button(self, text="Finalizar", bg="#34495e", fg="white", + font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", command=self.ventana_finalizar).pack(side="bottom", anchor="e", padx=20, pady=10) + + # Footer Frame anclado abajo (encima del botón) + self.footer_frame = tk.Frame(self, bg="white") + self.footer_frame.pack(side="bottom", fill="x", padx=10, pady=(15, 0)) + self.setup_footer_widgets() + + self.middle_frame = tk.Frame(self, bg="white") + self.middle_frame.pack(side="top", fill="both", expand=True, padx=10) + + # Sincronización Estructural: Grid Maestro para alinear filas + self.middle_frame.columnconfigure(0, weight=0) # Izquierda fija + self.middle_frame.columnconfigure(1, weight=1) # Derecha dinámica + self.middle_frame.rowconfigure(0, weight=1) # Área Superior (Carga) + self.middle_frame.rowconfigure(1, weight=0) # Área Inferior (Resultados) + self.middle_frame.rowconfigure(2, weight=0) # Scrollbar Horizontal + self.setup_left_column_widgets() + + self.setup_scrollable_area() + + def setup_header_widgets(self): + self.header_frame.columnconfigure(0, weight=0, minsize=150) + self.header_frame.columnconfigure(1, weight=0, minsize=120) + self.header_frame.columnconfigure(2, weight=1) + + # Registrar comandos de validación para los campos del header + vcmd_6 = (self.register(self.validar_max_6), '%P') + vcmd_100 = (self.register(self.validar_max_100), '%P') + + tk.Label(self.header_frame, text="ARTÍCULO", font=("Arial", 12, "bold"), borderwidth=1, relief="solid").grid(row=0, column=0, rowspan=2, sticky="nsew") + tk.Label(self.header_frame, text="OPERACIÓN:", font=("Arial", 10), anchor="e", padx=5, borderwidth=1, relief="solid").grid(row=0, column=1, sticky="nsew") + + frame_op = tk.Frame(self.header_frame, bg="white") + frame_op.grid(row=0, column=2, sticky="nsew") + self.combo_operacion = ttk.Combobox(frame_op, font=("Arial", 10)) + self.combo_operacion.pack(side="left", fill="both", expand=True) + btn_add_op = tk.Button( + frame_op, + text="+", + font=("Arial", 8, "bold"), + bg="#2c3e50", + fg="white", + bd=1, + relief="solid", + width=2, + command=lambda: self.abrir_modal_crear_recurso("OPERACION") + ) + btn_add_op.pack(side="right", padx=(2, 0)) + btn_del_op = tk.Button( + frame_op, + text="-", + font=("Arial", 8, "bold"), + bg="#e74c3c", + fg="white", + bd=1, + relief="solid", + width=2, + command=lambda: self.accion_borrar_recurso("OPERACION") + ) + btn_del_op.pack(side="right", padx=(2, 0)) + + + tk.Label(self.header_frame, text="DESCRIPCION:", font=("Arial", 10), anchor="e", padx=5, borderwidth=1, relief="solid").grid(row=1, column=1, sticky="nsew") + + self.entry_descripcion_articulo = tk.Entry(self.header_frame, borderwidth=1, relief="solid", font=("Arial", 10), + validate="key", validatecommand=vcmd_100) + self.entry_descripcion_articulo.grid(row=1, column=2, sticky="nsew") + + self.entry_articulo_codigo = tk.Entry(self.header_frame, borderwidth=1, relief="solid", justify="center", bg="#FFF2CC", font=("Arial", 10, "bold"), + validate="key", validatecommand=vcmd_6) + self.entry_articulo_codigo.grid(row=2, column=0, sticky="nsew") + + def setup_left_column_widgets(self): + f_top = tk.Frame(self.middle_frame, bg="white") + f_top.grid(row=0, column=0, sticky="nsew", pady=(10, 0)) + + tk.Label(f_top, text="DE PIE", width=12, anchor="w", font=("Arial", 9, "bold"), padx=5, borderwidth=1, relief="solid").grid(row=0, column=0, sticky="nsew") + tk.Checkbutton(f_top, variable=self.var_de_pie, bg="white", borderwidth=1, relief="solid", command=lambda: self.exclusividad_postura("pie")).grid(row=0, column=1, sticky="nsew") + tk.Label(f_top, text="SENTADO", width=12, anchor="w", font=("Arial", 9, "bold"), padx=5, borderwidth=1, relief="solid").grid(row=1, column=0, sticky="nsew") + tk.Checkbutton(f_top, variable=self.var_sentado, bg="white", borderwidth=1, relief="solid", command=lambda: self.exclusividad_postura("sentado")).grid(row=1, column=1, sticky="nsew") + + labels = [("MÁQUINA", self.var_maquina), ("HM", self.var_hm), ("OPERARIO", self.var_operario), + ("FECHA", self.var_fecha), ("Nº de CRONOMETRAJE", self.var_id_crono)] + for i, (txt, var) in enumerate(labels): + font_style = ("Arial", 9, "bold") if txt == "Nº de CRONOMETRAJE" else ("Arial", 9, "bold", "italic") + tk.Label(f_top, text=txt, font=font_style, anchor="w", padx=5, borderwidth=1, relief="solid").grid(row=2+i, column=0, sticky="nsew") + if txt == "MÁQUINA": + frame_maq = tk.Frame(f_top, bg="white") + frame_maq.grid(row=2+i, column=1, sticky="nsew") + self.combo_maquina = ttk.Combobox(frame_maq, state="normal", width=10, textvariable=self.var_maquina) + self.combo_maquina.pack(side="left", fill="both", expand=True) + btn_add_maq = tk.Button( + frame_maq, + text="+", + font=("Arial", 8, "bold"), + bg="#2c3e50", + fg="white", + bd=1, + relief="solid", + width=2, + command=lambda: self.abrir_modal_crear_recurso("MAQUINA") + ) + btn_add_maq.pack(side="right", padx=(2, 0)) + btn_del_maq = tk.Button( + frame_maq, + text="-", + font=("Arial", 8, "bold"), + bg="#e74c3c", + fg="white", + bd=1, + relief="solid", + width=2, + command=lambda: self.accion_borrar_recurso("MAQUINA") + ) + btn_del_maq.pack(side="right", padx=(2, 0)) + elif txt == "HM": + frame_hm = tk.Frame(f_top, bg="white") + frame_hm.grid(row=2+i, column=1, sticky="nsew") + self.combo_hm = ttk.Combobox(frame_hm, state="normal", width=10, textvariable=self.var_hm) + self.combo_hm.pack(side="left", fill="both", expand=True) + btn_add_hm = tk.Button( + frame_hm, + text="+", + font=("Arial", 8, "bold"), + bg="#2c3e50", + fg="white", + bd=1, + relief="solid", + width=2, + command=lambda: self.abrir_modal_crear_recurso("HM") + ) + btn_add_hm.pack(side="right", padx=(2, 0)) + btn_del_hm = tk.Button( + frame_hm, + text="-", + font=("Arial", 8, "bold"), + bg="#e74c3c", + fg="white", + bd=1, + relief="solid", + width=2, + command=lambda: self.accion_borrar_recurso("HM") + ) + btn_del_hm.pack(side="right", padx=(2, 0)) + elif txt == "OPERARIO": + vcmd_op = (self.register(self.validar_letras_30), '%P') + self.entry_operario = tk.Entry(f_top, textvariable=self.var_operario, borderwidth=1, relief="solid", bg="white", width=15, + validate="key", validatecommand=vcmd_op) + self.entry_operario.grid(row=2+i, column=1, sticky="nsew") + else: + bg_c = "#FFF2CC" if txt == "Nº de CRONOMETRAJE" else "white" + tk.Entry(f_top, textvariable=var, borderwidth=1, relief="solid", bg=bg_c, width=15).grid(row=2+i, column=1, sticky="nsew") + + # lila_frame anclado EXACTAMENTE en la misma fila base (row=1) que el Área de Resultados + self.lila_frame = tk.Frame(self.middle_frame, bg="white") + self.lila_frame.grid(row=1, column=0, sticky="nsew") + + titulos_resultados = ["Nº de Observaciones", "Elementos medidos", "Lote frecuencial", "Tiempo MEDIO", + "Desvío estandar", "Nº Obs. Necesarias", "Rango mediciones", "Suplemento", "Tiempo Normal", "Tiempo Estandar"] + for i, txt in enumerate(titulos_resultados): + tk.Label(self.lila_frame, text=txt, font=("Arial", 9, "bold"), anchor="w", padx=5, borderwidth=1, relief="solid", bg="white").grid(row=i, column=0, sticky="nsew") + # Sincronización Pixel-Perfect: forzamos el alto de cada fila de etiqueta a 24px + self.lila_frame.rowconfigure(i, minsize=24) + self.lila_frame.columnconfigure(0, weight=1) + + def setup_scrollable_area(self) -> None: + # Área de Carga (Superior) - Con scroll vertical + self.canvas = tk.Canvas(self.middle_frame, bg="white", highlightthickness=0) + self.canvas.grid(row=0, column=1, sticky="nsew", padx=(5, 0), pady=(10, 0)) + + sb_y = tk.Scrollbar(self.middle_frame, orient="vertical", command=self.canvas.yview) + sb_y.grid(row=0, column=2, sticky="ns", pady=(10, 0)) + + self.tabla_frame = tk.Frame(self.canvas, bg="white") + self.canvas.create_window((0, 0), window=self.tabla_frame, anchor="nw") + + # Área de Resultados (Inferior) - Sin scroll vertical + self.canvas_resultados = tk.Canvas(self.middle_frame, bg="white", highlightthickness=0, height=240) + self.canvas_resultados.grid(row=1, column=1, sticky="nsew", padx=(5, 0)) + + self.tabla_resultados_frame = tk.Frame(self.canvas_resultados, bg="white") + self.canvas_resultados.create_window((0, 0), window=self.tabla_resultados_frame, anchor="nw") + + # Scrollbar Horizontal (Compartida) + sb_x = tk.Scrollbar(self.middle_frame, orient="horizontal") + sb_x.grid(row=2, column=1, sticky="ew", padx=(5, 0)) + + # Sincronización Horizontal + def scroll_x(*args: Any) -> None: + self.canvas.xview(*args) + self.canvas_resultados.xview(*args) + + sb_x.config(command=scroll_x) + self.canvas.configure(xscrollcommand=sb_x.set, yscrollcommand=sb_y.set) + self.canvas_resultados.configure(xscrollcommand=sb_x.set) + + self.tabla_frame.bind("", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) + self.tabla_resultados_frame.bind("", lambda e: self.canvas_resultados.configure(scrollregion=self.canvas_resultados.bbox("all"))) + + # Scroll del ratón solo para el Área de Carga superior + def _on_mousewheel(event: Any) -> None: + self.canvas.yview_scroll(int(-1*(event.delta/120)), "units") + + self.canvas.bind("", lambda e: self.canvas.bind_all("", _on_mousewheel)) + self.canvas.bind("", lambda e: self.canvas.unbind_all("")) + + def setup_footer_widgets(self): + font_style = ("Arial", 9, "bold") + vcmd_piezas = (self.register(self.validar_entrada_numerica), '%P', '10') + vcmd_articulos = (self.register(self.validar_entrada_numerica), '%P', '10') + vcmd_operarios = (self.register(self.validar_entrada_numerica), '%P', '2') + + tk.Label(self.footer_frame, text="Piezas por golpe", bg="white", font=font_style).grid(row=0, column=0, sticky="e", padx=(0,5), pady=2) + self.ent_piezas_golpe = tk.Entry(self.footer_frame, textvariable=self.var_piezas_golpe, borderwidth=1, relief="solid", justify="center", bg="#FFF2CC", width=8, + validate="key", validatecommand=vcmd_piezas) + self.ent_piezas_golpe.grid(row=0, column=1, sticky="w") + self.ent_piezas_golpe.bind("", lambda e: self.recalcular_todo()) + self.ent_piezas_golpe.bind("", lambda e: self.recalcular_todo()) + tk.Label(self.footer_frame, text="Unidades/golpe", bg="white", font=font_style).grid(row=0, column=2, sticky="w", padx=(5,20)) + + tk.Label(self.footer_frame, text="Artículos por golpe", bg="white", font=font_style).grid(row=1, column=0, sticky="e", padx=(0,5), pady=2) + self.ent_articulos_golpe = tk.Entry(self.footer_frame, textvariable=self.var_articulos_golpe, borderwidth=1, relief="solid", justify="center", bg="#FFF2CC", width=8, + validate="key", validatecommand=vcmd_articulos) + self.ent_articulos_golpe.grid(row=1, column=1, sticky="w") + self.ent_articulos_golpe.bind("", lambda e: self.recalcular_todo()) + self.ent_articulos_golpe.bind("", lambda e: self.recalcular_todo()) + tk.Label(self.footer_frame, text="Unidades/golpe", bg="white", font=font_style).grid(row=1, column=2, sticky="w", padx=(5,20)) + + tk.Label(self.footer_frame, text="Nº de operarios en máquina", bg="white", font=font_style).grid(row=2, column=0, sticky="e", padx=(0,5), pady=2) + self.ent_operarios_maq = tk.Entry(self.footer_frame, textvariable=self.var_operarios_maq, borderwidth=1, relief="solid", justify="center", bg="#FFF2CC", width=8, + validate="key", validatecommand=vcmd_operarios) + self.ent_operarios_maq.grid(row=2, column=1, sticky="w") + self.ent_operarios_maq.bind("", lambda e: self.recalcular_todo()) + self.ent_operarios_maq.bind("", lambda e: self.recalcular_todo()) + + tk.Label(self.footer_frame, text="Tiempo estandar operación", bg="white", font=font_style).grid(row=0, column=3, sticky="e", padx=(20,5), pady=2) + self.ent_tiempo_estandar_op = tk.Entry(self.footer_frame, textvariable=self.var_tiempo_estandar_op, borderwidth=1, relief="solid", justify="center", bg="#C6EFCE", width=10) + self.ent_tiempo_estandar_op.grid(row=0, column=4, sticky="w") + self.ent_tiempo_estandar_op.config(state="readonly") + tk.Label(self.footer_frame, text="minutos/pieza (de CADA artículo)", bg="white", font=font_style).grid(row=0, column=5, sticky="w", padx=(5,20)) + + tk.Label(self.footer_frame, text="Cadencia", bg="white", font=font_style).grid(row=1, column=3, sticky="e", padx=(20,5), pady=2) + self.ent_cadencia_ph = tk.Entry(self.footer_frame, textvariable=self.var_cadencia_ph, borderwidth=1, relief="solid", justify="center", bg="#C6EFCE", width=10) + self.ent_cadencia_ph.grid(row=1, column=4, sticky="w") + self.ent_cadencia_ph.config(state="readonly") + tk.Label(self.footer_frame, text="piezas/hora (de CADA artículo)", bg="white", font=font_style).grid(row=1, column=5, sticky="w", padx=(5,20)) + + tk.Label(self.footer_frame, text="Cadencia", bg="white", font=font_style).grid(row=2, column=3, sticky="e", padx=(20,5), pady=2) + self.ent_cadencia_h1000 = tk.Entry(self.footer_frame, textvariable=self.var_cadencia_h1000, borderwidth=1, relief="solid", justify="center", bg="#C6EFCE", width=10) + self.ent_cadencia_h1000.grid(row=2, column=4, sticky="w") + self.ent_cadencia_h1000.config(state="readonly") + tk.Label(self.footer_frame, text="horas/1000 piezas", bg="white", font=font_style).grid(row=2, column=5, sticky="w", padx=(5,20)) + + epicor_frame = tk.Frame(self.footer_frame, bg="#E7E6E6", borderwidth=1, relief="solid") + epicor_frame.grid(row=0, column=6, rowspan=3, sticky="nsew", padx=(20, 0)) + epicor_frame.rowconfigure(0, weight=1); epicor_frame.rowconfigure(1, weight=1) + epicor_frame.rowconfigure(2, weight=1); epicor_frame.rowconfigure(3, weight=1) + + tk.Label(epicor_frame, text="Estandar Epicor", bg="#E7E6E6", font=font_style).grid(row=1, column=0, sticky="e", padx=(15,5)) + self.ent_estandar_epicor = tk.Entry(epicor_frame, textvariable=self.var_estandar_epicor, borderwidth=1, relief="solid", justify="center", bg="#00B0F0", width=10) + self.ent_estandar_epicor.grid(row=1, column=1, sticky="w") + self.ent_estandar_epicor.config(state="readonly") + tk.Label(epicor_frame, text="hh/1000", bg="#E7E6E6", font=font_style).grid(row=1, column=2, sticky="w", padx=(5,15)) + + tk.Label(epicor_frame, text="Dotación Epicor", bg="#E7E6E6", font=font_style).grid(row=2, column=0, sticky="e", padx=(15,5)) + self.ent_dotacion_epicor = tk.Entry(epicor_frame, textvariable=self.var_dotacion_epicor, borderwidth=1, relief="solid", justify="center", bg="#00B0F0", width=10) + self.ent_dotacion_epicor.grid(row=2, column=1, sticky="w") + self.ent_dotacion_epicor.config(state="readonly") + tk.Label(epicor_frame, text="operarios", bg="#E7E6E6", font=font_style).grid(row=2, column=2, sticky="w", padx=(5,15)) + + def sincronizar_lila(self, n_filas: int) -> None: + # Ya no es necesario recalcular alturas, los paneles estáticos están anclados abajo. + pass + + def agregar_elemento_dinamico(self, n_filas): + self.guardado_recientemente = False + + if n_filas > self.max_filas: + old_max = self.max_filas + self.max_filas = n_filas + self.sincronizar_lila(self.max_filas) + + for b in range(self.num_elementos): + col_base_existente = b * 3 + for f_idx in range(old_max, self.max_filas): + if len(self.entradas_tabla) <= f_idx: + self.entradas_tabla.append([]) + + f_w = [] + for c in range(3): + en = tk.Entry(self.tabla_frame, borderwidth=1, relief="solid", width=8, bg="#E7E6E6", justify="center") + en.config(state="disabled") + en.grid(row=f_idx + 2, column=col_base_existente + c, sticky="nsew") + f_w.append(en) + self.entradas_tabla[f_idx].append(en) + self.matriz_bloques[b].append(f_w) + + # Los resultados ahora siempre empiezan desde la fila 0 en tabla_resultados_frame + r_idx_nuevo = 0 + widgets_columna = self.widgets_resultados_columnas[b] + for i, widget in enumerate(widgets_columna): + widget.grid(row=r_idx_nuevo + i, column=col_base_existente, columnspan=3, sticky="nsew") + + col_base = self.num_elementos * 3 + + ent_n = tk.Entry(self.tabla_frame, borderwidth=1, relief="solid", justify="center", bg="white", fg="grey") + ent_n.insert(0, "Nombre Elemento") + ent_n.grid(row=0, column=col_base, columnspan=3, sticky="nsew", ipady=12) + self.nombres_elementos.append(ent_n) + + def on_focus_in(event, entry=ent_n): + if entry.get() == "Nombre Elemento": + entry.delete(0, tk.END) + entry.config(fg="black") + + def on_focus_out(event, entry=ent_n): + if entry.get() == "": + entry.insert(0, "Nombre Elemento") + entry.config(fg="grey") + + ent_n.bind("", on_focus_in) + ent_n.bind("", on_focus_out) + tk.Label(self.tabla_frame, text="V", font=("Arial", 8, "bold"), bg=config.COLOR_BG_TABLA_HEAD, borderwidth=1, relief="solid").grid(row=1, column=col_base, sticky="nsew") + + tk.Label(self.tabla_frame, text="MIN", font=("Arial", 8, "bold"), bg=config.COLOR_BG_TABLA_HEAD, borderwidth=1, relief="solid").grid(row=1, column=col_base+1, sticky="nsew") + + combo_entrada = ttk.Combobox(self.tabla_frame, values=["SEG", "MIN"], state="readonly", font=("Arial", 8, "bold"), justify="center", width=6) + combo_entrada.set("SEG") + combo_entrada.grid(row=1, column=col_base+2, sticky="nsew") + combo_entrada.bind("<>", lambda e: self.recalcular_todo()) + self.unidades_entrada.append(combo_entrada) + self.unidades_salida.append(combo_entrada) + + bloque = [] + vcmd_v = (self.register(self.validar_entrada_numerica), '%P', '3') + vcmd_seg = (self.register(self.validar_entrada_numerica), '%P', '5') + for f in range(self.max_filas): + if len(self.entradas_tabla) <= f: + self.entradas_tabla.append([]) + f_w = [] + for c in range(3): + is_active = (f < n_filas) + if c == 1: + bg_color = "#E7E6E6" + else: + bg_color = "white" if is_active else "#E7E6E6" + + # Aplicar validación numérica solo a columnas V (índice 0) y SEG (índice 2) + if c in (0, 2): + vcmd = vcmd_v if c == 0 else vcmd_seg + en = tk.Entry(self.tabla_frame, borderwidth=1, relief="solid", width=8, bg=bg_color, justify="center", + validate="key", validatecommand=vcmd) + else: + en = tk.Entry(self.tabla_frame, borderwidth=1, relief="solid", width=8, bg=bg_color, justify="center") + + if is_active: + if c == 0: + en.bind("", lambda e, b=self.num_elementos: self._on_cell_edit(b)) + en.bind("", lambda e, b_val=self.num_elementos, r_val=f, c_val=c: self._mover_foco_abajo(e, b_val, r_val, c_val)) + + def on_v_focus_in(event, entry=en): + val = entry.get().strip() + if val.endswith("%"): + entry.delete(0, tk.END) + entry.insert(0, val[:-1].strip()) + + def on_v_focus_out(event, entry=en): + val = entry.get().strip() + if not val: + return + if val.endswith("%"): + val = val[:-1].strip() + if val.isdigit() or val.replace(".", "", 1).isdigit(): + entry.delete(0, tk.END) + entry.insert(0, f"{val}%") + self.recalcular_todo() + + en.bind("", on_v_focus_in) + en.bind("", on_v_focus_out) + elif c == 1: + en.config(state="readonly") + elif c == 2: + en.bind("", lambda e, b=self.num_elementos: self._on_cell_edit(b)) + en.bind("", lambda e, b_val=self.num_elementos, r_val=f, c_val=c: self._mover_foco_abajo(e, b_val, r_val, c_val)) + else: + if c == 1: + en.config(state="readonly") + else: + en.config(state="disabled") + + en.grid(row=f + 2, column=col_base + c, sticky="nsew") + f_w.append(en) + self.entradas_tabla[f].append(en) + bloque.append(f_w) + self.matriz_bloques.append(bloque) + + # Resultados Finales de la Columna van a tabla_resultados_frame (Fila 0) + r_idx = 0 + var_c = tk.StringVar(value="0"); self.vars_conteo.append(var_c) + + lbl_conteo = tk.Label(self.tabla_resultados_frame, textvariable=var_c, font=("Arial", 9), borderwidth=1, relief="solid", bg="white") + lbl_conteo.grid(row=r_idx, column=col_base, columnspan=3, sticky="nsew") + self.tabla_resultados_frame.rowconfigure(r_idx, minsize=24) + + widgets_bottom = [lbl_conteo] + + vcmd_bot_10 = (self.register(self.validar_entrada_numerica), '%P', '10') + for i in range(1, 10): + bg_color = "#FFF2CC" if i in [2, 7] else "white" + if i in [1, 2, 7]: + ent_bot = tk.Entry(self.tabla_resultados_frame, borderwidth=1, relief="solid", justify="center", bg=bg_color, + validate="key", validatecommand=vcmd_bot_10) + else: + ent_bot = tk.Entry(self.tabla_resultados_frame, borderwidth=1, relief="solid", justify="center", bg=bg_color) + ent_bot.grid(row=r_idx+i, column=col_base, columnspan=3, sticky="nsew") + self.tabla_resultados_frame.rowconfigure(r_idx+i, minsize=24) + + if i in [1, 2, 7]: + ent_bot.bind("", lambda e: self.recalcular_todo()) + ent_bot.bind("", lambda e: self.recalcular_todo()) + if i == 1: + ent_bot.insert(0, "1") + elif i == 2: + ent_bot.insert(0, "1") + elif i == 7: + ent_bot.insert(0, "0") + else: + ent_bot.config(state="disabled") + + widgets_bottom.append(ent_bot) + + self.widgets_resultados_columnas.append(widgets_bottom) + + # Sincronización Horizontal (Ancho) + for i in range(3): + self.tabla_frame.columnconfigure(col_base+i, weight=0, minsize=75) + self.tabla_resultados_frame.columnconfigure(col_base+i, weight=0, minsize=75) + self.num_elementos += 1 + + def ventana_datos_columna(self): + ventana = tk.Toplevel(self) + ventana.title("Configuración") + centrar_ventana(ventana, 300, 200) + ventana.grab_set() + tk.Label(ventana, text="N° de observaciones:").pack(pady=5) + ent_f = tk.Entry(ventana); ent_f.insert(0, "10"); ent_f.pack() + tk.Label(ventana, text="Cantidad de Elementos:").pack(pady=5) + ent_c = tk.Entry(ventana); ent_c.insert(0, "1"); ent_c.pack() + def confirmar() -> None: + try: + f, c = int(ent_f.get()), int(ent_c.get()) + ventana.destroy() + for _ in range(c): + self.agregar_elemento_dinamico(f) + except ValueError: + logger.warning("Intento de configuración con datos no enteros (EAFP).") + messagebox.showerror("Error", "Los valores deben ser números enteros.") + except Exception: + logger.exception("Fallo inesperado al configurar columnas dinámicas.") + messagebox.showerror("Error", "Error al procesar la configuración.") + tk.Button(ventana, text="Generar", bg="#34495e", fg="white", font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", command=confirmar).pack(pady=15) + + def actualizar_conteo(self, b_idx): + self.guardado_recientemente = False + conteo = sum(1 for fila in self.matriz_bloques[b_idx] if any(c.get().strip() for c in fila)) + self.vars_conteo[b_idx].set(str(conteo)) + + def exclusividad_postura(self, s): + if s == "pie" and self.var_de_pie.get(): self.var_sentado.set(False) + elif s == "sentado" and self.var_sentado.get(): self.var_de_pie.set(False) + + def ventana_finalizar(self): + popup = tk.Toplevel(self) + popup.title("Opciones de Cierre") + centrar_ventana(popup, 350, 200) + popup.grab_set() + + tk.Label(popup, text="¿Qué opción desea realizar?", font=("Arial", 11, "bold")).pack(pady=15) + + if not self.guardado_recientemente: + tk.Button(popup, text="Guardar como PDF", bg="#34495e", fg="white", font=("Arial", 10, "bold"), + relief="groove", bd=2, cursor="hand2", command=lambda: self.accion_guardar(popup)).pack(fill="x", padx=40, pady=5) + + tk.Button(popup, text="Salir sin guardar", bg="#34495e", fg="white", font=("Arial", 10, "bold"), + relief="groove", bd=2, cursor="hand2", command=lambda: self.accion_salir(popup)).pack(fill="x", padx=40, pady=5) + + tk.Button(popup, text="Cancelar", bg="#34495e", fg="white", font=("Arial", 10, "bold"), + relief="groove", bd=2, cursor="hand2", command=popup.destroy).pack(fill="x", padx=40, pady=5) + + def accion_guardar(self, popup): + popup.destroy() + exito = self.controlador.guardar_archivo() + if exito: + self.guardado_recientemente = True + + def accion_salir(self, popup): + popup.destroy() + self.limpiar_datos() + # Retorna al Dashboard usando el método de Aplicacion + self.controlador.root.mostrar_frame(PaginaTareas) + + def limpiar_datos(self) -> None: + self.num_elementos = 0 + self.max_filas = 0 + self.nombres_elementos.clear() + self.entradas_tabla.clear() + self.matriz_bloques.clear() + self.vars_conteo.clear() + self.unidades_salida.clear() + self.unidades_entrada.clear() + self.widgets_resultados_columnas.clear() + self.id_tarea_actual = None + self.id_relevamiento_actual = None + + self.var_de_pie.set(False) + self.var_sentado.set(False) + self.var_maquina.set("") + self.var_hm.set("") + self.var_operario.set("") + self.var_id_crono.set("AUTO-001") + + self.var_piezas_golpe.set("1") + self.var_articulos_golpe.set("1") + self.var_operarios_maq.set("1") + self.var_tiempo_estandar_op.set("") + self.var_cadencia_ph.set("") + self.var_cadencia_h1000.set("") + self.var_estandar_epicor.set("") + self.var_dotacion_epicor.set("") + + self.guardado_recientemente = False + + for widget in self.tabla_frame.winfo_children(): + widget.destroy() + + for widget in self.tabla_resultados_frame.winfo_children(): + widget.destroy() + + for widget in self.header_frame.winfo_children(): + if isinstance(widget, tk.Entry): + widget.delete(0, tk.END) + + # El Combobox de operación vive dentro de frame_op (hijo de header_frame), + # por lo que el bucle anterior no lo alcanza. Limpieza explícita. + self.combo_operacion.set("") + + self.sincronizar_lila(0) + + def actualizar_vista(self) -> None: + """ + Inicializa dinámicamente la vista al mostrarse: + 1. Establece la fecha actual. + 2. Obtiene y muestra el correlativo de cronometraje desde la BD. + 3. Carga las listas de Máquinas y HM en los comboboxes. + """ + import datetime + self.limpiar_datos() + + # 1. Fecha actual + self.var_fecha.set(datetime.date.today().strftime("%d/%m/%Y")) + + # 2. Correlativo de cronometraje + try: + next_id = self.controlador.obtener_siguiente_id_relevamiento() + self.var_id_crono.set(f"AUTO-{next_id:03d}") + except Exception: + logger.exception("No se pudo obtener el siguiente correlativo para la vista.") + self.var_id_crono.set("AUTO-001") + + # 3. Poblar Comboboxes + try: + self.combo_maquina['values'] = self.controlador.obtener_lista_maquinas() + self.combo_hm['values'] = self.controlador.obtener_lista_hms() + self.combo_operacion['values'] = self.controlador.obtener_lista_operaciones() + except Exception: + logger.exception("Error al poblar comboboxes de datos maestros en la vista.") + + def validar_entrada_numerica(self, P: str, max_len: str = "") -> bool: + """ + Valida que el string P sea un float positivo o un estado de transición válido. + + Acepta cadena vacía (borrado), dígitos, un solo punto decimal, + y el carácter '%' al final (para valoraciones). Rechaza letras, + signos negativos y caracteres especiales. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si el valor es válido, False para rechazar la pulsación. + """ + if P == "": + return True + + P_clean = P.replace("%", "") + if max_len and max_len.isdigit(): + if len(P_clean) > int(max_len): + return False + + # Permitir '%' solo al final (para valoraciones tipo "100%") + if P.endswith("%"): + P = P[:-1] + if P == "": + return True + # Permitir un solo punto decimal y solo dígitos + if P == ".": + return True + try: + val = float(P) + return val >= 0.0 + except ValueError: + return False + + def validar_letras_30(self, P: str) -> bool: + """Valida que el texto contenga solo letras y espacios, con máximo 30 caracteres. + + Permite cadena vacía (campo borrado). Rechaza dígitos, signos + y cualquier carácter que no sea alfabético ni espacio. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si el valor cumple las restricciones, False en caso contrario. + """ + if P == "": + return True + return len(P) <= 30 and all(c.isalpha() or c.isspace() for c in P) + + def validar_max_6(self, P: str) -> bool: + """Valida que el texto no exceda 6 caracteres de cualquier tipo. + + Permite cadena vacía (campo borrado). Acepta letras, dígitos + y caracteres especiales siempre que la longitud no supere 6. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si la longitud es válida, False en caso contrario. + """ + return len(P) <= 6 + + def validar_max_100(self, P: str) -> bool: + """Valida que el texto no exceda 100 caracteres de cualquier tipo. + + Permite cadena vacía (campo borrado). Acepta letras, dígitos + y caracteres especiales siempre que la longitud no supere 100. + + Args: + P: String actual del widget Entry tras la edición propuesta. + + Returns: + True si la longitud es válida, False en caso contrario. + """ + return len(P) <= 100 + + def accion_borrar_recurso(self, tipo: str) -> None: + """ + Elimina el recurso actualmente seleccionado en el Combobox correspondiente. + + Lee el valor actual del Combobox de MAQUINA, HM u OPERACION, + pide confirmación al usuario, delega la eliminación al controlador + y recarga la lista. + + Args: + tipo: Tipo de recurso ('MAQUINA', 'HM' u 'OPERACION'). + """ + if tipo == "MAQUINA": + combo = self.combo_maquina + var = self.var_maquina + tipo_legible = "Máquina" + elif tipo == "OPERACION": + combo = self.combo_operacion + var = None + tipo_legible = "Operación" + else: + combo = self.combo_hm + var = self.var_hm + tipo_legible = "Herramienta/Matriz" + nombre = combo.get().strip() + + + if not nombre: + messagebox.showwarning( + "Sin selección", + f"Seleccione una {tipo_legible} del desplegable antes de intentar eliminar." + ) + return + + confirmado = messagebox.askyesno( + "Confirmar eliminación", + f"¿Está seguro de que desea eliminar la {tipo_legible} '{nombre}'?\n\n" + f"Si está asociada a relevamientos existentes, la operación será rechazada por la base de datos." + ) + if not confirmado: + return + + exito = self.controlador.borrar_recurso(nombre, tipo) + if exito: + messagebox.showinfo("Éxito", f"{tipo_legible} '{nombre}' eliminada correctamente.") + if var is not None: + var.set("") + else: + combo.set("") + if tipo == "MAQUINA": + self.combo_maquina['values'] = self.controlador.obtener_lista_maquinas() + elif tipo == "OPERACION": + self.combo_operacion['values'] = self.controlador.obtener_lista_operaciones() + else: + self.combo_hm['values'] = self.controlador.obtener_lista_hms() + else: + messagebox.showerror( + "Error", + f"No se pudo eliminar '{nombre}'.\n" + f"Puede estar asociada a relevamientos existentes." + ) + + def abrir_modal_crear_recurso(self, tipo: str) -> None: + """ + Abre una pequeña ventana modal (Toplevel) para ingresar una nueva Máquina o HM. + + Args: + tipo (str): El tipo de recurso a crear ('MAQUINA' o 'HM'). + """ + modal = tk.Toplevel(self) + titulos_tipo = {"MAQUINA": "Máquina", "HM": "Herramienta/Matriz", "OPERACION": "Operación"} + titulo_legible = titulos_tipo.get(tipo, tipo) + modal.title(f"Nueva {titulo_legible}") + centrar_ventana(modal, 350, 150) + modal.resizable(False, False) + modal.grab_set() # Hacerlo modal + + # Centrar el modal sobre la ventana principal + modal.transient(self) + + # Etiqueta + tk.Label( + modal, + text=f"Ingrese el nombre de la nueva {titulo_legible}:", + font=("Arial", 10, "bold") + ).pack(pady=(15, 5)) + + # Campo de entrada + entry_nombre = tk.Entry(modal, font=("Arial", 11), width=30) + entry_nombre.pack(pady=5) + entry_nombre.focus_set() + + def guardar() -> None: + nombre = entry_nombre.get().strip() + if not nombre: + messagebox.showerror("Error de Validación", "El nombre no puede estar vacío.", parent=modal) + return + + # Llamar al controlador para insertar en la BD + exito = self.controlador.crear_recurso(nombre, tipo) + if exito: + messagebox.showinfo("Éxito", f"'{nombre}' guardado correctamente.", parent=modal) + + # Recargar las opciones en el combobox correspondiente y dejar seleccionado el nuevo elemento + if tipo == "MAQUINA": + self.combo_maquina['values'] = self.controlador.obtener_lista_maquinas() + self.var_maquina.set(nombre) + elif tipo == "HM": + self.combo_hm['values'] = self.controlador.obtener_lista_hms() + self.var_hm.set(nombre) + elif tipo == "OPERACION": + self.combo_operacion['values'] = self.controlador.obtener_lista_operaciones() + self.combo_operacion.set(nombre) + + modal.destroy() + else: + messagebox.showerror("Error", f"No se pudo registrar '{nombre}' en la base de datos.", parent=modal) + + # Botón Guardar + btn_guardar = tk.Button( + modal, + text="Guardar", + bg="#34495e", + fg="white", + font=("Arial", 10, "bold"), + relief="groove", bd=2, cursor="hand2", + command=guardar + ) + btn_guardar.pack(pady=15) + + # Bind Enter key to save + entry_nombre.bind("", lambda e: guardar()) + + def _on_cell_edit(self, b_idx: int) -> None: + """Helper para actualizar conteo y disparar recálculos.""" + self.actualizar_conteo(b_idx) + self.recalcular_todo() + + def recalcular_todo(self, event: Any = None) -> None: + """ + Motor de cálculo en tiempo real refactorizado: + Normaliza a MINUTOS, extrae variables limpias, calcula estadísticas industriales + y actualiza la visualización de resultados y variables del footer. + """ + import math + import statistics + + def set_readonly_val(entry: tk.Entry, val: str) -> None: + entry.config(state="normal") + entry.delete(0, tk.END) + entry.insert(0, val) + entry.config(state="readonly") + + t_estandar_total = 0.0 + + for b_idx in range(self.num_elementos): + # Obtener el combobox del encabezado para este elemento + unit = "SEG" + try: + unit = self.unidades_entrada[b_idx].get().strip() + except Exception: + pass + + # Paso 1: Normalización a MINUTOS + for row_idx, fila in enumerate(self.matriz_bloques[b_idx]): + if row_idx >= self.max_filas: + continue + val_raw_str = fila[2].get().strip() + if not val_raw_str: + set_readonly_val(fila[1], "") + continue + + try: + val_raw = float(val_raw_str) + if unit == "SEG": + val_min = val_raw / 60.0 + else: + val_min = val_raw + set_readonly_val(fila[1], f"{val_min:.4f}") + except ValueError: + set_readonly_val(fila[1], "") + + # Paso 2: Extracción de variables limpias + minutos = [] + valoraciones = [] + + for row_idx, fila in enumerate(self.matriz_bloques[b_idx]): + if row_idx >= self.max_filas: + continue + min_str = fila[1].get().strip() + v_str = fila[0].get().strip() + + if min_str and v_str: + try: + m_val = float(min_str) + clean_v = v_str + if clean_v.endswith("%"): + clean_v = clean_v[:-1].strip() + v_val = float(clean_v) / 100.0 + + minutos.append(m_val) + valoraciones.append(v_val) + except ValueError: + pass + + widgets = self.widgets_resultados_columnas[b_idx] + n = len(minutos) + + if n == 0: + set_readonly_val(widgets[3], "0.0000") # Tiempo MEDIO + set_readonly_val(widgets[4], "0.0000") # Desvío estandar + set_readonly_val(widgets[5], "0.00") # Obs. Necesarias + set_readonly_val(widgets[6], "MIN: 0.00 | MAX: 0.00") # Rango + set_readonly_val(widgets[8], "0.0000") # Tiempo Normal + set_readonly_val(widgets[9], "0.0000") # Tiempo Estandar + continue + + # Paso 3: Fórmulas Estadísticas + # Inputs manuales + try: + elem_val = float(widgets[1].get().strip() or 1.0) + except ValueError: + elem_val = 1.0 + + try: + lote_str = widgets[2].get().strip() + lote_val = float(lote_str) if lote_str else None + except ValueError: + lote_val = None + + try: + sup_str = widgets[7].get().strip() + if sup_str.endswith("%"): + suplemento = float(sup_str[:-1].strip()) / 100.0 + else: + val_sup = float(sup_str) + if val_sup >= 1.0: + suplemento = val_sup / 100.0 + else: + suplemento = val_sup + except ValueError: + suplemento = 0.0 + + # Divisor: div = lote_frecuencial o elementos_medidos + div = lote_val if lote_val is not None else elem_val + if div <= 0: + div = 1.0 + + # Tiempo Medio: sum(minutos) / (n * div) + sum_min = sum(minutos) + medio = sum_min / (n * div) + set_readonly_val(widgets[3], f"{medio:.4f}") + + # Desvío Estándar: statistics.stdev(minutos) (si n>=2, sino 0) + std_dev = 0.0 + if n >= 2: + try: + std_dev = statistics.stdev(minutos) + except Exception: + std_dev = 0.0 + set_readonly_val(widgets[4], f"{std_dev:.4f}") + + # Rango Mediciones: max(minutos) - min(minutos) + rango = max(minutos) - min(minutos) + set_readonly_val(widgets[6], f"{rango:.4f}") + + # Nº Obs. Necesarias + sum_sq_min = sum(m**2 for m in minutos) + obs_nec = 0.0 + if sum_min > 0: + radicand = max(0.0, n * sum_sq_min - (sum_min ** 2)) + obs_nec = ((40.0 * math.sqrt(radicand)) / sum_min) ** 2 + set_readonly_val(widgets[5], f"{obs_nec:.2f}") + + # Tiempo Normal: sum(m * v for m, v in zip(minutos, valoraciones)) / (n * div) + t_normal = sum(m * v for m, v in zip(minutos, valoraciones)) / (n * div) + set_readonly_val(widgets[8], f"{t_normal:.4f}") + + # Tiempo Estándar: Tiempo Normal * (1 + suplemento) + t_estandar = t_normal * (1.0 + suplemento) + set_readonly_val(widgets[9], f"{t_estandar:.4f}") + t_estandar_total += t_estandar + + # Paso 4: Actualización de Variables Globales del Footer en Cascada + def set_readonly_entry_val(entry: tk.Entry, val: float) -> None: + val_str = f"{val:.3f}" if val > 0.0 else "0.000" + entry.config(state="normal") + entry.delete(0, tk.END) + entry.insert(0, val_str) + entry.config(state="readonly") + + try: + p_golpe = float(self.var_piezas_golpe.get().strip() or 1.0) + except ValueError: + p_golpe = 1.0 + + try: + a_golpe = float(self.var_articulos_golpe.get().strip() or 1.0) + except ValueError: + a_golpe = 1.0 + + # NUEVO: Extraemos el número de operarios + try: + o_maq = float(self.var_operarios_maq.get().strip() or 1.0) + except ValueError: + o_maq = 1.0 + + # Invocar la lógica de totalización en el Controlador + metricas = self.controlador.calcular_metricas_globales(t_estandar_total, p_golpe, a_golpe) + + # Actualizar los widgets globales de forma segura + set_readonly_entry_val(self.ent_tiempo_estandar_op, metricas["te_operacion"]) + set_readonly_entry_val(self.ent_cadencia_ph, metricas["cadencia_ph"]) + set_readonly_entry_val(self.ent_cadencia_h1000, metricas["cadencia_h1000"]) + set_readonly_entry_val(self.ent_estandar_epicor, metricas["estandar_epicor"]) + + # FIX: La dotación es exactamente igual al número de operarios + set_readonly_entry_val(self.ent_dotacion_epicor, o_maq) + def ventana_finalizar(self) -> None: + """Muestra popup con opciones de cierre, incluyendo persistencia en BD.""" + popup = tk.Toplevel(self) + popup.title("Opciones de Cierre") + centrar_ventana(popup, 350, 250) + popup.grab_set() + + tk.Label(popup, text="¿Qué opción desea realizar?", font=("Arial", 11, "bold")).pack(pady=15) + + tk.Button(popup, text="Guardar en Base de Datos", bg="#2c3e50", fg="white", font=("Arial", 10, "bold"), + command=lambda: self.accion_guardar_bd(popup)).pack(fill="x", padx=40, pady=5) + + if not self.guardado_recientemente: + tk.Button(popup, text="Guardar como PDF", bg="#27ae60", fg="white", font=("Arial", 10, "bold"), + command=lambda: self.accion_guardar(popup)).pack(fill="x", padx=40, pady=5) + + tk.Button(popup, text="Salir sin guardar", bg="#c0392b", fg="white", font=("Arial", 10, "bold"), + command=lambda: self.accion_salir(popup)).pack(fill="x", padx=40, pady=5) + + tk.Button(popup, text="Cancelar", bg="gray", fg="white", font=("Arial", 10, "bold"), + command=popup.destroy).pack(fill="x", padx=40, pady=5) + + def accion_guardar_bd(self, popup: tk.Toplevel) -> None: + """Acción de guardar los datos del relevamiento en la BD.""" + popup.destroy() + exito = self.guardar_en_bd() + if exito: + messagebox.showinfo("Éxito", "Relevamiento guardado exitosamente en la Base de Datos.") + self.guardado_recientemente = True + + def guardar_en_bd(self) -> bool: + """ + Valida, sanitiza e inserta/actualiza el cronometraje en la BD. + Cumple estrictamente con la seguridad y casteo seguro (Regla 0 y Regla 7). + """ + from tkinter import messagebox + try: + # 1. Recolección e intercepción de nulos (Strings) + def str_or_none(val: str) -> Optional[str]: + cleaned = val.strip() + return cleaned if cleaned and cleaned.lower() != "none" else None + + art_codigo = str_or_none(self.entry_articulo_codigo.get()) + art_desc = str_or_none(self.entry_descripcion_articulo.get()) + operacion = str_or_none(self.combo_operacion.get()) + maquina = str_or_none(self.var_maquina.get()) + hm = str_or_none(self.var_hm.get()) + operario = str_or_none(self.var_operario.get()) + fecha_str = str_or_none(self.var_fecha.get()) + id_tarea_val = str_or_none(str(self.id_tarea_actual)) + + if not art_codigo: + messagebox.showwarning("Error de Validación", "El código de Artículo es obligatorio.") + return False + if not operacion: + messagebox.showwarning("Error de Validación", "La Operación es obligatoria.") + return False + + from dao import ArticuloDAO + import datetime as dt + from modelos import Relevamiento, TiemposElemento, Recurso, Operario + + # Obtener o crear artículo enviando codigo y descripcion como campos independientes + articulo_obj = ArticuloDAO.obtener_o_crear(art_codigo, art_desc or art_codigo) + + # Delegamos la creación o búsqueda de recursos al DAO. Enviamos objetos de transporte. + maquina_obj = Recurso(id_recurso="0", nombre=maquina, tipo="MAQUINA") if maquina else None + hm_obj = Recurso(id_recurso="0", nombre=hm, tipo="HM") if hm else None + operario_obj = Operario(id_operario="0", nombre=operario, legajo="") if operario else None + + try: + fecha_obj = dt.datetime.strptime(fecha_str, "%d/%m/%Y").date() if fecha_str else dt.date.today() + except ValueError: + fecha_obj = dt.date.today() + + postura = "pie" if self.var_de_pie.get() else "sentado" if self.var_sentado.get() else "pie" + + # 2. Casteo Seguro de métricas globales (Regla de Nulos y Casteo) + def safe_int_parse(val: str, field_name: str) -> int: + val = val.strip() + if not val or val.lower() == "none": + return 1 + try: + return int(float(val)) + except ValueError: + raise ValueError(f"El campo '{field_name}' debe ser un número entero. Valor ingresado: '{val}'") + + try: + p_golpe = safe_int_parse(self.var_piezas_golpe.get(), "Piezas/Golpe") + a_golpe = safe_int_parse(self.var_articulos_golpe.get(), "Artículos/Golpe") + o_maq = safe_int_parse(self.var_operarios_maq.get(), "Operarios/Maq") + except ValueError as ve: + messagebox.showwarning("Error de Tipos", str(ve)) + return False + + relevamiento = Relevamiento( + id_relevamiento=self.id_relevamiento_actual, + id_tarea=int(id_tarea_val) if id_tarea_val else None, + articulo=articulo_obj, + operacion=operacion, + recurso=maquina_obj, + hm=hm_obj, + operario=operario_obj, + fecha=fecha_obj, + postura=postura, + piezas_por_golpe=p_golpe, + articulos_por_golpe=a_golpe, + operarios_maquina=o_maq, + tiempos=[] + ) + + # 3. Recolectar y sanitizar tiempos de la grilla + for b_idx in range(self.num_elementos): + raw_nombre = str_or_none(self.nombres_elementos[b_idx].get()) + if not raw_nombre or raw_nombre.strip() == "Nombre Elemento": + nombre_el = f"Elemento {b_idx + 1}" + else: + nombre_el = raw_nombre + + for row_idx, fila in enumerate(self.matriz_bloques[b_idx]): + if row_idx >= self.max_filas: + continue + + val_v_str = str_or_none(fila[0].get()) + val_min_str = str_or_none(fila[1].get()) + val_raw_str = str_or_none(fila[2].get()) + + if val_v_str or val_min_str or val_raw_str: + try: + # Valoración + v = 100.0 + if val_v_str: + if val_v_str.endswith("%"): + val_v_str = val_v_str[:-1].strip() + v = float(val_v_str) + + # Minutos y Segundos (Regla de extracción segura) + m = 0.0 + if val_raw_str: + unit = "SEG" + try: + unit = self.unidades_entrada[b_idx].get().strip() + except Exception: + pass + raw_val = float(val_raw_str) + m = raw_val / 60.0 if unit == "SEG" else raw_val + elif val_min_str: + m = float(val_min_str) + + s = m * 60.0 + + # Métricas inferiores + el_med_str = str_or_none(self.widgets_resultados_columnas[b_idx][1].get()) + el_medidos = int(float(el_med_str)) if el_med_str else 1 + + lote_str = str_or_none(self.widgets_resultados_columnas[b_idx][2].get()) + lote_freq = float(lote_str) if lote_str else 1.0 + + suple_str = str_or_none(self.widgets_resultados_columnas[b_idx][7].get()) + suple = 0.0 + if suple_str: + if suple_str.endswith("%"): + suple_str = suple_str[:-1].strip() + suple = float(suple_str) + if suple > 1.0: + suple = suple / 100.0 + + relevamiento.tiempos.append(TiemposElemento( + elemento=nombre_el, + valoracion=v, + minutos=m, + segundos=s, + elementos_medidos=el_medidos, + lote_frecuencial=lote_freq, + suplemento=suple + )) + except ValueError as e: + messagebox.showwarning("Datos Inválidos en Grilla", f"Se encontró texto donde iba un número en la columna {b_idx + 1}, fila {row_idx + 1}.\nRevise los valores ingresados.") + return False + + exito = self.controlador.guardar_relevamiento_bd(relevamiento) + if exito: + self.id_relevamiento_actual = relevamiento.id_relevamiento + self.var_id_crono.set(f"AUTO-{relevamiento.id_relevamiento:03d}") + return exito + except Exception as e: + logger.exception("Error en la fase de recolección de vista.") + messagebox.showerror("Error Inesperado", f"Detalle técnico:\n{str(e)}") + return False + def cargar_datos_nuevos(self, id_tarea: int) -> None: + """ + Limpia la vista y la vincula a una tarea nueva. + """ + self.limpiar_datos() + self.id_tarea_actual = id_tarea + self.id_relevamiento_actual = None + self.var_id_crono.set("AUTO-NUEVO") + + def cargar_datos(self, relevamiento: Any, id_tarea: int) -> None: + """ + Carga toda la información de un relevamiento (cabecera + detalle) + e inicializa el grid de tiempos de manera dinámica. + """ + try: + self.limpiar_datos() + self.id_tarea_actual = id_tarea + self.id_relevamiento_actual = relevamiento.id_relevamiento + + # Setear cabecera con sanitización de nulos - mapeo correcto codigo/descripcion + if relevamiento.articulo: + self.entry_articulo_codigo.delete(0, tk.END) + art_codigo = str(relevamiento.articulo.codigo) if relevamiento.articulo.codigo else "" + self.entry_articulo_codigo.insert(0, art_codigo) + self.entry_descripcion_articulo.delete(0, tk.END) + art_desc = str(relevamiento.articulo.descripcion) if relevamiento.articulo.descripcion else "" + self.entry_descripcion_articulo.insert(0, art_desc) + + op_val = str(relevamiento.operacion) if relevamiento.operacion is not None else "" + self.combo_operacion.set(op_val) + + if relevamiento.recurso: + maq_val = str(relevamiento.recurso.nombre) if relevamiento.recurso.nombre is not None else "" + self.var_maquina.set(maq_val) + if relevamiento.hm: + hm_val = str(relevamiento.hm.nombre) if relevamiento.hm.nombre is not None else "" + self.var_hm.set(hm_val) + if relevamiento.operario: + op_nombre = str(relevamiento.operario.nombre) if relevamiento.operario.nombre is not None else "" + self.var_operario.set(op_nombre) + + # Formatear fecha + from datetime import date + if relevamiento.fecha is not None: + if isinstance(relevamiento.fecha, date): + self.var_fecha.set(relevamiento.fecha.strftime("%d/%m/%Y")) + else: + self.var_fecha.set(str(relevamiento.fecha)) + else: + self.var_fecha.set("") + + # Nº de Cronometraje: si es borrador nuevo (sin ID), obtener el siguiente correlativo de la BD + # Nº de Cronometraje: Lógica de "Borrador Nuevo" o "Cargar Existente" + if relevamiento.id_relevamiento is None: + self.id_relevamiento_actual = None + + if hasattr(relevamiento, 'numero_importado') and relevamiento.numero_importado: + self.var_id_crono.set(relevamiento.numero_importado) + else: + try: + next_id = self.controlador.obtener_siguiente_id_relevamiento() + self.var_id_crono.set(f"AUTO-{next_id:03d}") + except Exception: + self.var_id_crono.set("AUTO-NUEVO") + else: + self.id_relevamiento_actual = relevamiento.id_relevamiento + self.var_id_crono.set(f"AUTO-{self.id_relevamiento_actual:03d}") + + # Postura + postura_val = str(relevamiento.postura).lower() if relevamiento.postura is not None else "pie" + if postura_val == "pie": + self.var_de_pie.set(True) + self.var_sentado.set(False) + else: + self.var_de_pie.set(False) + self.var_sentado.set(True) + + # Footer + piezas = str(relevamiento.piezas_por_golpe) if relevamiento.piezas_por_golpe is not None else "1" + arts = str(relevamiento.articulos_por_golpe) if relevamiento.articulos_por_golpe is not None else "1" + ops = str(relevamiento.operarios_maquina) if relevamiento.operarios_maquina is not None else "1" + + self.var_piezas_golpe.set(piezas) + self.var_articulos_golpe.set(arts) + self.var_operarios_maq.set(ops) + + # Reconstruir detalle de grilla + from collections import OrderedDict + tiempos_por_elemento = OrderedDict() + if relevamiento.tiempos: + for t in relevamiento.tiempos: + el_nombre = str(t.elemento) if t.elemento is not None else "Elemento" + if el_nombre not in tiempos_por_elemento: + tiempos_por_elemento[el_nombre] = [] + tiempos_por_elemento[el_nombre].append(t) + + if not tiempos_por_elemento: + return + + # Obtener el número máximo de observaciones para inicializar la grilla + max_obs = max(len(t_list) for t_list in tiempos_por_elemento.values()) + if max_obs < 10: + max_obs = 10 # Mantener mínimo de 10 filas para estética + + # Agregar columnas dinámicas + for elem_nombre, t_list in tiempos_por_elemento.items(): + self.agregar_elemento_dinamico(max_obs) + col_idx = self.num_elementos - 1 + + # Poner nombre del elemento + self.nombres_elementos[col_idx].delete(0, tk.END) + self.nombres_elementos[col_idx].insert(0, elem_nombre) + self.nombres_elementos[col_idx].config(fg="black") + + # Poner observaciones + for row_idx, t in enumerate(t_list): + fila_widgets = self.matriz_bloques[col_idx][row_idx] + + # Valoración sanitizada + val_v = "" + if t.valoracion is not None: + val_v = f"{t.valoracion:.0f}%" if t.valoracion.is_integer() else f"{t.valoracion}%" + + fila_widgets[0].delete(0, tk.END) + fila_widgets[0].insert(0, val_v) + + # Minutos -> celda MIN (readonly, index 1) + val_m = "" + if t.minutos is not None and t.minutos > 0.0: + val_m = f"{t.minutos:.4f}" + + fila_widgets[1].config(state="normal") + fila_widgets[1].delete(0, tk.END) + fila_widgets[1].insert(0, val_m) + fila_widgets[1].config(state="readonly") + + # Segundos -> celda RAW (editable, index 2) para recuperar el número original + val_s = "" + if t.segundos is not None and t.segundos > 0.0: + val_s = f"{t.segundos:.2f}" + + fila_widgets[2].delete(0, tk.END) + fila_widgets[2].insert(0, val_s) + + # Poner métricas inferiores de la columna + first_t = t_list[0] + + # Elementos medidos + self.widgets_resultados_columnas[col_idx][1].config(state="normal") + self.widgets_resultados_columnas[col_idx][1].delete(0, tk.END) + el_med = str(first_t.elementos_medidos) if first_t.elementos_medidos is not None else "1" + self.widgets_resultados_columnas[col_idx][1].insert(0, el_med) + + # Lote frecuencial + self.widgets_resultados_columnas[col_idx][2].config(state="normal") + self.widgets_resultados_columnas[col_idx][2].delete(0, tk.END) + lote_f = str(first_t.lote_frecuencial) if first_t.lote_frecuencial is not None else "1.0" + self.widgets_resultados_columnas[col_idx][2].insert(0, lote_f) + + # Suplemento + self.widgets_resultados_columnas[col_idx][7].config(state="normal") + self.widgets_resultados_columnas[col_idx][7].delete(0, tk.END) + suple_str = "" + if first_t.suplemento is not None: + suple_val = first_t.suplemento + if suple_val <= 1.0 and suple_val > 0.0: + suple_val = suple_val * 100 + suple_str = f"{suple_val:.0f}" if suple_val.is_integer() else f"{suple_val}" + self.widgets_resultados_columnas[col_idx][7].insert(0, suple_str) + + # Actualizar conteo de filas cargadas para cada columna + for b_idx in range(self.num_elementos): + self.actualizar_conteo(b_idx) + + # Ejecutar el motor de cálculo en tiempo real + self.recalcular_todo() + self.guardado_recientemente = True + + except Exception as e: + logger.exception("Error poblando la interfaz") + from tkinter import messagebox + messagebox.showerror("Error al Cargar", f"Falló el renderizado de la interfaz:\n{str(e)}") + + def obtener_datos_limpios(self) -> Dict[str, Any]: + """ + Extrae la totalidad de los datos de la planilla para generar un PDF tipo 'snapshot'. + + Retorna un diccionario integral con tres secciones: + - 'cabecera': datos de artículo, operación, máquina, operario, etc. + - 'elementos': lista de diccionarios, cada uno con nombre, observaciones y resultados. + - 'footer': métricas globales (piezas/golpe, TE operación, cadencias, Epicor). + + Returns: + Dict[str, Any]: Diccionario con toda la información visible en la UI. + """ + # --- CABECERA COMPLETA --- + cabecera: Dict[str, str] = { + "articulo_codigo": self.entry_articulo_codigo.get().strip() or "S/C", + "articulo_descripcion": self.entry_descripcion_articulo.get().strip() or "Sin Descripción", + "operacion": self.combo_operacion.get().strip() or "S/O", + "maquina": self.var_maquina.get().strip() or "S/M", + "herramienta": self.var_hm.get().strip() or "S/H", + "operario": self.var_operario.get().strip() or "S/OP", + "fecha": self.var_fecha.get().strip() or "S/F", + "postura": "Pie" if self.var_de_pie.get() else "Sentado" if self.var_sentado.get() else "N/A", + "id_crono": self.var_id_crono.get().strip() or "N/A", + } + + # --- ELEMENTOS (Observaciones + Resultados Estadísticos) --- + elementos: List[Dict[str, Any]] = [] + for b_idx in range(self.num_elementos): + nombre = self.nombres_elementos[b_idx].get().strip() + if nombre == "Nombre Elemento" or not nombre: + nombre = f"Elemento {b_idx + 1}" + + # Observaciones: V, MIN, RAW de cada fila activa + observaciones: List[Dict[str, str]] = [] + for row_idx, fila in enumerate(self.matriz_bloques[b_idx]): + if row_idx >= self.max_filas: + continue + v_str = fila[0].get().strip() + min_str = fila[1].get().strip() + raw_str = fila[2].get().strip() + + if v_str or min_str or raw_str: + observaciones.append({ + "valoracion": v_str or "-", + "min": min_str or "-", + "raw": raw_str or "-", + }) + + # Resultados: los 10 widgets de widgets_resultados_columnas + widgets = self.widgets_resultados_columnas[b_idx] + # Mapeo posicional: [0]=Nº Obs(Label), [1]=Elem Med, [2]=Lote Frec, + # [3]=T.MEDIO, [4]=Desvío, [5]=Obs Nec, [6]=Rango, + # [7]=Suplemento, [8]=T.Normal, [9]=T.Estándar + def _extraer_valor_widget(widget: Any) -> str: + """Extrae texto de un widget tk (Entry o Label con StringVar).""" + try: + if isinstance(widget, tk.Label): + # Labels usan cget('text') o textvariable + var = widget.cget("textvariable") + if var: + return widget.getvar(var) + return widget.cget("text") + # Entry (normal o readonly) + return widget.get().strip() + except Exception: + return "" + + resultados: Dict[str, str] = { + "n_observaciones": _extraer_valor_widget(widgets[0]), + "elementos_medidos": _extraer_valor_widget(widgets[1]), + "lote_frecuencial": _extraer_valor_widget(widgets[2]), + "tiempo_medio": _extraer_valor_widget(widgets[3]), + "desvio_estandar": _extraer_valor_widget(widgets[4]), + "obs_necesarias": _extraer_valor_widget(widgets[5]), + "rango": _extraer_valor_widget(widgets[6]), + "suplemento": _extraer_valor_widget(widgets[7]), + "tiempo_normal": _extraer_valor_widget(widgets[8]), + "tiempo_estandar": _extraer_valor_widget(widgets[9]), + } + + elementos.append({ + "nombre": nombre, + "observaciones": observaciones, + "resultados": resultados, + }) + + # --- FOOTER COMPLETO (Métricas Globales + Epicor) --- + footer: Dict[str, str] = { + "piezas_por_golpe": self.var_piezas_golpe.get().strip() or "1", + "articulos_por_golpe": self.var_articulos_golpe.get().strip() or "1", + "operarios_maquina": self.var_operarios_maq.get().strip() or "1", + "te_operacion": self.var_tiempo_estandar_op.get().strip() or "0.000", + "cadencia_ph": self.var_cadencia_ph.get().strip() or "0.000", + "cadencia_h1000": self.var_cadencia_h1000.get().strip() or "0.000", + "estandar_epicor": self.var_estandar_epicor.get().strip() or "0.000", + "dotacion_epicor": self.var_dotacion_epicor.get().strip() or "0.000", + } + + return { + "cabecera": cabecera, + "elementos": elementos, + "footer": footer, + } + + def _mover_foco_abajo(self, event, b_idx: int, row_idx: int, c_idx: int) -> None: + if row_idx + 1 < self.max_filas: + siguiente = self.matriz_bloques[b_idx][row_idx + 1][c_idx] + if siguiente.cget("state") == "normal": + siguiente.focus_set() + + +class PaginaBuscadorHistorico(tk.Frame): + """ + Vista del Buscador Histórico de relevamientos con filtros tipo Excel. + + Permite a los responsables de planta buscar cronometrajes pasados + haciendo clic en los encabezados de columna para activar Comboboxes + flotantes con valores únicos extraídos de los datos actuales. + + El ID de la base de datos se oculta del usuario; se usa internamente + como iid del Treeview. El Código del Artículo es el identificador visual. + + Attributes: + controlador: Instancia del controlador MVC principal. + tree: Treeview con los resultados de la búsqueda. + filtros_activos: Diccionario con los filtros aplicados actualmente. + _combo_flotante: Referencia al Combobox flotante activo (o None). + _nombres_originales: Nombres base de cada columna (sin íconos de filtro). + """ + + # Mapeo: nombre columna visible -> clave de filtro en el DAO + _COL_A_FILTRO: Dict[str, str] = { + "Artículo": "articulo", + "Operación": "operacion", + "Máquina": "maquina", + "HM": "hm", + "Operario": "operario", + } + + # Columnas que NO permiten filtrado por clic en header + _COLUMNAS_SIN_FILTRO: tuple = ("Fecha",) + + def __init__(self, parent: tk.Widget, controlador: Any) -> None: + """ + Inicializa la vista del Buscador Histórico. + + Args: + parent: Widget padre (contenedor de frames de la aplicación). + controlador: Instancia del Controlador MVC. + """ + super().__init__(parent, bg="#ecf0f1") + self.controlador = controlador + + # Estado interno de filtros activos + self.filtros_activos: Dict[str, str] = {} + self._combo_flotante: Optional[ttk.Combobox] = None + + # Nombres base de cada columna (sin ícono de filtro) + self._nombres_originales: Dict[str, str] = {} + + # ═══════════════════════════════════════════════════════════════ + # PANEL SUPERIOR — Título + # ═══════════════════════════════════════════════════════════════ + panel_titulo = tk.Frame(self, bg="#2c3e50") + panel_titulo.pack(side="top", fill="x") + tk.Label( + panel_titulo, text="Buscador Histórico de Relevamientos", + font=("Arial", 14, "bold"), bg="#2c3e50", fg="white" + ).pack(pady=10) + + # Barra de instrucción sutil + barra_instruccion = tk.Frame(self, bg="#dfe6e9") + barra_instruccion.pack(side="top", fill="x", padx=15, pady=(5, 0)) + tk.Label( + barra_instruccion, + text="💡 Haga clic en los encabezados de columna para filtrar", + font=("Arial", 9, "italic"), bg="#dfe6e9", fg="#636e72" + ).pack(side="left", padx=10, pady=4) + + # Indicador de resultados (lado derecho de la barra de instrucción) + self.var_resultado_info = tk.StringVar(value="") + tk.Label( + barra_instruccion, textvariable=self.var_resultado_info, + font=("Arial", 9, "bold"), bg="#dfe6e9", fg="#2c3e50" + ).pack(side="right", padx=10, pady=4) + + # ═══════════════════════════════════════════════════════════════ + # PANEL CENTRAL — Treeview con resultados + # ═══════════════════════════════════════════════════════════════ + self.panel_grilla = tk.Frame(self, bg="white") + self.panel_grilla.pack(side="top", fill="both", expand=True, padx=15, pady=5) + + # Todas las columnas internas (ID oculta) + todas_columnas: tuple = ("ID", "Fecha", "Artículo", "Operación", "Máquina", "HM", "Operario") + # Columnas visibles para el usuario (sin ID) + columnas_visibles: tuple = ("Fecha", "Artículo", "Operación", "Máquina", "HM", "Operario") + + self.tree = ttk.Treeview( + self.panel_grilla, columns=todas_columnas, show="headings", + selectmode="browse", displaycolumns=columnas_visibles + ) + + # Configuración de columnas + anchos: Dict[str, int] = { + "ID": 0, "Fecha": 110, "Artículo": 160, + "Operación": 160, "Máquina": 160, "HM": 130, "Operario": 160, + } + for col in todas_columnas: + self.tree.heading(col, text=col, anchor="center") + ancho = anchos.get(col, 120) + anchor = "center" if col == "Fecha" else "w" + self.tree.column(col, width=ancho, anchor=anchor, minwidth=50) + # Guardar nombres originales para restaurar tras limpiar filtros + self._nombres_originales[col] = col + + # Columna ID con ancho 0 (oculta vía displaycolumns, refuerzo visual) + self.tree.column("ID", width=0, stretch=False, minwidth=0) + + # Scrollbars + scroll_y = ttk.Scrollbar(self.panel_grilla, orient="vertical", command=self.tree.yview) + scroll_x = ttk.Scrollbar(self.panel_grilla, orient="horizontal", command=self.tree.xview) + self.tree.configure(yscrollcommand=scroll_y.set, xscrollcommand=scroll_x.set) + + self.tree.grid(row=0, column=0, sticky="nsew") + scroll_y.grid(row=0, column=1, sticky="ns") + scroll_x.grid(row=1, column=0, sticky="ew") + + self.panel_grilla.columnconfigure(0, weight=1) + self.panel_grilla.rowconfigure(0, weight=1) + + # Eventos + self.tree.bind("", self._on_doble_clic) + self.tree.bind("", self._on_header_click) + + # ═══════════════════════════════════════════════════════════════ + # PANEL INFERIOR — Botones de acción + # ═══════════════════════════════════════════════════════════════ + panel_inferior = tk.Frame(self, bg="#ecf0f1") + panel_inferior.pack(side="bottom", fill="x", padx=15, pady=10) + + tk.Button( + panel_inferior, text="📂 Cargar Relevamiento", bg="#34495e", fg="white", + font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", + command=self._cargar_seleccionado + ).pack(side="right", padx=5) + + self.btn_limpiar = tk.Button( + panel_inferior, text="🧹 Limpiar Filtros", bg="#34495e", fg="white", + font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", + command=self._limpiar_filtros + ) + self.btn_limpiar.pack(side="right", padx=5) + + # Botón "Borrar Planilla" — RBAC: solo visible para JEFE/GERENTE + self.btn_borrar = tk.Button( + panel_inferior, text="🗑 Borrar Planilla", bg="#34495e", fg="white", + font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", + command=self._borrar_seleccionado + ) + + tk.Button( + panel_inferior, text="↩ Volver al Home", bg="#34495e", fg="white", + font=("Arial", 10, "bold"), relief="groove", bd=2, cursor="hand2", + command=lambda: self.controlador.root.mostrar_frame(PaginaTareas) + ).pack(side="left", padx=5) + + # ═══════════════════════════════════════════════════════════════════ + # FILTROS TIPO EXCEL — Combobox Flotante sobre Encabezados + # ═══════════════════════════════════════════════════════════════════ + + def _on_header_click(self, event: Any) -> None: + """ + Intercepta clics en la zona de encabezados del Treeview. + + Usa identify_region para verificar que el clic fue en 'heading'. + Usa identify_column para determinar qué columna fue clickeada. + Si la columna admite filtrado, despliega un Combobox flotante + con los valores únicos actuales de esa columna. + + Args: + event: Evento de Tkinter con coordenadas x, y del clic. + """ + region = self.tree.identify_region(event.x, event.y) + if region != "heading": + return + + # Destruir Combobox flotante previo si existe + self._destruir_combo_flotante() + + # Obtener el índice de columna clickeada (formato "#N") + col_id_raw: str = self.tree.identify_column(event.x) + try: + # col_id_raw es "#1", "#2", etc. — índice 1-based sobre displaycolumns + col_display_idx: int = int(col_id_raw.replace("#", "")) - 1 + except ValueError: + return + + # Mapear índice de displaycolumns al nombre real de la columna + displaycolumns = self.tree.cget("displaycolumns") + if isinstance(displaycolumns, str): + displaycolumns = self.tree.configure("displaycolumns")[4] + if isinstance(displaycolumns, str): + displaycolumns = displaycolumns.split() + + if col_display_idx < 0 or col_display_idx >= len(displaycolumns): + return + + col_name: str = displaycolumns[col_display_idx] + + # No filtrar columnas excluidas + if col_name in self._COLUMNAS_SIN_FILTRO: + return + + # No filtrar columnas sin mapeo + if col_name not in self._COL_A_FILTRO: + return + + # Extraer valores únicos de la columna desde los datos actuales del Treeview + valores_unicos: List[str] = sorted({ + str(self.tree.set(child, col_name)) + for child in self.tree.get_children() + if self.tree.set(child, col_name).strip() + }) + + # Agregar opción vacía al inicio para "sin filtro" + opciones: List[str] = ["(Todos)"] + valores_unicos + + # ─── Geometría: posicionar Combobox sobre el encabezado ─── + tree_x: int = self.tree.winfo_x() + tree_y: int = self.tree.winfo_y() + + # Calcular x offset iterando sobre las columnas anteriores visibles + x_offset: int = 0 + for i in range(col_display_idx): + prev_col = displaycolumns[i] + col_width = self.tree.column(prev_col, "width") + x_offset += col_width + + col_width: int = self.tree.column(col_name, "width") + header_height: int = 26 # Altura típica del heading en ttk.Treeview + + combo_x: int = tree_x + x_offset + combo_y: int = tree_y + combo_w: int = col_width + + # Crear Combobox flotante dentro del panel_grilla + self._combo_flotante = ttk.Combobox( + self.panel_grilla, values=opciones, font=("Arial", 9), + state="normal" # Permite tipeo libre + selección + ) + self._combo_flotante.place(x=combo_x, y=combo_y, width=combo_w, height=header_height) + + # Inyectar filtro actual si existe + clave_filtro: str = self._COL_A_FILTRO[col_name] + filtro_actual: str = self.filtros_activos.get(clave_filtro, "") + if filtro_actual: + self._combo_flotante.set(filtro_actual) + else: + self._combo_flotante.set("") + + self._combo_flotante.focus_set() + self._combo_flotante.event_generate("") + + # ─── Bindings del Combobox flotante ─── + def aplicar_filtro(e: Any = None) -> None: + """Aplica el valor seleccionado/escrito como filtro y refresca.""" + if self._combo_flotante is None: + return + valor: str = self._combo_flotante.get().strip() + self._destruir_combo_flotante() + + if valor == "(Todos)" or valor == "": + # Remover filtro para esta columna + self.filtros_activos.pop(clave_filtro, None) + self.tree.heading(col_name, text=self._nombres_originales[col_name]) + else: + # Aplicar filtro + self.filtros_activos[clave_filtro] = valor + self.tree.heading(col_name, text=f"🔍 {self._nombres_originales[col_name]}") + + self._ejecutar_busqueda() + + def cancelar(e: Any = None) -> None: + """Destruye el Combobox sin aplicar cambios.""" + self._destruir_combo_flotante() + + self._combo_flotante.bind("", aplicar_filtro) + self._combo_flotante.bind("<>", aplicar_filtro) + self._combo_flotante.bind("", cancelar) + self._combo_flotante.bind("", cancelar) + + def _destruir_combo_flotante(self) -> None: + """ + Destruye el Combobox flotante activo si existe. + Manejo seguro para evitar errores si ya fue destruido. + """ + if self._combo_flotante is not None: + try: + self._combo_flotante.destroy() + except tk.TclError: + pass + self._combo_flotante = None + + # ═══════════════════════════════════════════════════════════════════ + # Lógica de Búsqueda y Población de Datos + # ═══════════════════════════════════════════════════════════════════ + + def _construir_filtros(self) -> Dict[str, str]: + """ + Construye el diccionario de filtros a partir de self.filtros_activos. + + Returns: + Diccionario con las claves de filtro y sus valores actuales. + """ + return dict(self.filtros_activos) + + def _ejecutar_busqueda(self) -> None: + """ + Ejecuta la búsqueda histórica delegando al controlador y puebla la grilla. + """ + filtros: Dict[str, str] = self._construir_filtros() + try: + resultados: List[tuple] = self.controlador.buscar_relevamientos_historicos(filtros) + self._poblar_grilla(resultados) + # Actualizar indicador de resultados + n: int = len(resultados) + n_filtros: int = len(self.filtros_activos) + texto: str = f"{n} resultado{'s' if n != 1 else ''}" + if n_filtros > 0: + texto += f" | {n_filtros} filtro{'s' if n_filtros != 1 else ''} activo{'s' if n_filtros != 1 else ''}" + self.var_resultado_info.set(texto) + except Exception: + logger.exception("Error al ejecutar búsqueda histórica desde la vista.") + messagebox.showerror( + "Error de Búsqueda", + "Ocurrió un error al buscar los relevamientos históricos.\nRevise el log del sistema." + ) + + def _poblar_grilla(self, resultados: List[tuple]) -> None: + """ + Limpia y vuelve a cargar el Treeview con los resultados provistos. + + El id_relevamiento (índice 0) se almacena como iid para uso interno + pero no es visible al usuario gracias a displaycolumns. + + Args: + resultados: Lista de tuplas + (id_relevamiento, fecha, codigo_articulo, operacion, maquina, hm, operario). + """ + for item in self.tree.get_children(): + self.tree.delete(item) + for fila in resultados: + # iid = id_relevamiento (índice 0), values incluye TODAS las columnas + self.tree.insert( + "", "end", iid=str(fila[0]), + values=(fila[0], fila[1], fila[2], fila[3], fila[4], fila[5], fila[6]) + ) + + def _limpiar_filtros(self) -> None: + """ + Limpia todos los filtros activos, restaura los encabezados originales + y recarga la grilla sin filtros. + """ + self.filtros_activos.clear() + self._destruir_combo_flotante() + + # Restaurar textos originales de todos los encabezados + for col_name, nombre_original in self._nombres_originales.items(): + self.tree.heading(col_name, text=nombre_original) + + self.var_resultado_info.set("") + self._ejecutar_busqueda() + + # ═══════════════════════════════════════════════════════════════════ + # Acciones de Selección y Carga + # ═══════════════════════════════════════════════════════════════════ + + def _cargar_seleccionado(self) -> None: + """ + Captura el ID del relevamiento seleccionado en la grilla (almacenado + como iid) y lo abre en la vista Plantilla mediante el controlador. + """ + seleccion = self.tree.selection() + if not seleccion: + messagebox.showwarning( + "Sin selección", + "Debe seleccionar un relevamiento de la grilla para cargarlo." + ) + return + try: + id_rel: int = int(seleccion[0]) + self.controlador.abrir_relevamiento_desde_id(id_rel) + except ValueError: + logger.exception("El ID seleccionado no es un entero válido.") + messagebox.showerror("Error", "El ID seleccionado no es válido.") + except Exception: + logger.exception("Error al intentar cargar el relevamiento seleccionado.") + messagebox.showerror( + "Error", + "No se pudo cargar el relevamiento seleccionado.\nRevise el log del sistema." + ) + + def _borrar_seleccionado(self) -> None: + """ + Elimina físicamente el relevamiento seleccionado de la base de datos. + + Extrae el ID del Treeview (almacenado como iid), pide confirmación, + delega al controlador y recarga la grilla tras la eliminación. + """ + seleccion = self.tree.selection() + if not seleccion: + messagebox.showwarning( + "Sin selección", + "Debe seleccionar un relevamiento de la grilla para eliminarlo." + ) + return + try: + id_rel: int = int(seleccion[0]) + except ValueError: + logger.exception("El ID seleccionado no es un entero válido para borrado.") + messagebox.showerror("Error", "El ID seleccionado no es válido.") + return + + confirmado = messagebox.askyesno( + "Confirmar eliminación", + f"¿Está seguro de que desea eliminar PERMANENTEMENTE el relevamiento ID {id_rel}?\n\n" + f"Esta acción no se puede deshacer." + ) + if not confirmado: + return + + exito = self.controlador.borrar_relevamiento(id_rel) + if exito: + messagebox.showinfo("Éxito", f"Relevamiento {id_rel} eliminado correctamente.") + self._ejecutar_busqueda() + else: + messagebox.showerror( + "Error", + f"No se pudo eliminar el relevamiento {id_rel}.\nRevise el log del sistema." + ) + + def _on_doble_clic(self, event: Any) -> None: + """ + Manejador del evento sobre la grilla. + Ejecuta la misma acción que el botón 'Cargar Relevamiento'. + Solo actúa si el doble clic fue en la zona de celdas (no en heading). + + Args: + event: Evento de Tkinter con coordenadas del clic. + """ + region = self.tree.identify_region(event.x, event.y) + if region == "heading": + return # Evitar conflicto con _on_header_click + self._cargar_seleccionado() + + # ═══════════════════════════════════════════════════════════════════ + # Método Público (Interfaz MVC) + # ═══════════════════════════════════════════════════════════════════ + + def actualizar_vista(self) -> None: + """ + Limpia la UI completa: resetea filtros activos, restaura encabezados + originales y carga todos los registros por defecto (filtros vacíos). + Se invoca automáticamente al mostrar el frame vía mostrar_frame(). + """ + self.filtros_activos.clear() + self._destruir_combo_flotante() + + for col_name, nombre_original in self._nombres_originales.items(): + self.tree.heading(col_name, text=nombre_original) + + self.var_resultado_info.set("") + self._ejecutar_busqueda() + + # RBAC: Mostrar u ocultar el botón "Borrar Planilla" según el rol + usuario = self.controlador.usuario_actual + if usuario and usuario.rol == "JEFE/GERENTE": + self.btn_borrar.pack(side="right", padx=5, before=self.btn_limpiar) + else: + self.btn_borrar.pack_forget() + + +class PaginaManual(tk.Frame): + """ + Pantalla integrada de solo lectura con el Manual de Usuario completo. + + Se renderiza como un tk.Frame dentro del contenedor principal de la + aplicación (no como Toplevel), permitiendo navegación estándar vía + mostrar_frame(). El contenido se inyecta en un tk.Text con tags de + estilo y se bloquea con state='disabled'. + + El botón "Volver" evalúa dinámicamente si el usuario está autenticado: + si no hay sesión activa, regresa a PaginaLogin; si hay sesión, + regresa a PaginaTareas. + + Attributes: + controlador: Referencia al Controlador MVC. + texto: Widget tk.Text de solo lectura con el contenido del manual. + """ + + def __init__(self, parent: tk.Widget, controlador: Any) -> None: + """ + Inicializa el frame del Manual de Usuario. + + Configura el fondo blanco, crea el título, el widget tk.Text + con scrollbar vertical, inyecta todo el contenido formateado + y lo bloquea en modo solo lectura. + + Args: + parent: Widget contenedor padre (self.contenedor de Aplicacion). + controlador: Instancia del Controlador MVC. + """ + super().__init__(parent, bg="white") + self.controlador = controlador + + # --- Título superior --- + tk.Label( + self, text="Manual de Usuario — Métodos y Tiempos", + font=("Arial", 16, "bold"), bg="white", fg="#2c3e50" + ).pack(side="top", pady=(15, 5)) + + # --- Botón Volver (parte inferior) --- + tk.Button( + self, text="Volver", + font=("Arial", 10, "bold"), bg="#34495e", fg="white", + relief="groove", bd=2, cursor="hand2", + command=self._volver + ).pack(side="bottom", pady=(5, 15)) + + # --- Contenedor de Text + Scrollbar --- + frame_contenido = tk.Frame(self, bg="white") + frame_contenido.pack(fill="both", expand=True, padx=15, pady=(5, 5)) + + scrollbar = tk.Scrollbar(frame_contenido, orient="vertical") + scrollbar.pack(side="right", fill="y") + + self.texto = tk.Text( + frame_contenido, + wrap="word", + font=("Arial", 10), + bg="white", + fg="#2c3e50", + padx=15, + pady=15, + spacing1=2, + spacing3=2, + yscrollcommand=scrollbar.set, + ) + self.texto.pack(side="left", fill="both", expand=True) + scrollbar.config(command=self.texto.yview) + + # --- Tags de estilo --- + self.texto.tag_configure("titulo", font=("Arial", 16, "bold"), foreground="#2c3e50", spacing1=10, spacing3=6) + self.texto.tag_configure("subtitulo", font=("Arial", 13, "bold"), foreground="#34495e", spacing1=8, spacing3=4) + self.texto.tag_configure("seccion", font=("Arial", 11, "bold"), foreground="#2980b9", spacing1=6, spacing3=3) + self.texto.tag_configure("negrita", font=("Arial", 10, "bold")) + self.texto.tag_configure("normal", font=("Arial", 10)) + self.texto.tag_configure("item", font=("Arial", 10), lmargin1=30, lmargin2=45) + self.texto.tag_configure("separador", font=("Arial", 6), foreground="#bdc3c7") + self.texto.tag_configure("nota", font=("Arial", 9, "italic"), foreground="#7f8c8d", lmargin1=20, lmargin2=20) + self.texto.tag_configure("tabla_head", font=("Arial", 10, "bold"), foreground="#2c3e50", background="#ecf0f1") + self.texto.tag_configure("tabla_row", font=("Consolas", 9), lmargin1=15, lmargin2=15) + + # --- Inyectar contenido --- + self._insertar_contenido() + + self.texto.config(state="normal") + self.texto.insert(tk.END, """ +-------------------------------------------------- +6. RECUPERACIÓN DE USUARIO Y CONTRASEÑA +-------------------------------------------------- +En caso de olvidar sus credenciales de acceso, el sistema permite generar una nueva contraseña de forma segura. + +Pasos para restablecer sus datos: +1. En la pantalla principal de Inicio de Sesión (Login), haga clic en el enlace inferior que dice "¿Olvidó su usuario/contraseña?". +2. Se abrirá una ventana de seguridad. Complete los siguientes campos: + - Su Nombre Completo: Ingrese su nombre tal cual fue registrado en el sistema. + - Nueva Contraseña: Cree su nueva clave (solo letras y números, máximo 30 caracteres). + - Clave Maestra de Autorización: Ingrese la clave de seguridad provista por la Gerencia. +3. Haga clic en el botón "Restablecer Credenciales". +4. Si los datos son correctos, el sistema le mostrará un mensaje de éxito recordándole cuál es su Nombre de Usuario y confirmando el cambio de contraseña. +""") + self.texto.config(state="disabled") + + def _volver(self) -> None: + """ + Navega de vuelta a la pantalla correspondiente según el estado de sesión. + + Si no hay usuario autenticado (acceso desde Login), regresa a + PaginaLogin. Si hay sesión activa, regresa a PaginaTareas. + """ + if self.controlador.usuario_actual is None: + self.controlador.root.mostrar_frame(PaginaLogin) + else: + self.controlador.root.mostrar_frame(PaginaTareas) + + def _ins(self, contenido: str, tag: str = "normal") -> None: + """ + Helper interno para insertar una línea con tag en el widget Text. + + Args: + contenido: Texto a insertar. + tag: Nombre del tag de estilo a aplicar. + """ + self.texto.insert("end", contenido + "\n", tag) + + def _sep(self) -> None: + """Inserta una línea separadora horizontal decorativa.""" + self.texto.insert("end", "\u2501" * 80 + "\n", "separador") + + def _insertar_contenido(self) -> None: + """ + Inyecta el contenido completo del Manual de Usuario en el widget Text. + + Utiliza los helpers _ins y _sep para insertar secciones con formato. + El contenido cubre: Introducción, Dashboard, Cronometraje, Buscador + Histórico, Copia de Seguridad (Backup), Barra de Menú y Glosario + de Términos. + """ + ins = self._ins + sep = self._sep + + ins("MANUAL DE USUARIO \u2014 MÉTODOS Y TIEMPOS", "titulo") + ins("Versión Beta 0.15 \u00b7 Junio 2026", "nota") + ins("Audiencia: Metodistas \u00b7 Jefes/Gerentes de Planta", "nota") + sep() + + # --- SECCION 1 --- + ins("1. INTRODUCCIÓN Y ACCESO AL SISTEMA", "subtitulo") + sep() + + ins("1.1 ¿Qué es Métodos y Tiempos?", "seccion") + ins("Métodos y Tiempos es un software de escritorio diseñado para la gestión integral " + "de estudios de cronometraje industrial. Permite a los equipos de Ingeniería de Métodos:") + ins(" \u2022 Crear y gestionar plantillas de cronometraje con múltiples elementos.", "item") + ins(" \u2022 Registrar tiempos, valoraciones y suplementos de forma dinámica.", "item") + ins(" \u2022 Calcular automáticamente métricas industriales (Tiempo Estándar, Cadencia, Epicor).", "item") + ins(" \u2022 Persistir los relevamientos en base de datos centralizada (SQL Server).", "item") + ins(" \u2022 Exportar informes en formato PDF.", "item") + ins(" \u2022 Buscar y consultar el historial de todos los estudios realizados.", "item") + ins("") + + ins("1.2 Roles del Sistema", "seccion") + ins("El sistema implementa Control de Acceso Basado en Roles (RBAC) con dos perfiles:") + ins("") + ins(" METODISTA JEFE/GERENTE", "tabla_head") + ins(" Ver Dashboard: Solo tareas asignadas Ver Dashboard: Todas las tareas", "tabla_row") + ins(" Realizar cronometrajes: SI Realizar cronometrajes: NO", "tabla_row") + ins(" Crear nuevas tareas: NO Crear nuevas tareas: SI", "tabla_row") + ins(" Borrar tareas: NO Borrar tareas: SI", "tabla_row") + ins(" Cambiar estado a Cerrado: NO Cambiar estado a Cerrado: SI", "tabla_row") + ins(" Borrar planillas historicas: NO Borrar planillas historicas: SI", "tabla_row") + ins(" Buscar relevamientos: SI Buscar relevamientos: SI", "tabla_row") + ins("") + + ins("1.3 Pantalla de Inicio de Sesión (Login)", "seccion") + ins("Al abrir la aplicación se presenta la pantalla de Inicio de Sesión. " + "Esta pantalla bloquea el acceso a todas las funcionalidades hasta autenticarse.") + ins("") + ins("Campos del formulario:", "negrita") + ins(" \u2022 Usuario: Ingrese su nombre de usuario registrado.", "item") + ins(" \u2022 Contraseña: Los caracteres se muestran ocultos por seguridad.", "item") + ins("") + ins("Botones disponibles:", "negrita") + ins(" \u2022 Ingresar: Valida credenciales. Si son correctas, redirige al Dashboard.", "item") + ins(" \u2022 Crear usuario: Redirige a la pantalla de Registro.", "item") + ins("") + ins("Cada inicio y cierre de sesión queda registrado en el sistema de auditoría.", "nota") + ins("") + + ins("1.4 Registro de Nuevos Usuarios", "seccion") + ins("La pantalla de Registro permite dar de alta nuevas cuentas con los campos:") + ins(" \u2022 Nombre Completo: Nombre real del usuario.", "item") + ins(" \u2022 Nombre de Usuario (Username): Identificador único para el login.", "item") + ins(" \u2022 Rol del Sistema: METODISTA o JEFE/GERENTE.", "item") + ins(" \u2022 Contraseña: Clave de acceso personal (se almacena cifrada).", "item") + ins("") + ins("Si se selecciona JEFE/GERENTE, el sistema solicitará una Contraseña Maestra " + "de autorización para prevenir escalada de privilegios.", "nota") + ins("") + + ins("1.5 Cerrar Sesión", "seccion") + ins("Desde el menú superior: Archivo \u2192 Cerrar Sesión. " + "Limpia la sesión activa y devuelve a la pantalla de Login.") + sep() + + # --- SECCION 2 --- + ins("2. PANEL PRINCIPAL (DASHBOARD)", "subtitulo") + sep() + + ins("2.1 Pestaña Inicio", "seccion") + ins("Funciona como página de bienvenida y acceso rápido:") + ins(" \u2022 Saludo personalizado: Muestra 'Bienvenido [Nombre]' según la sesión.", "item") + ins(" \u2022 Botón Buscar: Acceso directo al Buscador Histórico.", "item") + ins(" \u2022 Últimos trabajos consultados: Tabla con los 5 relevamientos más recientes.", "item") + ins(" \u2022 Doble clic en cualquier fila abre el relevamiento en el Módulo de Cronometraje.", "item") + ins("") + + ins("2.2 Pestaña Tareas", "seccion") + ins("Centro de gestión de solicitudes de cronometraje. Muestra una tabla con las tareas " + "disponibles según el rol del usuario.") + ins("") + ins("Columnas: Título, Descripción, Estado, Fecha, Informe.", "negrita") + ins("") + ins("Botones para JEFE/GERENTE:", "negrita") + ins(" \u2022 Cargar nueva tarea: Formulario modal (Título, Descripción, Metodista asignado).", "item") + ins(" \u2022 Cambiar Estado: Permite asignar cualquier estado, incluyendo Cerrado.", "item") + ins(" \u2022 Borrar tarea: Elimina permanentemente la tarea (previa confirmación).", "item") + ins("") + ins("Botones para METODISTA:", "negrita") + ins(" \u2022 Realizar Cronometraje: Abre el módulo vinculado a la tarea seleccionada.", "item") + ins(" \u2022 Cambiar Estado: Permite modificar, excepto asignar Cerrado.", "item") + ins("") + ins("Los Metodistas solo ven sus tareas asignadas. Los Jefes/Gerentes ven todas.", "nota") + sep() + + # --- SECCION 3 --- + ins("3. MÓDULO DE CRONOMETRAJE", "subtitulo") + sep() + + ins("3.1 Acceso", "seccion") + ins("Se accede de tres formas:") + ins(" \u2022 Menú Archivo \u2192 Nueva plantilla (plantilla vacía).", "item") + ins(" \u2022 Menú Archivo \u2192 Importar desde Excel... (carga automática).", "item") + ins(" \u2022 Desde el Dashboard (vinculada a una tarea o trabajos recientes).", "item") + ins("") + + ins("3.2 Cabecera del Artículo", "seccion") + ins(" \u2022 ARTÍCULO (código): Campo obligatorio, resaltado en amarillo.", "item") + ins(" \u2022 OPERACIÓN: Nombre o código de la operación. Obligatorio.", "item") + ins(" \u2022 DESCRIPCIÓN: Descripción detallada del artículo.", "item") + ins("") + + ins("3.3 Panel Lateral (Datos del Estudio)", "seccion") + ins(" \u2022 DE PIE / SENTADO: Casillas mutuamente excluyentes (postura del operario).", "item") + ins(" \u2022 MÁQUINA: Desplegable + botones [+] para agregar y [-] para eliminar.", "item") + ins(" \u2022 HM (Herramienta/Matriz): Desplegable + botones [+] y [-].", "item") + ins(" \u2022 OPERARIO: Campo de texto libre.", "item") + ins(" \u2022 FECHA: Se establece automáticamente con la fecha actual.", "item") + ins(" \u2022 Nº de CRONOMETRAJE: Identificador correlativo automático (AUTO-XXX).", "item") + ins("") + + ins("3.4 Creación de Plantilla", "seccion") + ins("Botón 'Agregar nueva columna' (esquina superior derecha):", "negrita") + ins("Abre un formulario modal con:") + ins(" \u2022 Nº de observaciones: Cantidad de filas por elemento (predeterminado: 10).", "item") + ins(" \u2022 Cantidad de Elementos: Elementos a agregar simultáneamente (predeterminado: 1).", "item") + ins("Al presionar 'Generar', se crean las columnas dinámicas en la zona central.", "item") + ins("") + + ins("3.5 Grilla de Tiempos", "seccion") + ins("Cada elemento genera un bloque de tres columnas:") + ins(" \u2022 V (Valoración): Factor de ritmo en %. Editable. Se agrega '%' al salir.", "item") + ins(" \u2022 MIN (Minutos): Conversión automática. Solo lectura.", "item") + ins(" \u2022 SEG/MIN (Tiempo bruto): Tiempo cronometrado. Editable. Solo acepta números.", "item") + ins("") + ins("El selector SEG/MIN en el encabezado define la unidad de entrada.", "negrita") + ins("") + + ins("3.6 Resultados Estadísticos (por elemento)", "seccion") + ins("Se calculan automáticamente en tiempo real:") + ins(" \u2022 Nº de Observaciones: Conteo automático de filas con datos.", "item") + ins(" \u2022 Elementos medidos: Editable (predeterminado: 1).", "item") + ins(" \u2022 Lote frecuencial: Factor de frecuencia. Editable (predeterminado: 1).", "item") + ins(" \u2022 Tiempo MEDIO: Suma(min) / (n x divisor).", "item") + ins(" \u2022 Desvío estándar: Dispersión estadística (requiere 2+ datos).", "item") + ins(" \u2022 Nº Obs. Necesarias: Cantidad estadísticamente necesaria.", "item") + ins(" \u2022 Rango mediciones: Valor máximo menos mínimo.", "item") + ins(" \u2022 Suplemento: Porcentaje por fatiga/necesidades. Editable.", "item") + ins(" \u2022 Tiempo Normal: Suma(min x V) / (n x divisor).", "item") + ins(" \u2022 Tiempo Estándar: Tiempo Normal x (1 + Suplemento).", "item") + ins("") + + ins("3.7 Footer: Métricas Globales", "seccion") + ins("Campos editables (fondo amarillo):", "negrita") + ins(" \u2022 Piezas por golpe: Piezas producidas por ciclo.", "item") + ins(" \u2022 Artículos por golpe: Artículos diferentes por ciclo.", "item") + ins(" \u2022 Nº de operarios en máquina.", "item") + ins("") + ins("Campos de resultado (fondo verde, solo lectura):", "negrita") + ins(" \u2022 Tiempo estándar operación = Suma(TE) / Piezas por golpe [min/pieza].", "item") + ins(" \u2022 Cadencia = 60 / TE Operación [piezas/hora].", "item") + ins(" \u2022 Cadencia = 1000 / Cadencia piezas/hora [horas/1000 piezas].", "item") + ins("") + ins("Bloque Epicor (fondo azul, solo lectura):", "negrita") + ins(" \u2022 Estándar Epicor = Cadencia horas/1000 [hh/1000].", "item") + ins(" \u2022 Dotación Epicor = Nº de operarios en máquina.", "item") + ins("") + + ins("3.8 Importación desde Excel", "seccion") + ins("Menú Archivo \u2192 Importar desde Excel...", "negrita") + ins("El sistema lee automáticamente: cabecera (artículo, operación, máquina, operario, " + "fecha), grilla de tiempos (valoraciones, minutos, segundos) y parámetros (suplementos, " + "lote frecuencial, piezas/golpe). Los datos se cargan como borrador nuevo.") + ins("") + + ins("3.9 Finalización", "seccion") + ins("Botón 'Finalizar' (esquina inferior derecha). Opciones:", "negrita") + ins(" \u2022 Guardar en Base de Datos: Persiste el cronometraje completo en SQL Server.", "item") + ins(" \u2022 Guardar como PDF: Genera informe PDF (nombre sugerido = código artículo).", "item") + ins(" \u2022 Salir sin guardar: Limpia la plantilla y vuelve al Dashboard.", "item") + ins(" \u2022 Cancelar: Cierra el diálogo sin acción.", "item") + ins("") + ins("'Salir sin guardar' es irreversible. Asegúrese de guardar antes.", "nota") + sep() + + # --- SECCION 4 --- + ins("4. BUSCADOR HISTÓRICO", "subtitulo") + sep() + + ins("4.1 Acceso", "seccion") + ins(" \u2022 Botón 'Buscar' en la pestaña Inicio del Dashboard.", "item") + ins("") + + ins("4.2 Estructura", "seccion") + ins("Título: 'Buscador Histórico de Relevamientos'.", "negrita") + ins("Tabla con columnas: Fecha, Artículo, Operación, Máquina, HM, Operario.") + ins("Indicador de resultados en la esquina derecha.") + ins("") + + ins("4.3 Filtros Flotantes (tipo Excel)", "seccion") + ins(" 1. Haga clic en el encabezado de una columna filtrable.", "item") + ins(" 2. Aparece un desplegable flotante con valores únicos disponibles.", "item") + ins(" 3. Seleccione un valor o escriba directamente para buscar.", "item") + ins(" 4. Presione Enter o seleccione para aplicar.", "item") + ins(" 5. El encabezado filtrado se marca con un icono de búsqueda.", "item") + ins(" 6. Seleccione '(Todos)' para remover el filtro de esa columna.", "item") + ins(" 7. Puede combinar filtros en múltiples columnas (operación AND).", "item") + ins("") + ins("La columna Fecha no admite filtrado por clic.", "nota") + ins("") + + ins("4.4 Botones de Acción", "seccion") + ins(" \u2022 Volver al Home: Regresa al Dashboard.", "item") + ins(" \u2022 Limpiar Filtros: Remueve todos los filtros y recarga.", "item") + ins(" \u2022 Cargar Relevamiento: Abre el registro seleccionado en el Cronometraje.", "item") + ins(" \u2022 Borrar Planilla (solo JEFE/GERENTE): Eliminación permanente.", "item") + ins("") + ins("Doble clic en una fila equivale a presionar 'Cargar Relevamiento'.", "negrita") + ins("") + ins("La eliminación de planillas es permanente e irreversible.", "nota") + sep() + + # --- MENU SUPERIOR --- + ins("BARRA DE MENÚ SUPERIOR", "subtitulo") + sep() + ins("Se habilita tras iniciar sesión. Menú Archivo:", "negrita") + ins(" \u2022 Nueva plantilla: Abre plantilla de cronometraje vacía.", "item") + ins(" \u2022 Importar desde Excel...: Diálogo de selección de archivo.", "item") + ins(" \u2022 Volver al Home: Regresa al Dashboard.", "item") + ins(" \u2022 Cerrar Sesión: Cierra sesión y vuelve al Login.", "item") + ins("") + ins("Menú Herramientas:", "negrita") + ins(" \u2022 Generar Backup de Base de Datos: Crea una copia de seguridad.", "item") + ins("") + ins("Botón directo 'Manual de Usuario':", "negrita") + ins(" \u2022 Ubicado en la barra superior, al lado de Herramientas. Abre esta pantalla.", "item") + sep() + + # --- SECCION 5: BACKUP --- + ins("5. COPIA DE SEGURIDAD (BACKUP) DE LA BASE DE DATOS", "subtitulo") + sep() + ins("El sistema permite generar copias de seguridad de toda la información " + "almacenada para prevenir pérdidas de datos.") + ins("") + ins("Pasos para generar un Backup:", "negrita") + ins(" 1. En la barra superior de la pantalla principal, haz clic en el menú 'Herramientas'.", "item") + ins(" 2. Selecciona la opción 'Generar Backup de Base de Datos'.", "item") + ins(" 3. En la ventana emergente, selecciona la carpeta donde deseas guardar el archivo.", "item") + ins(" (Nota: Debe ser una ubicación válida en el equipo servidor).", "nota") + ins(" 4. Haz clic en 'Seleccionar Carpeta'.", "item") + ins(" 5. El sistema procesará la solicitud y mostrará un cartel de Éxito indicando " + "la ruta donde se guardó el archivo (.bak).", "item") + ins("") + ins("IMPORTANTE SOBRE LA RESTAURACIÓN:", "negrita") + ins("La restauración de un backup implica sobreescribir el motor de datos y matar " + "conexiones activas. En caso de emergencia, debe contactar al departamento de " + "Sistemas (Administrador de Base de Datos) para realizar la restauración " + "directamente en el servidor.", "nota") + sep() + + # --- GLOSARIO --- + ins("GLOSARIO DE TÉRMINOS", "subtitulo") + sep() + ins(" \u2022 Relevamiento: Estudio completo de cronometraje.", "item") + ins(" \u2022 Elemento: Parte medible de una operación.", "item") + ins(" \u2022 Valoración (V): Factor de ajuste según ritmo del operario (100% = normal).", "item") + ins(" \u2022 Suplemento: Porcentaje adicional por fatiga y necesidades personales.", "item") + ins(" \u2022 Tiempo Normal: Tiempo observado ajustado por valoración.", "item") + ins(" \u2022 Tiempo Estándar: Tiempo Normal + suplementos. Referencia final.", "item") + ins(" \u2022 Cadencia: Velocidad de producción (piezas/hora o horas/1000).", "item") + ins(" \u2022 Lote frecuencial: Cada cuántos ciclos se realiza un elemento.", "item") + ins(" \u2022 Epicor: Sistema ERP. Estándar y Dotación son datos de carga.", "item") + ins(" \u2022 RBAC: Control de Acceso Basado en Roles.", "item") + ins(" \u2022 Tarea: Solicitud de trabajo asignada a un Metodista.", "item") + ins("") + ins("Version Beta 0.15 - Junio 2026 - Ingenieria de Software", "nota")