514 lines
22 KiB
Python
514 lines
22 KiB
Python
"""
|
|
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
|