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")