migrazione verso gitea

This commit is contained in:
2026-03-31 19:15:33 +02:00
parent 8806d598eb
commit f6a5b1b29f
118 changed files with 17197 additions and 459 deletions

View File

@@ -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 ===================