268 lines
10 KiB
Python
268 lines
10 KiB
Python
"""
|
|
views/admin_dashboard_view.py — Admin home screen.
|
|
|
|
Shows today's completion stats at-a-glance:
|
|
- Headline KPI cards (total users, active today, total sites)
|
|
- Per-user completion table with inline progress bar
|
|
- Auto-refreshes every 60 seconds
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
import logging
|
|
from datetime import date
|
|
|
|
from utils.ui_helpers import (
|
|
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, FONT_TITLE,
|
|
show_error,
|
|
)
|
|
|
|
logger = logging.getLogger("admin_dashboard_view")
|
|
|
|
REFRESH_INTERVAL_MS = 60_000 # auto-refresh every 60 seconds
|
|
|
|
|
|
class AdminDashboardView(ttk.Frame):
|
|
def __init__(self, parent, current_user: dict):
|
|
super().__init__(parent)
|
|
self.current_user = current_user
|
|
self._refresh_job = None
|
|
self._build_ui()
|
|
self._load()
|
|
|
|
def destroy(self):
|
|
"""Cancel the auto-refresh job and mousewheel binding when destroyed."""
|
|
if self._refresh_job:
|
|
try:
|
|
self.after_cancel(self._refresh_job)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self._canvas.unbind_all("<MouseWheel>")
|
|
except Exception:
|
|
pass
|
|
super().destroy()
|
|
|
|
# ─── Layout ───────────────────────────────────────────────────────────────
|
|
|
|
def _build_ui(self):
|
|
# ── Header ────────────────────────────────────────────────────────────
|
|
hdr = ttk.Frame(self)
|
|
hdr.pack(fill="x", pady=(0, 16))
|
|
|
|
ttk.Label(hdr, text="Dashboard",
|
|
style="Heading.TLabel").pack(side="left")
|
|
|
|
self._date_lbl = ttk.Label(
|
|
hdr,
|
|
text=date.today().strftime("%A, %d %B %Y"),
|
|
style="Dim.TLabel"
|
|
)
|
|
self._date_lbl.pack(side="right", padx=(0, 4))
|
|
|
|
ttk.Button(hdr, text="↻ Refresh", style="Ghost.TButton",
|
|
command=self._load).pack(side="right", padx=(0, 8))
|
|
|
|
# ── KPI cards row ─────────────────────────────────────────────────────
|
|
self._kpi_frame = tk.Frame(self, bg=COLOURS["bg"])
|
|
self._kpi_frame.pack(fill="x", pady=(0, 20))
|
|
|
|
# ── User completion table ─────────────────────────────────────────────
|
|
ttk.Label(self, text="Today's Progress by User",
|
|
style="Heading.TLabel").pack(anchor="w", pady=(0, 8))
|
|
|
|
# Scrollable table frame
|
|
table_outer = ttk.Frame(self)
|
|
table_outer.pack(fill="both", expand=True)
|
|
|
|
self._canvas = tk.Canvas(table_outer, bg=COLOURS["bg"],
|
|
highlightthickness=0)
|
|
vsb = ttk.Scrollbar(table_outer, orient="vertical",
|
|
command=self._canvas.yview)
|
|
self._canvas.configure(yscrollcommand=vsb.set)
|
|
vsb.pack(side="right", fill="y")
|
|
self._canvas.pack(side="left", fill="both", expand=True)
|
|
|
|
self._table_frame = ttk.Frame(self._canvas)
|
|
self._table_frame.bind(
|
|
"<Configure>",
|
|
lambda e: self._canvas.configure(
|
|
scrollregion=self._canvas.bbox("all"))
|
|
)
|
|
self._canvas_win = self._canvas.create_window(
|
|
(0, 0), window=self._table_frame, anchor="nw"
|
|
)
|
|
self._canvas.bind(
|
|
"<Configure>",
|
|
lambda e: self._canvas.itemconfig(self._canvas_win, width=e.width)
|
|
)
|
|
self._canvas.bind("<Enter>", self._on_canvas_enter)
|
|
self._canvas.bind("<Leave>", self._on_canvas_leave)
|
|
|
|
def _on_canvas_enter(self, event):
|
|
self._canvas.bind_all(
|
|
"<MouseWheel>",
|
|
lambda e: self._safe_scroll(e)
|
|
)
|
|
|
|
def _on_canvas_leave(self, event):
|
|
try:
|
|
self._canvas.unbind_all("<MouseWheel>")
|
|
except Exception:
|
|
pass
|
|
|
|
def _safe_scroll(self, event):
|
|
try:
|
|
self._canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
|
except Exception:
|
|
pass
|
|
|
|
# ─── Data ─────────────────────────────────────────────────────────────────
|
|
|
|
def _load(self):
|
|
# Cancel any pending auto-refresh before issuing a new one
|
|
if self._refresh_job:
|
|
try:
|
|
self.after_cancel(self._refresh_job)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
from models import get_admin_dashboard_stats
|
|
stats = get_admin_dashboard_stats()
|
|
except Exception as e:
|
|
show_error(f"Failed to load dashboard:\n{e}")
|
|
return
|
|
|
|
self._render_kpis(stats)
|
|
self._render_table(stats["user_stats"])
|
|
|
|
# Schedule next auto-refresh
|
|
self._refresh_job = self.after(REFRESH_INTERVAL_MS, self._load)
|
|
logger.info("Admin dashboard refreshed.")
|
|
|
|
# ─── KPI Cards ────────────────────────────────────────────────────────────
|
|
|
|
def _render_kpis(self, stats: dict):
|
|
for w in self._kpi_frame.winfo_children():
|
|
w.destroy()
|
|
|
|
total_users = stats["total_users"]
|
|
active_today = stats["active_today"]
|
|
total_sites = stats["total_sites"]
|
|
|
|
# Compute overall completion %
|
|
user_stats = stats["user_stats"]
|
|
if user_stats:
|
|
avg_pct = sum(
|
|
(r["pct_complete"] or 0) for r in user_stats
|
|
) / len(user_stats)
|
|
else:
|
|
avg_pct = 0
|
|
|
|
cards = [
|
|
("Total Users", str(total_users), COLOURS["accent"]),
|
|
("Active Today", str(active_today), COLOURS["success"]),
|
|
("Monitored Sites", str(total_sites), COLOURS["warning"]),
|
|
("Avg Completion", f"{avg_pct:.0f}%", COLOURS["accent"]),
|
|
]
|
|
|
|
self._kpi_frame.columnconfigure(list(range(len(cards))), weight=1)
|
|
|
|
for col, (label, value, colour) in enumerate(cards):
|
|
card = tk.Frame(self._kpi_frame, bg=COLOURS["surface"],
|
|
padx=20, pady=16)
|
|
card.grid(row=0, column=col, padx=(0, 12) if col < len(cards)-1 else 0,
|
|
sticky="ew")
|
|
|
|
tk.Label(card, text=value,
|
|
font=(FONT_BOLD[0], 26, "bold"),
|
|
bg=COLOURS["surface"],
|
|
fg=colour).pack()
|
|
tk.Label(card, text=label,
|
|
font=FONT_SMALL,
|
|
bg=COLOURS["surface"],
|
|
fg=COLOURS["text_dim"]).pack()
|
|
|
|
# ─── User Table ───────────────────────────────────────────────────────────
|
|
|
|
def _render_table(self, user_stats: list):
|
|
for w in self._table_frame.winfo_children():
|
|
w.destroy()
|
|
|
|
# Column headers
|
|
headers = ["User", "Full Name", "Checked", "Total", "Completion"]
|
|
col_weights = [1, 2, 0, 0, 3]
|
|
header_frame = tk.Frame(self._table_frame, bg=COLOURS["surface2"])
|
|
header_frame.pack(fill="x", pady=(0, 2))
|
|
|
|
for col, (hdr, wt) in enumerate(zip(headers, col_weights)):
|
|
header_frame.columnconfigure(col, weight=wt, minsize=80)
|
|
tk.Label(header_frame, text=hdr,
|
|
bg=COLOURS["surface2"], fg=COLOURS["accent"],
|
|
font=FONT_BOLD, anchor="w",
|
|
padx=12, pady=8).grid(
|
|
row=0, column=col, sticky="ew")
|
|
|
|
if not user_stats:
|
|
tk.Label(self._table_frame,
|
|
text="No active users with shifts today.",
|
|
bg=COLOURS["bg"], fg=COLOURS["text_dim"],
|
|
font=FONT_SMALL).pack(pady=20)
|
|
return
|
|
|
|
for i, row in enumerate(user_stats):
|
|
bg = COLOURS["surface"] if i % 2 == 0 else COLOURS["surface2"]
|
|
row_frame = tk.Frame(self._table_frame, bg=bg)
|
|
row_frame.pack(fill="x")
|
|
|
|
pct = float(row["pct_complete"] or 0)
|
|
checked = int(row["checked_count"] or 0)
|
|
total = int(row["total_sites"] or 0)
|
|
|
|
# Colour: green if 100%, amber if >0%, red if 0%
|
|
if pct >= 100:
|
|
pct_colour = COLOURS["success"]
|
|
elif pct > 0:
|
|
pct_colour = COLOURS["warning"]
|
|
else:
|
|
pct_colour = COLOURS["danger"]
|
|
|
|
for col, (wt, content) in enumerate(zip(col_weights, [
|
|
row["username"],
|
|
row["full_name"] or "",
|
|
str(checked),
|
|
str(total),
|
|
None, # progress bar column
|
|
])):
|
|
row_frame.columnconfigure(col, weight=wt, minsize=80)
|
|
|
|
if content is not None:
|
|
tk.Label(row_frame, text=content,
|
|
bg=bg, fg=COLOURS["text"],
|
|
font=FONT, anchor="w",
|
|
padx=12, pady=10).grid(
|
|
row=0, column=col, sticky="ew")
|
|
else:
|
|
# Progress bar + percentage label
|
|
bar_cell = tk.Frame(row_frame, bg=bg, padx=12, pady=8)
|
|
bar_cell.grid(row=0, column=col, sticky="ew")
|
|
bar_cell.columnconfigure(0, weight=1)
|
|
|
|
# Outer track
|
|
track = tk.Frame(bar_cell, bg=COLOURS["border"],
|
|
height=14)
|
|
track.pack(fill="x", side="left", expand=True)
|
|
track.pack_propagate(False)
|
|
|
|
# Fill — drawn after geometry update
|
|
fill = tk.Frame(track, bg=pct_colour, height=14)
|
|
fill.place(relx=0, rely=0,
|
|
relwidth=min(pct / 100, 1.0), relheight=1.0)
|
|
|
|
tk.Label(bar_cell,
|
|
text=f"{pct:.0f}%",
|
|
bg=bg, fg=pct_colour,
|
|
font=FONT_BOLD,
|
|
width=5, anchor="e").pack(side="left", padx=(6, 0))
|