Files
WebChecker/views/admin_shifts_view.py

782 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
views/admin_shifts_view.py — Admin panel: Shift Management.
Allows admins to:
• Create / edit / soft-delete shifts
• Name each shift and add an optional note
• Set the days of the week the shift runs
• Set a start time and end time
• Assign one or more users to the shift
• Assign one or more websites to the shift (with drag-reorder)
"""
import tkinter as tk
from tkinter import ttk
import logging
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
scrolled_text, show_error, show_info, confirm_delete,
)
logger = logging.getLogger("admin_shifts_view")
# Day labels: index 0 = Monday (ISO), stored as MySQL DAYOFWEEK digits
# MySQL: 1=Sun 2=Mon 3=Tue 4=Wed 5=Thu 6=Fri 7=Sat
DAY_MAP = [
("Mon", "2"), ("Tue", "3"), ("Wed", "4"),
("Thu", "5"), ("Fri", "6"), ("Sat", "7"), ("Sun", "1"),
]
class AdminShiftsView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._show_inactive = tk.BooleanVar(value=False)
self._build_ui()
self._load_shifts()
# ─── Layout ───────────────────────────────────────────────────────────────
def _build_ui(self):
toolbar = ttk.Frame(self)
toolbar.pack(fill="x", pady=(0, 10))
ttk.Label(toolbar, text="Shift Management",
style="Heading.TLabel").pack(side="left")
ttk.Button(toolbar, text=" New Shift",
command=self._open_add).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✎ Edit",
style="Ghost.TButton",
command=self._open_edit).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✕ Delete",
style="Danger.TButton",
command=self._delete_selected).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="⬇ Export PDF",
style="Ghost.TButton",
command=self._export_pdf).pack(side="right", padx=(0, 12))
# Show / hide inactive shifts toggle
ttk.Checkbutton(
toolbar,
text="Show inactive",
variable=self._show_inactive,
command=self._load_shifts,
style="TCheckbutton",
).pack(side="left", padx=(16, 0))
cols = ("ID", "Shift Name", "Days", "Start", "End",
"Users", "Websites", "Active", "Note")
self.tree = ttk.Treeview(self, columns=cols,
show="headings", selectmode="browse")
widths = [40, 160, 130, 70, 70, 60, 70, 60, 200]
for col, w in zip(cols, widths):
self.tree.heading(col, text=col)
self.tree.column(col, width=w,
anchor="center" if w <= 70 else "w")
self.tree.pack(fill="both", expand=True)
self.tree.bind("<Double-1>", lambda _: self._open_edit())
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=vsb.set)
vsb.place(relx=1, rely=0, relheight=1, anchor="ne")
# Tag active vs inactive rows
self.tree.tag_configure("inactive", foreground=COLOURS["text_dim"])
# ─── Data ─────────────────────────────────────────────────────────────────
def _load_shifts(self):
from models import get_all_shifts
self.tree.delete(*self.tree.get_children())
show_inactive = self._show_inactive.get()
try:
all_shifts = get_all_shifts()
shown = 0
for s in all_shifts:
if not s["is_active"] and not show_inactive:
continue
days_str = _days_label(s["days_of_week"])
active = "✔" if s["is_active"] else "✘"
tag = "active" if s["is_active"] else "inactive"
self.tree.insert(
"", "end", iid=str(s["id"]), tags=(tag,),
values=(
s["id"],
s["name"],
days_str,
str(s["start_time"]),
str(s["end_time"]),
s["user_count"],
s["website_count"],
active,
(s["note"] or "")[:60],
)
)
shown += 1
hidden = len(all_shifts) - shown
if hidden and not show_inactive:
logger.debug(f"Shifts view: {hidden} inactive shift(s) hidden.")
except Exception as e:
show_error(f"Failed to load shifts:\n{e}")
def _get_selected_id(self):
sel = self.tree.selection()
return int(sel[0]) if sel else None
# ─── Export ───────────────────────────────────────────────────────────────
def _export_pdf(self):
"""Export the full shift schedule to a PDF file."""
from tkinter import filedialog
from models import get_all_shifts, get_shift_assigned_users, get_shift_assigned_websites
import datetime
save_path = filedialog.asksaveasfilename(
title="Save Shift Schedule PDF",
defaultextension=".pdf",
filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")],
initialfile=f"shift_schedule_{datetime.date.today().isoformat()}.pdf",
)
if not save_path:
return
try:
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
)
shifts = get_all_shifts()
doc = SimpleDocTemplate(save_path, pagesize=A4,
leftMargin=2*cm, rightMargin=2*cm,
topMargin=2*cm, bottomMargin=2*cm)
styles = getSampleStyleSheet()
ACCENT = colors.HexColor("#5b4de8")
LIGHT = colors.HexColor("#e0dff8")
DARK = colors.HexColor("#1a1a2e")
DIMGREY = colors.HexColor("#6b6b80")
title_style = ParagraphStyle("Title", parent=styles["Title"],
textColor=ACCENT, fontSize=20, spaceAfter=4)
sub_style = ParagraphStyle("Sub", parent=styles["Normal"],
textColor=DIMGREY, fontSize=10, spaceAfter=16)
h2_style = ParagraphStyle("H2", parent=styles["Heading2"],
textColor=ACCENT, fontSize=13, spaceBefore=14)
body_style = ParagraphStyle("Body", parent=styles["Normal"],
textColor=DARK, fontSize=10, leading=14)
dim_style = ParagraphStyle("Dim", parent=styles["Normal"],
textColor=DIMGREY, fontSize=9, leading=12)
story = [
Paragraph("Shift Schedule", title_style),
Paragraph(f"Generated {datetime.datetime.now().strftime('%d %B %Y, %H:%M')}",
sub_style),
HRFlowable(width="100%", thickness=1, color=ACCENT),
Spacer(1, 0.4*cm),
]
for s in shifts:
if not s["is_active"]:
continue
days_str = _days_label(s["days_of_week"])
start = str(s.get("start_time", ""))[:5]
end = str(s.get("end_time", ""))[:5]
story.append(Paragraph(s["name"], h2_style))
story.append(Paragraph(
f"<b>Days:</b> {days_str} &nbsp;&nbsp; "
f"<b>Time:</b> {start} {end} &nbsp;&nbsp; "
f"<b>Users:</b> {s['user_count']} &nbsp;&nbsp; "
f"<b>Websites:</b> {s['website_count']}",
body_style))
if s.get("note"):
story.append(Paragraph(f"<i>{s['note']}</i>", dim_style))
story.append(Spacer(1, 0.25*cm))
# Users table
users = get_shift_assigned_users(s["id"])
if users:
user_data = [["Assigned Users", ""]]
for u in users:
user_data.append([u["username"], u["full_name"] or ""])
t = Table(user_data, colWidths=[5*cm, 8*cm])
t.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), LIGHT),
("TEXTCOLOR", (0,0), (-1,0), ACCENT),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 9),
("ROWBACKGROUNDS", (0,1), (-1,-1), [colors.white, colors.HexColor("#f5f5fa")]),
("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#c5c5d8")),
("LEFTPADDING", (0,0), (-1,-1), 8),
("TOPPADDING", (0,0), (-1,-1), 4),
("BOTTOMPADDING", (0,0), (-1,-1), 4),
]))
story.append(t)
story.append(Spacer(1, 0.2*cm))
# Websites table
sites = get_shift_assigned_websites(s["id"])
if sites:
site_data = [["#", "Website", "URL"]]
for i, w in enumerate(sites, 1):
site_data.append([str(i), w["name"], w["url"]])
t = Table(site_data, colWidths=[1*cm, 5*cm, 10*cm])
t.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), LIGHT),
("TEXTCOLOR", (0,0), (-1,0), ACCENT),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 9),
("ROWBACKGROUNDS", (0,1), (-1,-1), [colors.white, colors.HexColor("#f5f5fa")]),
("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#c5c5d8")),
("LEFTPADDING", (0,0), (-1,-1), 6),
("TOPPADDING", (0,0), (-1,-1), 4),
("BOTTOMPADDING", (0,0), (-1,-1), 4),
]))
story.append(t)
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=0.5,
color=colors.HexColor("#c5c5d8")))
story.append(Spacer(1, 0.3*cm))
doc.build(story)
from models import log_action
log_action(self.current_user["id"], "EXPORT_SHIFT_PDF", "shifts",
None, f"Shift schedule exported to PDF: {save_path}")
logger.info(f"Shift schedule PDF exported: {save_path}")
from utils.ui_helpers import show_info
show_info(f"PDF exported successfully.\n\n{save_path}")
except ImportError:
from utils.ui_helpers import show_error
show_error("reportlab is required for PDF export.\n"
"Install it with: pip install reportlab")
except Exception as e:
from utils.ui_helpers import show_error
show_error(f"PDF export failed:\n{e}")
# ─── Actions ──────────────────────────────────────────────────────────────
def _open_add(self):
ShiftDialog(self, self.current_user, shift_data=None,
on_save=self._load_shifts)
def _open_edit(self):
sid = self._get_selected_id()
if not sid:
show_error("Please select a shift to edit.")
return
from models import (get_shift_by_id, get_shift_assigned_users,
get_shift_assigned_websites)
data = get_shift_by_id(sid)
data["users"] = get_shift_assigned_users(sid)
data["websites"] = get_shift_assigned_websites(sid)
ShiftDialog(self, self.current_user, shift_data=data,
on_save=self._load_shifts)
def _delete_selected(self):
sid = self._get_selected_id()
if not sid:
show_error("Please select a shift to delete.")
return
vals = self.tree.item(sid, "values")
name = vals[1] if vals else str(sid)
if confirm_delete(name):
try:
from models import delete_shift
delete_shift(self.current_user["id"], sid)
logger.info(f"Shift id={sid} deleted by admin "
f"{self.current_user['username']}.")
show_info(f"Shift '{name}' deactivated successfully.")
self._load_shifts()
except Exception as e:
show_error(f"Delete failed:\n{e}")
# ─── Shift Dialog (Add / Edit) ────────────────────────────────────────────────
class ShiftDialog(tk.Toplevel):
def __init__(self, parent, current_user, shift_data, on_save):
super().__init__(parent)
self.current_user = current_user
self.shift_data = shift_data
self.on_save = on_save
self.is_edit = shift_data is not None
self.title("Edit Shift" if self.is_edit else "New Shift")
self.configure(bg=COLOURS["bg"])
self.grab_set()
self.resizable(True, True)
self._all_users = []
self._all_websites = []
self._load_options()
self._build_ui()
self._centre()
if self.is_edit:
self._populate()
def _centre(self):
self.update_idletasks()
w, h = 860, 680
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _load_options(self):
from models import get_all_users, get_all_websites
try:
self._all_users = [u for u in get_all_users()
if u["is_active"] and u["role"] == "user"]
self._all_websites = [w for w in get_all_websites()]
except Exception as e:
show_error(f"Failed to load options:\n{e}")
# ─── UI ───────────────────────────────────────────────────────────────────
def _build_ui(self):
# ── Header ────────────────────────────────────────────────────────────
ttk.Label(self,
text="Edit Shift" if self.is_edit else "New Shift",
style="Heading.TLabel").pack(anchor="w", padx=20, pady=(16, 4))
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=20, pady=(0, 10))
# ── Main body (left details | right assignment panels) ────────────────
body = ttk.Frame(self)
body.pack(fill="both", expand=True, padx=20)
body.columnconfigure(0, weight=0, minsize=280)
body.columnconfigure(1, weight=1)
body.rowconfigure(0, weight=1)
self._build_details_panel(body)
self._build_assignment_panel(body)
# ── Bottom buttons ────────────────────────────────────────────────────
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=20, pady=(10, 6))
btn_row = ttk.Frame(self)
btn_row.pack(fill="x", padx=20, pady=(0, 16))
ttk.Button(btn_row, text="Save Shift",
command=self._save).pack(side="right", padx=(8, 0))
ttk.Button(btn_row, text="Cancel", style="Ghost.TButton",
command=self.destroy).pack(side="right")
def _build_details_panel(self, parent):
"""Left column: name, time, days, active, note."""
pane = tk.Frame(parent, bg=COLOURS["surface"], padx=16, pady=16)
pane.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
pane.columnconfigure(1, weight=1)
ttk.Label(pane, text="Shift Details",
style="Heading.TLabel",
background=COLOURS["surface"]).grid(
row=0, column=0, columnspan=2, sticky="w", pady=(0, 12))
def field(label, row):
tk.Label(pane, text=label, bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=row, column=0, sticky="w", padx=(0, 8), pady=4)
var = tk.StringVar()
ent = ttk.Entry(pane, textvariable=var)
ent.grid(row=row, column=1, sticky="ew", pady=4)
return var
self.name_var = field("Shift Name *", 1)
self.start_time_var = field("Start Time (HH:MM)", 2)
self.end_time_var = field("End Time (HH:MM)", 3)
# Days of week checkboxes
tk.Label(pane, text="Days of Week", bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=4, column=0, sticky="nw", pady=(8, 0))
days_frame = tk.Frame(pane, bg=COLOURS["surface"])
days_frame.grid(row=4, column=1, sticky="w", pady=(8, 0))
self._day_vars = {}
for day_lbl, digit in DAY_MAP:
var = tk.BooleanVar(value=True)
self._day_vars[digit] = var
cb = tk.Checkbutton(
days_frame, text=day_lbl, variable=var,
bg=COLOURS["surface"], fg=COLOURS["text"],
activebackground=COLOURS["surface"],
activeforeground=COLOURS["accent"],
selectcolor=COLOURS["surface2"],
font=FONT_SMALL, cursor="hand2",
)
cb.pack(side="left", padx=2)
# Active toggle
tk.Label(pane, text="Active", bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=5, column=0, sticky="w", pady=8)
self.active_var = tk.BooleanVar(value=True)
tk.Checkbutton(
pane, variable=self.active_var,
bg=COLOURS["surface"], activebackground=COLOURS["surface"],
selectcolor=COLOURS["surface2"],
).grid(row=5, column=1, sticky="w", pady=8)
# Note
tk.Label(pane, text="Note", bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=6, column=0, sticky="nw", pady=4)
note_frame, self.note_txt = scrolled_text(pane, height=5, width=28)
note_frame.grid(row=6, column=1, sticky="ew", pady=4)
def _build_assignment_panel(self, parent):
"""Right column: dual-list pickers for users and websites."""
pane = ttk.Frame(parent)
pane.grid(row=0, column=1, sticky="nsew")
pane.rowconfigure(0, weight=1)
pane.rowconfigure(1, weight=1)
pane.columnconfigure(0, weight=1)
# Users picker
self._user_picker = _DualListPicker(
pane,
title="Assigned Users",
all_items=[(u["id"], f"{u['username']}{u['full_name'] or ''}",)
for u in self._all_users],
)
self._user_picker.grid(row=0, column=0, sticky="nsew", pady=(0, 8))
# Websites picker — include check_type suffix in label and colour map
_TYPE_SUFFIX = {"daily": " [D]", "weekly": " [W]"}
_TYPE_COLOUR = {
"daily": COLOURS.get("accent", "#5B4DE8"),
"weekly": COLOURS.get("warning", "#F57C00"),
}
site_items = [
(w["id"], w["name"] + _TYPE_SUFFIX.get(w.get("check_type", "daily"), ""))
for w in self._all_websites
]
site_colours = {
w["id"]: _TYPE_COLOUR.get(w.get("check_type", "daily"), COLOURS["text"])
for w in self._all_websites
}
self._site_picker = _DualListPicker(
pane,
title="Assigned Websites",
all_items=site_items,
allow_reorder=True,
item_colours=site_colours,
)
self._site_picker.grid(row=1, column=0, sticky="nsew")
# ─── Pre-populate ─────────────────────────────────────────────────────────
def _populate(self):
d = self.shift_data
self.name_var.set(d.get("name") or "")
# Normalise timedelta → HH:MM string (MySQL returns timedelta for TIME)
self.start_time_var.set(_time_to_str(d.get("start_time")))
self.end_time_var.set(_time_to_str(d.get("end_time")))
dow = d.get("days_of_week") or "1234567"
for digit, var in self._day_vars.items():
var.set(digit in dow)
self.active_var.set(bool(d.get("is_active", 1)))
self.note_txt.insert("1.0", d.get("note") or "")
assigned_user_ids = [u["id"] for u in d.get("users", [])]
assigned_website_ids = [w["id"] for w in d.get("websites", [])]
self._user_picker.set_selected(assigned_user_ids)
self._site_picker.set_selected(assigned_website_ids)
# ─── Save ─────────────────────────────────────────────────────────────────
def _save(self):
name = self.name_var.get().strip()
start_time = self.start_time_var.get().strip() or "00:00"
end_time = self.end_time_var.get().strip() or "23:59"
note = self.note_txt.get("1.0", "end-1c").strip()
is_active = int(self.active_var.get())
if not name:
show_error("Shift name is required.")
return
if not _valid_time(start_time) or not _valid_time(end_time):
show_error("Times must be in HH:MM format (e.g. 08:00).")
return
days_of_week = "".join(d for d, v in self._day_vars.items() if v.get())
if not days_of_week:
show_error("Please select at least one day of the week.")
return
user_ids = self._user_picker.get_selected_ids()
website_ids = self._site_picker.get_selected_ids()
try:
if self.is_edit:
from models import update_shift
update_shift(
self.current_user["id"],
self.shift_data["id"],
name, days_of_week, start_time, end_time,
note, is_active, user_ids, website_ids,
)
logger.info(f"Shift id={self.shift_data['id']} updated "
f"by {self.current_user['username']}.")
show_info("Shift updated successfully.")
else:
from models import create_shift
create_shift(
self.current_user["id"],
name, days_of_week, start_time, end_time,
note, user_ids, website_ids,
)
logger.info(f"New shift '{name}' created "
f"by {self.current_user['username']}.")
show_info("Shift created successfully.")
self.on_save()
self.destroy()
except Exception as e:
show_error(f"Save failed:\n{e}")
# ─── Dual-List Picker Widget ──────────────────────────────────────────────────
class _DualListPicker(ttk.Frame):
"""
A reusable dual-listbox: Available (left) ↔ Assigned (right).
Supports optional drag-to-reorder on the assigned list.
"""
def __init__(self, parent, title: str, all_items: list,
allow_reorder=False, item_colours: dict = None):
super().__init__(parent)
self._all_items = all_items # [(id, label), ...]
self._allow_reorder = allow_reorder
self._drag_start = None
# item_colours: {id: colour_str} — applied per-item after refresh
self._item_colours = item_colours or {}
self._build(title)
def _build(self, title: str):
self.columnconfigure(0, weight=1)
self.columnconfigure(2, weight=1)
self.rowconfigure(1, weight=1)
ttk.Label(self, text=title,
style="Heading.TLabel").grid(
row=0, column=0, columnspan=3, sticky="w", pady=(4, 2))
# Colour legend — only shown when item_colours are provided
if self._item_colours:
legend = tk.Frame(self, bg=COLOURS["bg"])
legend.grid(row=0, column=0, columnspan=3, sticky="e", pady=(4, 2))
for lbl_text, colour in [
("● Daily", COLOURS.get("accent", "#5B4DE8")),
("● Weekly", COLOURS.get("warning", "#F57C00")),
]:
tk.Label(legend, text=lbl_text,
bg=COLOURS["bg"], fg=colour,
font=FONT_SMALL).pack(side="left", padx=(0, 10))
# ── Available list ────────────────────────────────────────────────────
avail_frame = tk.Frame(self, bg=COLOURS["surface"])
avail_frame.grid(row=1, column=0, sticky="nsew")
tk.Label(avail_frame, text="Available",
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).pack(anchor="w", padx=4)
av_lb_frame = tk.Frame(avail_frame, bg=COLOURS["surface"])
av_lb_frame.pack(fill="both", expand=True)
av_sb = tk.Scrollbar(av_lb_frame, bg=COLOURS["surface2"])
av_sb.pack(side="right", fill="y")
self._avail_lb = tk.Listbox(
av_lb_frame, selectmode="extended", height=8,
bg=COLOURS["surface2"], fg=COLOURS["text"],
selectbackground=COLOURS["accent"], selectforeground=COLOURS["white"],
relief="flat", font=FONT, activestyle="none",
yscrollcommand=av_sb.set,
)
self._avail_lb.pack(side="left", fill="both", expand=True)
av_sb.config(command=self._avail_lb.yview)
self._avail_lb.bind("<Double-Button-1>", lambda _: self._add())
# ── Arrow buttons ─────────────────────────────────────────────────────
btn_col = tk.Frame(self, bg=COLOURS["bg"])
btn_col.grid(row=1, column=1, padx=6)
def arrow_btn(text, cmd):
return tk.Button(
btn_col, text=text, command=cmd,
bg=COLOURS["surface2"], fg=COLOURS["text"],
activebackground=COLOURS["accent"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_BOLD,
cursor="hand2", width=4, pady=4,
)
arrow_btn("→", self._add).pack(pady=4)
arrow_btn("←", self._remove).pack(pady=4)
arrow_btn("→→", self._add_all).pack(pady=(12, 4))
arrow_btn("←←", self._remove_all).pack(pady=4)
if self._allow_reorder:
arrow_btn("↑", self._move_up).pack(pady=(12, 4))
arrow_btn("↓", self._move_down).pack(pady=4)
# ── Assigned list ─────────────────────────────────────────────────────
assign_frame = tk.Frame(self, bg=COLOURS["surface"])
assign_frame.grid(row=1, column=2, sticky="nsew")
tk.Label(assign_frame, text="Assigned",
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).pack(anchor="w", padx=4)
as_lb_frame = tk.Frame(assign_frame, bg=COLOURS["surface"])
as_lb_frame.pack(fill="both", expand=True)
as_sb = tk.Scrollbar(as_lb_frame, bg=COLOURS["surface2"])
as_sb.pack(side="right", fill="y")
self._assign_lb = tk.Listbox(
as_lb_frame, selectmode="extended", height=8,
bg=COLOURS["surface2"], fg=COLOURS["text"],
selectbackground=COLOURS["accent"], selectforeground=COLOURS["white"],
relief="flat", font=FONT, activestyle="none",
yscrollcommand=as_sb.set,
)
self._assign_lb.pack(side="left", fill="both", expand=True)
as_sb.config(command=self._assign_lb.yview)
self._assign_lb.bind("<Double-Button-1>", lambda _: self._remove())
# Internal data: parallel lists of (id, label)
self._avail_data = list(self._all_items)
self._assign_data = []
self._refresh_listboxes()
# ─── Operations ───────────────────────────────────────────────────────────
def _refresh_listboxes(self):
self._avail_lb.delete(0, "end")
for idx, (id_, lbl) in enumerate(self._avail_data):
self._avail_lb.insert("end", lbl)
if id_ in self._item_colours:
self._avail_lb.itemconfig(idx, fg=self._item_colours[id_])
self._assign_lb.delete(0, "end")
for idx, (id_, lbl) in enumerate(self._assign_data):
self._assign_lb.insert("end", lbl)
if id_ in self._item_colours:
self._assign_lb.itemconfig(idx, fg=self._item_colours[id_])
def _add(self):
sel = list(self._avail_lb.curselection())
if not sel:
return
items = [self._avail_data[i] for i in sel]
for i in reversed(sel):
del self._avail_data[i]
self._assign_data.extend(items)
self._refresh_listboxes()
def _remove(self):
sel = list(self._assign_lb.curselection())
if not sel:
return
items = [self._assign_data[i] for i in sel]
for i in reversed(sel):
del self._assign_data[i]
self._avail_data.extend(items)
self._avail_data.sort(key=lambda x: x[1])
self._refresh_listboxes()
def _add_all(self):
self._assign_data.extend(self._avail_data)
self._avail_data.clear()
self._refresh_listboxes()
def _remove_all(self):
self._avail_data.extend(self._assign_data)
self._assign_data.clear()
self._avail_data.sort(key=lambda x: x[1])
self._refresh_listboxes()
def _move_up(self):
sel = self._assign_lb.curselection()
if not sel or sel[0] == 0:
return
i = sel[0]
self._assign_data[i - 1], self._assign_data[i] = \
self._assign_data[i], self._assign_data[i - 1]
self._refresh_listboxes()
self._assign_lb.selection_set(i - 1)
def _move_down(self):
sel = self._assign_lb.curselection()
if not sel or sel[0] >= len(self._assign_data) - 1:
return
i = sel[0]
self._assign_data[i + 1], self._assign_data[i] = \
self._assign_data[i], self._assign_data[i + 1]
self._refresh_listboxes()
self._assign_lb.selection_set(i + 1)
# ─── Public API ───────────────────────────────────────────────────────────
def set_selected(self, ids: list):
"""Pre-select items by id (called when editing an existing shift)."""
id_set = set(ids)
id_order = {id_: idx for idx, id_ in enumerate(ids)}
remaining = []
selected = []
for item in self._all_items:
if item[0] in id_set:
selected.append(item)
else:
remaining.append(item)
selected.sort(key=lambda x: id_order.get(x[0], 9999))
self._avail_data = remaining
self._assign_data = selected
self._refresh_listboxes()
def get_selected_ids(self) -> list:
return [id_ for id_, _ in self._assign_data]
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _days_label(dow_str: str) -> str:
"""Convert '23456' → 'Mon Tue Wed Thu Fri'."""
digit_to_lbl = {digit: lbl for lbl, digit in DAY_MAP}
# Preserve order Mon-Sun
order = [d for _, d in DAY_MAP]
return " ".join(digit_to_lbl[d] for d in order if d in (dow_str or ""))
def _time_to_str(val) -> str:
"""Normalise MySQL TIME (timedelta or str) to HH:MM string."""
if val is None:
return "00:00"
import datetime
if isinstance(val, datetime.timedelta):
total = int(val.total_seconds())
h, m = divmod(total // 60, 60)
return f"{h:02d}:{m:02d}"
# Already a string or time object
return str(val)[:5]
def _valid_time(s: str) -> bool:
"""Return True if s matches HH:MM."""
parts = s.split(":")
if len(parts) != 2:
return False
try:
h, m = int(parts[0]), int(parts[1])
return 0 <= h <= 23 and 0 <= m <= 59
except ValueError:
return False