""" views/reports_view.py — Reports panel (Admin only). Tabs ──── 1. Shift Detail — every check event, filterable by date range / user / website 2. Unchecked — sites not checked on a given date, per user 3. Summary — per-user per-day completion percentage All three tabs share the same Export toolbar (CSV + Excel). """ import os import tkinter as tk from tkinter import ttk, filedialog import logging from datetime import date, timedelta from utils.ui_helpers import ( COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, show_error, show_info, DateEntry, ) logger = logging.getLogger("reports_view") # ─── Column definitions per report type ─────────────────────────────────────── SHIFT_COLS = ["check_date", "checked_at", "username", "full_name", "website_name", "url", "user_note", "status"] UNCHECKED_COLS = ["check_date", "username", "full_name", "website_name", "url", "status"] SUMMARY_COLS = ["check_date", "username", "full_name", "checked_count", "total_sites", "pct_complete"] class ReportsView(ttk.Frame): def __init__(self, parent, current_user: dict): super().__init__(parent) self.current_user = current_user self._users = [] # [{id, username, full_name}, ...] self._websites = [] # [{id, name}, ...] self._load_filter_options() self._build_ui() # ─── Bootstrap ──────────────────────────────────────────────────────────── def _load_filter_options(self): try: from models import get_report_filter_options self._users, self._websites = get_report_filter_options() except Exception as e: logger.error(f"Failed to load filter options: {e}") # ─── Layout ─────────────────────────────────────────────────────────────── def _build_ui(self): # ── Page header ─────────────────────────────────────────────────────── hdr = ttk.Frame(self) hdr.pack(fill="x", pady=(0, 12)) ttk.Label(hdr, text="Reports", style="Heading.TLabel").pack(side="left") # ── Notebook (tabs) ─────────────────────────────────────────────────── self.nb = ttk.Notebook(self) self.nb.pack(fill="both", expand=True) self._tab_shift = self._make_tab("📋 Shift Detail", SHIFT_COLS, self._run_shift_report) self._tab_unchecked = self._make_tab("⚠️ Unchecked", UNCHECKED_COLS, self._run_unchecked_report) self._tab_summary = self._make_tab("📊 Summary", SUMMARY_COLS, self._run_summary_report) self._build_chart_tab() def _make_tab(self, label: str, columns: list, run_fn) -> dict: """ Build one tab with a filter strip, result treeview, and export toolbar. Returns a dict of widget references used by the run / export functions. """ frame = ttk.Frame(self.nb) self.nb.add(frame, text=label) # ── Filter strip ────────────────────────────────────────────────────── filter_card = tk.Frame(frame, bg=COLOURS["surface"], pady=10) filter_card.pack(fill="x", padx=0, pady=(0, 10)) widgets = {} is_unchecked = (columns == UNCHECKED_COLS) is_summary = (columns == SUMMARY_COLS) col = 0 def _lbl(text): nonlocal col tk.Label(filter_card, text=text, bg=COLOURS["surface"], fg=COLOURS["text_dim"], font=FONT_SMALL).grid(row=0, column=col, padx=(14, 2), pady=6, sticky="w") col += 1 def _cb(values, width=18): nonlocal col var = tk.StringVar(value=values[0]) cb = ttk.Combobox(filter_card, textvariable=var, values=values, state="readonly", width=width) cb.grid(row=0, column=col, padx=(0, 8), pady=6) col += 1 return var, cb # Date from / Date to — use DateEntry calendar pickers today_str = date.today().isoformat() week_ago = (date.today() - timedelta(days=6)).isoformat() if is_unchecked: _lbl("Date") de = DateEntry(filter_card, initial_date=today_str, width=11) de.grid(row=0, column=col, padx=(0, 8), pady=6) col += 1 widgets["date_single"] = de else: _lbl("From") de_from = DateEntry(filter_card, initial_date=week_ago, width=11) de_from.grid(row=0, column=col, padx=(0, 4), pady=6); col += 1 _lbl("To") de_to = DateEntry(filter_card, initial_date=today_str, width=11) de_to.grid(row=0, column=col, padx=(0, 8), pady=6); col += 1 widgets["date_from"] = de_from widgets["date_to"] = de_to # User filter (all tabs) _lbl("User") user_labels = ["All Users"] + [ f"{u['username']} — {u['full_name'] or ''}" for u in self._users ] uvar, _ = _cb(user_labels, width=22) widgets["user_var"] = uvar # Website filter (shift detail only) if not is_unchecked and not is_summary: _lbl("Website") site_labels = ["All Websites"] + [w["name"] for w in self._websites] svar, _ = _cb(site_labels, width=22) widgets["site_var"] = svar # Run button run_btn = tk.Button( filter_card, text=" Run Report ", command=lambda w=widgets: run_fn(w), bg=COLOURS["accent"], fg=COLOURS["white"], activebackground=COLOURS["accent_hover"], activeforeground=COLOURS["white"], relief="flat", font=FONT_BOLD, cursor="hand2", padx=10, pady=4, ) run_btn.grid(row=0, column=col, padx=(4, 14), pady=6); col += 1 filter_card.columnconfigure(col, weight=1) # ── Status / count bar ──────────────────────────────────────────────── status_bar = ttk.Frame(frame) status_bar.pack(fill="x", pady=(0, 4)) count_lbl = ttk.Label(status_bar, text="Run a report to see results.", style="Dim.TLabel") count_lbl.pack(side="left") widgets["count_lbl"] = count_lbl # Export buttons tk.Button( status_bar, text="⬇ Export Excel", command=lambda w=widgets, c=columns: self._export(w, c, "excel"), bg=COLOURS["success"], fg=COLOURS["white"], activebackground="#3d9140", activeforeground=COLOURS["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4, ).pack(side="right", padx=(4, 0)) tk.Button( status_bar, text="⬇ Export CSV", command=lambda w=widgets, c=columns: self._export(w, c, "csv"), bg=COLOURS["surface2"], fg=COLOURS["text"], activebackground=COLOURS["border"], activeforeground=COLOURS["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4, ).pack(side="right", padx=(4, 0)) # ── Treeview ────────────────────────────────────────────────────────── tree_frame = ttk.Frame(frame) tree_frame.pack(fill="both", expand=True) col_display = [c.replace("_", " ").title() for c in columns] tree = ttk.Treeview(tree_frame, columns=col_display, show="headings", selectmode="browse") col_widths = { "Check Date": 95, "Checked At": 140, "Username": 110, "Full Name": 150, "Website Name": 170, "Url": 230, "User Note": 200, "Status": 90, "Checked Count": 100, "Total Sites": 90, "Pct Complete": 100, } for disp in col_display: w = col_widths.get(disp, 120) tree.heading(disp, text=disp, command=lambda d=disp, t=tree: self._sort_tree(t, d)) tree.column(disp, width=w, anchor="center" if w < 130 else "w", minwidth=60) # Colour-tag rows by status tree.tag_configure("checked", foreground=COLOURS["success"]) tree.tag_configure("unchecked", foreground=COLOURS["danger"]) vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=tree.yview) hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=tree.xview) tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) tree.grid(row=0, column=0, sticky="nsew") vsb.grid(row=0, column=1, sticky="ns") hsb.grid(row=1, column=0, sticky="ew") tree_frame.rowconfigure(0, weight=1) tree_frame.columnconfigure(0, weight=1) widgets["tree"] = tree widgets["columns"] = columns widgets["rows"] = [] # populated after each run return widgets # ─── Report Runners ─────────────────────────────────────────────────────── def _resolve_user_id(self, user_var): """Translate the combobox display string back to a DB user id or None.""" val = user_var.get() if val == "All Users": return None username = val.split(" — ")[0].strip() for u in self._users: if u["username"] == username: return u["id"] return None def _resolve_website_id(self, site_var): val = site_var.get() if val == "All Websites": return None for w in self._websites: if w["name"] == val: return w["id"] return None def _populate_tree(self, widgets: dict, rows: list): """Clear and refill the treeview; update count label.""" tree = widgets["tree"] columns = widgets["columns"] tree.delete(*tree.get_children()) for row in rows: values = [str(row.get(c) or "") for c in columns] tag = "unchecked" if row.get("status") == "Not Checked" else "checked" tree.insert("", "end", values=values, tags=(tag,)) widgets["rows"] = rows widgets["count_lbl"].config( text=f"{len(rows)} record{'s' if len(rows) != 1 else ''} found." ) def _run_shift_report(self, widgets: dict): from models import get_shift_report try: date_from = widgets["date_from"].get().strip() or None date_to = widgets["date_to"].get().strip() or None user_id = self._resolve_user_id(widgets["user_var"]) website_id = self._resolve_website_id(widgets["site_var"]) rows = get_shift_report(date_from, date_to, user_id, website_id) self._populate_tree(widgets, rows) logger.info( f"[REPORT] Shift Detail run by {self.current_user['username']} - " f"from={date_from} to={date_to} user={user_id} site={website_id} " f"-> {len(rows)} rows" ) except Exception as e: show_error(f"Report failed:\n{e}") def _run_unchecked_report(self, widgets: dict): from models import get_unchecked_report try: target_date = widgets["date_single"].get().strip() or None user_id = self._resolve_user_id(widgets["user_var"]) rows = get_unchecked_report(target_date, user_id) self._populate_tree(widgets, rows) logger.info( f"[REPORT] Unchecked run by {self.current_user['username']} - " f"date={target_date} user={user_id} -> {len(rows)} rows" ) except Exception as e: show_error(f"Report failed:\n{e}") def _run_summary_report(self, widgets: dict): from models import get_summary_report try: date_from = widgets["date_from"].get().strip() or None date_to = widgets["date_to"].get().strip() or None rows = get_summary_report(date_from, date_to) self._populate_tree(widgets, rows) logger.info( f"[REPORT] Summary run by {self.current_user['username']} - " f"from={date_from} to={date_to} -> {len(rows)} rows" ) except Exception as e: show_error(f"Report failed:\n{e}") # ─── Export ─────────────────────────────────────────────────────────────── def _export(self, widgets: dict, columns: list, fmt: str): rows = widgets.get("rows", []) if not rows: show_error("No data to export. Please run a report first.") return save_dir = filedialog.askdirectory(title="Select folder to save report") if not save_dir: return # Derive a meaningful base filename from the active tab tab_text = self.nb.tab(self.nb.select(), "text").strip() base = "report_" + tab_text.replace(" ", "_").replace(" ", "_") \ .replace("/", "").replace("⚠️", "unchecked") \ .replace("📋", "shift").replace("📊", "summary") \ .lower() try: from utils.export import export_csv, export_excel if fmt == "csv": path = export_csv(rows, columns, base, save_dir) else: sheet = tab_text.replace(" ", " ").strip() path = export_excel(rows, columns, base, save_dir, sheet_title=sheet) from models import log_action log_action( self.current_user["id"], f"EXPORT_{fmt.upper()}", "reports", None, f"Exported '{tab_text.strip()}' ({len(rows)} rows) → {path}" ) show_info(f"Export complete!\n\nSaved to:\n{path}") except Exception as e: show_error(f"Export failed:\n{e}") # ─── Column Sort ────────────────────────────────────────────────────────── @staticmethod def _sort_tree(tree: ttk.Treeview, col: str): """Toggle ascending / descending sort on a treeview column.""" items = [(tree.set(k, col), k) for k in tree.get_children("")] reverse = getattr(tree, f"_sort_rev_{col}", False) items.sort(reverse=reverse) for idx, (_, k) in enumerate(items): tree.move(k, "", idx) setattr(tree, f"_sort_rev_{col}", not reverse) # ─── Chart Tab ──────────────────────────────────────────────────────────── def _build_chart_tab(self): """Fourth tab: completion bar chart powered by matplotlib.""" frame = ttk.Frame(self.nb) self.nb.add(frame, text="📈 Completion Chart") # Filter strip filter_card = tk.Frame(frame, bg=COLOURS["surface"], pady=10) filter_card.pack(fill="x", pady=(0, 10)) tk.Label(filter_card, text="From", bg=COLOURS["surface"], fg=COLOURS["text_dim"], font=FONT_SMALL).grid(row=0, column=0, padx=(14, 2), pady=6) today_str = date.today().isoformat() week_ago = (date.today() - timedelta(days=6)).isoformat() self._chart_from = DateEntry(filter_card, initial_date=week_ago, width=11) self._chart_from.grid(row=0, column=1, padx=(0, 4), pady=6) tk.Label(filter_card, text="To", bg=COLOURS["surface"], fg=COLOURS["text_dim"], font=FONT_SMALL).grid(row=0, column=2, padx=(4, 2), pady=6) self._chart_to = DateEntry(filter_card, initial_date=today_str, width=11) self._chart_to.grid(row=0, column=3, padx=(0, 12), pady=6) tk.Button( filter_card, text=" Generate Chart ", command=self._run_chart, bg=COLOURS["accent"], fg=COLOURS["white"], activebackground=COLOURS["accent_hover"], activeforeground=COLOURS["white"], relief="flat", font=FONT_BOLD, cursor="hand2", padx=10, pady=4, ).grid(row=0, column=4, padx=(0, 14), pady=6) filter_card.columnconfigure(5, weight=1) # Chart canvas placeholder self._chart_status = ttk.Label( frame, text="Select a date range and click Generate Chart.", style="Dim.TLabel" ) self._chart_status.pack(pady=20) self._chart_frame = ttk.Frame(frame) self._chart_frame.pack(fill="both", expand=True, padx=8, pady=8) def _run_chart(self): """Fetch summary data and render a grouped bar chart.""" date_from = self._chart_from.get().strip() or None date_to = self._chart_to.get().strip() or None try: from models import get_summary_report rows = get_summary_report(date_from, date_to) except Exception as e: show_error(f"Failed to load chart data:\n{e}") return if not rows: self._chart_status.config(text="No data found for the selected range.") return self._chart_status.config(text="") self._render_chart(rows, date_from, date_to) logger.info(f"[CHART] Generated completion chart " f"from={date_from} to={date_to} rows={len(rows)}") def _render_chart(self, rows: list, date_from, date_to): """Build and embed a matplotlib bar chart in the chart frame.""" # Clear previous chart for w in self._chart_frame.winfo_children(): w.destroy() try: import matplotlib matplotlib.use("Agg") # non-interactive backend — safe for Tkinter import matplotlib.pyplot as plt import matplotlib.ticker as mticker from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg except ImportError: show_error( "matplotlib is required for charts.\n" "Install it with: pip install matplotlib" ) return C = COLOURS # ── Aggregate per-user average completion ────────────────────────────── from collections import defaultdict user_pcts: dict[str, list] = defaultdict(list) for row in rows: user_pcts[row["username"]].append(float(row["pct_complete"] or 0)) users = list(user_pcts.keys()) avg_pct = [sum(v) / len(v) for v in user_pcts.values()] # Colour bars by completion level bar_colours = [ "#4caf50" if p >= 100 else "#f0a500" if p >= 50 else "#e05c5c" for p in avg_pct ] # ── Plot ────────────────────────────────────────────────────────────── fig_bg = C["surface"] axes_bg = C["surface2"] fig, ax = plt.subplots(figsize=(max(6, len(users) * 0.9 + 2), 4), facecolor=fig_bg) ax.set_facecolor(axes_bg) x = range(len(users)) bars = ax.bar(x, avg_pct, color=bar_colours, width=0.55, zorder=2) # Grid lines ax.yaxis.set_major_locator(mticker.MultipleLocator(20)) ax.set_ylim(0, 110) ax.grid(axis="y", color=C["border"], linewidth=0.7, zorder=1) ax.set_axisbelow(True) # Labels ax.set_xticks(list(x)) ax.set_xticklabels(users, rotation=30, ha="right", color=C["text"], fontsize=9) ax.set_ylabel("Avg Completion (%)", color=C["text_dim"], fontsize=9) ax.tick_params(colors=C["text_dim"], which="both") for spine in ax.spines.values(): spine.set_edgecolor(C["border"]) title_range = "" if date_from and date_to: title_range = f" ({date_from} to {date_to})" ax.set_title(f"User Completion{title_range}", color=C["text"], fontsize=11, pad=10) # Value labels on bars for bar, pct in zip(bars, avg_pct): ax.text( bar.get_x() + bar.get_width() / 2, bar.get_height() + 1.5, f"{pct:.0f}%", ha="center", va="bottom", color=C["text"], fontsize=8, fontweight="bold", ) fig.tight_layout() # ── Embed in Tkinter ────────────────────────────────────────────────── canvas = FigureCanvasTkAgg(fig, master=self._chart_frame) canvas.draw() canvas.get_tk_widget().pack(fill="both", expand=True) plt.close(fig) # free memory