3200 lines
154 KiB
Python
3200 lines
154 KiB
Python
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("<Return>", 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("<Return>", 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("<Double-1>", 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 <Visibility> 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("<<TreeviewSelect>>", self.on_tree_select)
|
|
self.tree.bind("<Double-1>", self.on_double_click)
|
|
|
|
self.tooltip_win = None
|
|
self.celda_actual_tooltip = ("", "")
|
|
self.tree.bind("<Motion>", self.manejar_tooltip_tabla)
|
|
self.tree.bind("<Leave>", 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("<KeyPress>", 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("<Return>", 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("<Return>", 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("<Return>", 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("<Return>", 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("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
|
|
self.tabla_resultados_frame.bind("<Configure>", 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("<Enter>", lambda e: self.canvas.bind_all("<MouseWheel>", _on_mousewheel))
|
|
self.canvas.bind("<Leave>", lambda e: self.canvas.unbind_all("<MouseWheel>"))
|
|
|
|
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("<KeyRelease>", lambda e: self.recalcular_todo())
|
|
self.ent_piezas_golpe.bind("<FocusOut>", 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("<KeyRelease>", lambda e: self.recalcular_todo())
|
|
self.ent_articulos_golpe.bind("<FocusOut>", 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("<KeyRelease>", lambda e: self.recalcular_todo())
|
|
self.ent_operarios_maq.bind("<FocusOut>", 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("<FocusIn>", on_focus_in)
|
|
ent_n.bind("<FocusOut>", 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("<<ComboboxSelected>>", 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("<KeyRelease>", lambda e, b=self.num_elementos: self._on_cell_edit(b))
|
|
en.bind("<Return>", 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("<FocusIn>", on_v_focus_in)
|
|
en.bind("<FocusOut>", on_v_focus_out)
|
|
elif c == 1:
|
|
en.config(state="readonly")
|
|
elif c == 2:
|
|
en.bind("<KeyRelease>", lambda e, b=self.num_elementos: self._on_cell_edit(b))
|
|
en.bind("<Return>", 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("<KeyRelease>", lambda e: self.recalcular_todo())
|
|
ent_bot.bind("<FocusOut>", 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("<Return>", 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("<Double-1>", self._on_doble_clic)
|
|
self.tree.bind("<Button-1>", 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("<Button-1>")
|
|
|
|
# ─── 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("<Return>", aplicar_filtro)
|
|
self._combo_flotante.bind("<<ComboboxSelected>>", aplicar_filtro)
|
|
self._combo_flotante.bind("<Escape>", cancelar)
|
|
self._combo_flotante.bind("<FocusOut>", 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 <Double-1> 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")
|