04/21 Fisrt commit

This commit is contained in:
2026-04-21 17:16:37 -04:00
parent f94d86f04b
commit 7548dfc9bf
23 changed files with 6860 additions and 1 deletions
+267
View File
@@ -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))