782 lines
34 KiB
Python
782 lines
34 KiB
Python
"""
|
||
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} "
|
||
f"<b>Time:</b> {start} – {end} "
|
||
f"<b>Users:</b> {s['user_count']} "
|
||
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 |