migrazione verso gitea
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
# =================== gestione_pickinglist.py (NO-FLICKER + UX TUNING + MICRO-SPINNER) ===================
|
||||
"""Picking list management window.
|
||||
|
||||
The module presents a master/detail UI for packing lists, supports reservation
|
||||
and unreservation through an async stored-procedure port and keeps rendering
|
||||
smooth by relying on deferred updates and lightweight progress indicators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import tkinter as tk
|
||||
@@ -10,9 +15,6 @@ from dataclasses import dataclass
|
||||
# Usa overlay e runner "collaudati"
|
||||
from gestione_aree_frame_async import BusyOverlay, AsyncRunner
|
||||
|
||||
from async_loop_singleton import get_global_loop
|
||||
from db_async_singleton import get_db as _get_db_singleton
|
||||
|
||||
# === IMPORT procedura async prenota/s-prenota (no pyodbc qui) ===
|
||||
import asyncio
|
||||
try:
|
||||
@@ -99,10 +101,11 @@ def _rows_to_dicts(res: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def _s(v) -> str:
|
||||
"""Stringify safe: None -> '', altrimenti str(v)."""
|
||||
"""Return a string representation, converting ``None`` to an empty string."""
|
||||
return "" if v is None else str(v)
|
||||
|
||||
def _first(d: Dict[str, Any], keys: List[str], default: str = ""):
|
||||
"""Return the first non-empty value found among the provided keys."""
|
||||
for k in keys:
|
||||
if k in d and d[k] not in (None, ""):
|
||||
return d[k]
|
||||
@@ -111,6 +114,8 @@ def _first(d: Dict[str, Any], keys: List[str], default: str = ""):
|
||||
# -------------------- column specs --------------------
|
||||
@dataclass
|
||||
class ColSpec:
|
||||
"""Describe one logical column rendered in a ``ScrollTable``."""
|
||||
|
||||
title: str
|
||||
key: str
|
||||
width: int
|
||||
@@ -149,6 +154,7 @@ class ToolbarSpinner:
|
||||
"""
|
||||
FRAMES = ("◐", "◓", "◑", "◒")
|
||||
def __init__(self, parent: tk.Widget):
|
||||
"""Create the spinner label attached to the given parent widget."""
|
||||
self.parent = parent
|
||||
self.lbl = ctk.CTkLabel(parent, text="", width=28)
|
||||
self._i = 0
|
||||
@@ -156,9 +162,11 @@ class ToolbarSpinner:
|
||||
self._job = None
|
||||
|
||||
def widget(self) -> ctk.CTkLabel:
|
||||
"""Return the label widget hosting the spinner animation."""
|
||||
return self.lbl
|
||||
|
||||
def start(self, text: str = ""):
|
||||
"""Start the animation and optionally show a short status message."""
|
||||
if self._active:
|
||||
return
|
||||
self._active = True
|
||||
@@ -166,6 +174,7 @@ class ToolbarSpinner:
|
||||
self._tick()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the animation and clear the label text."""
|
||||
self._active = False
|
||||
if self._job is not None:
|
||||
try:
|
||||
@@ -176,6 +185,7 @@ class ToolbarSpinner:
|
||||
self.lbl.configure(text="")
|
||||
|
||||
def _tick(self):
|
||||
"""Advance the spinner animation frame."""
|
||||
if not self._active:
|
||||
return
|
||||
self._i = (self._i + 1) % len(self.FRAMES)
|
||||
@@ -196,6 +206,7 @@ class ScrollTable(ctk.CTkFrame):
|
||||
PADY = 2
|
||||
|
||||
def __init__(self, master, columns: List[ColSpec]):
|
||||
"""Create a fixed-header scrollable table rendered with Tk/CTk widgets."""
|
||||
super().__init__(master)
|
||||
self.columns = columns
|
||||
self.total_w = sum(c.width for c in self.columns)
|
||||
@@ -236,6 +247,7 @@ class ScrollTable(ctk.CTkFrame):
|
||||
self._build_header()
|
||||
|
||||
def _build_header(self):
|
||||
"""Build the static header row using the configured columns."""
|
||||
for w in self.h_inner.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
@@ -260,6 +272,7 @@ class ScrollTable(ctk.CTkFrame):
|
||||
self.h_canvas.configure(scrollregion=(0,0,self.total_w,ROW_H))
|
||||
|
||||
def _update_body_width(self):
|
||||
"""Keep the scroll region aligned with the current body content width."""
|
||||
self.b_canvas.itemconfigure(self.body_window, width=self.total_w)
|
||||
sr = self.b_canvas.bbox("all")
|
||||
if sr:
|
||||
@@ -268,22 +281,27 @@ class ScrollTable(ctk.CTkFrame):
|
||||
self.b_canvas.configure(scrollregion=(0,0,self.total_w,0))
|
||||
|
||||
def _on_body_configure(self):
|
||||
"""React to body resize events by syncing dimensions and header scroll."""
|
||||
self._update_body_width()
|
||||
self._sync_header_width()
|
||||
|
||||
def _sync_header_width(self):
|
||||
"""Mirror the body horizontal scroll position on the header canvas."""
|
||||
first, _ = self.b_canvas.xview()
|
||||
self.h_canvas.xview_moveto(first)
|
||||
|
||||
def _xscroll_both(self, *args):
|
||||
"""Scroll header and body together when the horizontal bar moves."""
|
||||
self.h_canvas.xview(*args)
|
||||
self.b_canvas.xview(*args)
|
||||
|
||||
def _xscroll_set_both(self, first, last):
|
||||
"""Update the header viewport and scrollbar thumb in one place."""
|
||||
self.h_canvas.xview_moveto(first)
|
||||
self.xbar.set(first, last)
|
||||
|
||||
def clear_rows(self):
|
||||
"""Remove all rendered body rows."""
|
||||
for w in self.b_inner.winfo_children():
|
||||
w.destroy()
|
||||
self._update_body_width()
|
||||
@@ -295,6 +313,7 @@ class ScrollTable(ctk.CTkFrame):
|
||||
anchors: Optional[List[str]] = None,
|
||||
checkbox_builder: Optional[Callable[[tk.Widget], ctk.CTkCheckBox]] = None,
|
||||
):
|
||||
"""Append one row to the table body."""
|
||||
row = ctk.CTkFrame(self.b_inner, fg_color="transparent",
|
||||
height=ROW_H, width=self.total_w)
|
||||
row.pack(fill="x", expand=False)
|
||||
@@ -326,13 +345,24 @@ class ScrollTable(ctk.CTkFrame):
|
||||
|
||||
# -------------------- PL row model --------------------
|
||||
class PLRow:
|
||||
"""State holder for one picking list row and its selection checkbox."""
|
||||
|
||||
def __init__(self, pl: Dict[str, Any], on_check):
|
||||
"""Bind a picking list payload to a ``BooleanVar`` and callback."""
|
||||
self.pl = pl
|
||||
self.var = ctk.BooleanVar(value=False)
|
||||
self._callback = on_check
|
||||
def is_checked(self) -> bool: return self.var.get()
|
||||
def set_checked(self, val: bool): self.var.set(val)
|
||||
|
||||
def is_checked(self) -> bool:
|
||||
"""Return whether the row is currently selected."""
|
||||
return self.var.get()
|
||||
|
||||
def set_checked(self, val: bool):
|
||||
"""Programmatically update the checkbox state."""
|
||||
self.var.set(val)
|
||||
|
||||
def build_checkbox(self, parent) -> ctk.CTkCheckBox:
|
||||
"""Create the checkbox widget bound to this row model."""
|
||||
return ctk.CTkCheckBox(parent, text="", variable=self.var,
|
||||
command=lambda: self._callback(self, self.var.get()))
|
||||
|
||||
@@ -340,8 +370,11 @@ class PLRow:
|
||||
# -------------------- main frame (no-flicker + UX tuning + spinner) --------------------
|
||||
class GestionePickingListFrame(ctk.CTkFrame):
|
||||
def __init__(self, master, *, db_client=None, conn_str=None):
|
||||
"""Create the master/detail picking list frame."""
|
||||
super().__init__(master)
|
||||
self.db_client = db_client or _get_db_singleton(get_global_loop(), conn_str)
|
||||
if db_client is None:
|
||||
raise ValueError("GestionePickingListFrame richiede un db_client condiviso.")
|
||||
self.db_client = db_client
|
||||
self.runner = AsyncRunner(self) # runner condiviso (usa loop globale)
|
||||
self.busy = BusyOverlay(self) # overlay collaudato
|
||||
|
||||
@@ -350,6 +383,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
self.detail_doc = None
|
||||
|
||||
self._first_loading: bool = False # flag per cursore d'attesa solo al primo load
|
||||
self._render_job = None # Tracking del job di rendering in corso
|
||||
|
||||
self._build_layout()
|
||||
# 🔇 Niente reload immediato: carichiamo quando la finestra è idle (= già resa)
|
||||
@@ -368,6 +402,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
|
||||
# ---------- UI ----------
|
||||
def _build_layout(self):
|
||||
"""Build toolbar, master table and detail table."""
|
||||
for r in (1, 3): self.grid_rowconfigure(r, weight=1)
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
|
||||
@@ -394,6 +429,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
self._draw_details_hint()
|
||||
|
||||
def _draw_details_hint(self):
|
||||
"""Render the placeholder row shown when no document is selected."""
|
||||
self.det_table.clear_rows()
|
||||
self.det_table.add_row(
|
||||
values=["", "", "", "Seleziona una Picking List per vedere le UDC…", "", ""],
|
||||
@@ -414,6 +450,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
pass
|
||||
|
||||
def _refresh_mid_rows(self, rows: List[Dict[str, Any]]):
|
||||
"""Rebuild the master table using the latest query results."""
|
||||
self.pl_table.clear_rows()
|
||||
self.rows_models.clear()
|
||||
|
||||
@@ -443,6 +480,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
|
||||
# ----- helpers -----
|
||||
def _get_selected_model(self) -> Optional[PLRow]:
|
||||
"""Return the currently checked picking list row, if any."""
|
||||
for m in self.rows_models:
|
||||
if m.is_checked():
|
||||
return m
|
||||
@@ -480,6 +518,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
|
||||
# ----- eventi -----
|
||||
def on_row_checked(self, model: PLRow, is_checked: bool):
|
||||
"""Handle row selection changes and refresh the detail section."""
|
||||
# selezione esclusiva
|
||||
if is_checked:
|
||||
for m in self.rows_models:
|
||||
@@ -493,22 +532,25 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
return await self.db_client.query_json(SQL_PL_DETAILS, {"Documento": self.detail_doc})
|
||||
|
||||
def _ok(res):
|
||||
self.spinner.stop() # spinner OFF
|
||||
# NON fermare lo spinner subito: lo farà _refresh_details_incremental
|
||||
self._detail_cache[self.detail_doc] = _rows_to_dicts(res)
|
||||
# differisci il render dei dettagli (più fluido)
|
||||
self.after_idle(self._refresh_details)
|
||||
# Avvia il rendering incrementale che mantiene l'overlay attivo
|
||||
self._refresh_details_incremental()
|
||||
|
||||
def _err(ex):
|
||||
self.spinner.stop()
|
||||
self.busy.hide() # Chiudi l'overlay in caso di errore
|
||||
messagebox.showerror("DB", f"Errore nel caricamento dettagli:\n{ex}")
|
||||
|
||||
self.runner.run(
|
||||
_job(),
|
||||
on_success=_ok,
|
||||
on_error=_err,
|
||||
busy=self.busy,
|
||||
busy=None, # NON usare busy automatico: lo gestiamo manualmente nel rendering
|
||||
message=f"Carico UDC per Documento {self.detail_doc}…"
|
||||
)
|
||||
# Mostra manualmente l'overlay per la query
|
||||
self.busy.show(f"Carico UDC per Documento {self.detail_doc}…")
|
||||
|
||||
else:
|
||||
if not any(m.is_checked() for m in self.rows_models):
|
||||
@@ -517,6 +559,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
|
||||
# ----- load PL -----
|
||||
def reload_from_db(self, first: bool = False):
|
||||
"""Load or reload the picking list summary table from the database."""
|
||||
self.spinner.start(" Carico…") # spinner ON
|
||||
async def _job():
|
||||
return await self.db_client.query_json(SQL_PL, {})
|
||||
@@ -550,6 +593,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
)
|
||||
|
||||
def _refresh_details(self):
|
||||
"""Render the detail table for the currently selected document."""
|
||||
self.det_table.clear_rows()
|
||||
if not self.detail_doc:
|
||||
self._draw_details_hint()
|
||||
@@ -576,8 +620,74 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
anchors=[c.anchor for c in DET_COLS]
|
||||
)
|
||||
|
||||
def _refresh_details_incremental(self, batch_size: int = 25):
|
||||
"""
|
||||
Render detail table incrementally in batches to keep UI responsive.
|
||||
Mantiene l'overlay visibile fino al completamento del rendering.
|
||||
"""
|
||||
self.det_table.clear_rows()
|
||||
if not self.detail_doc:
|
||||
self._draw_details_hint()
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
return
|
||||
|
||||
rows = self._detail_cache.get(self.detail_doc, [])
|
||||
if not rows:
|
||||
self.det_table.add_row(values=["", "", "", "Nessuna UDC trovata.", "", ""],
|
||||
row_index=0, anchors=["w"]*6)
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
return
|
||||
|
||||
# Inizia il rendering incrementale
|
||||
total_rows = len(rows)
|
||||
self.busy.show(f"Rendering {len(rows)} UDC...")
|
||||
self._render_batch(rows, batch_size, 0, total_rows)
|
||||
|
||||
def _render_batch(self, rows: List[Dict[str, Any]], batch_size: int, start_idx: int, total_rows: int):
|
||||
"""
|
||||
Render a batch of rows and schedule the next batch.
|
||||
Mantiene lo spinner attivo fino all'ultimo batch.
|
||||
"""
|
||||
end_idx = min(start_idx + batch_size, total_rows)
|
||||
|
||||
# Aggiorna lo spinner con il progresso
|
||||
progress_pct = int((end_idx / total_rows) * 100)
|
||||
self.spinner.lbl.configure(text=f"◐ Rendering {progress_pct}%")
|
||||
|
||||
# Aggiorna anche il messaggio dell'overlay
|
||||
self.busy.show(f"Rendering {progress_pct}% ({end_idx}/{total_rows} UDC)...")
|
||||
|
||||
# Renderizza il batch corrente
|
||||
for r in range(start_idx, end_idx):
|
||||
d = rows[r]
|
||||
pallet = _s(_first(d, ["Pallet", "UDC", "PalletID"]))
|
||||
lotto = _s(_first(d, ["Lotto"]))
|
||||
articolo = _s(_first(d, ["Articolo", "CodArticolo", "CodiceArticolo", "Art", "Codice"]))
|
||||
descr = _s(_first(d, ["Descrizione", "Descr", "DescrArticolo", "DescArticolo", "DesArticolo"]))
|
||||
qta = _s(_first(d, ["Qta", "Quantita", "Qty", "QTY"]))
|
||||
ubi_raw = _first(d, ["Ubicazione", "Cella", "PalletCella"])
|
||||
loc = "Non scaffalata" if (ubi_raw is None or str(ubi_raw).strip()=="") else str(ubi_raw).strip()
|
||||
|
||||
self.det_table.add_row(
|
||||
values=[pallet, lotto, articolo, descr, qta, loc],
|
||||
row_index=r,
|
||||
anchors=[c.anchor for c in DET_COLS]
|
||||
)
|
||||
|
||||
# Se ci sono ancora righe da renderizzare, schedula il prossimo batch
|
||||
if end_idx < total_rows:
|
||||
# Lascia respirare Tk tra i batch (10ms)
|
||||
self.after(10, lambda: self._render_batch(rows, batch_size, end_idx, total_rows))
|
||||
else:
|
||||
# Ultimo batch completato: ferma lo spinner e chiudi l'overlay
|
||||
self.spinner.stop()
|
||||
self.busy.hide()
|
||||
|
||||
# ----- azioni -----
|
||||
def on_prenota(self):
|
||||
"""Reserve the selected picking list."""
|
||||
model = self._get_selected_model()
|
||||
if not model:
|
||||
messagebox.showinfo("Prenota", "Seleziona una Picking List (checkbox) prima di prenotare.")
|
||||
@@ -617,6 +727,7 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
)
|
||||
|
||||
def on_sprenota(self):
|
||||
"""Unreserve the selected picking list."""
|
||||
model = self._get_selected_model()
|
||||
if not model:
|
||||
messagebox.showinfo("S-prenota", "Seleziona una Picking List (checkbox) prima di s-prenotare.")
|
||||
@@ -656,13 +767,15 @@ class GestionePickingListFrame(ctk.CTkFrame):
|
||||
)
|
||||
|
||||
def on_export(self):
|
||||
"""Placeholder for a future export implementation."""
|
||||
messagebox.showinfo("Esporta", "Stub esportazione.")
|
||||
|
||||
|
||||
# factory per main
|
||||
def create_frame(parent, *, db_client=None, conn_str=None) -> 'GestionePickingListFrame':
|
||||
"""Factory used by the launcher to build the picking list frame."""
|
||||
ctk.set_appearance_mode("light")
|
||||
ctk.set_default_color_theme("green")
|
||||
return GestionePickingListFrame(parent, db_client=db_client, conn_str=conn_str)
|
||||
return GestionePickingListFrame(parent, db_client=db_client)
|
||||
|
||||
# =================== /gestione_pickinglist.py ===================
|
||||
|
||||
Reference in New Issue
Block a user