""" 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("", 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"Days: {days_str}    " f"Time: {start} – {end}    " f"Users: {s['user_count']}    " f"Websites: {s['website_count']}", body_style)) if s.get("note"): story.append(Paragraph(f"{s['note']}", 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("", 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("", 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