04/21 Fisrt commit
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
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))
|
||||
Reference in New Issue
Block a user