""" 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("") 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( "", 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( "", lambda e: self._canvas.itemconfig(self._canvas_win, width=e.width) ) self._canvas.bind("", self._on_canvas_enter) self._canvas.bind("", self._on_canvas_leave) def _on_canvas_enter(self, event): self._canvas.bind_all( "", lambda e: self._safe_scroll(e) ) def _on_canvas_leave(self, event): try: self._canvas.unbind_all("") 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))