UI: 3-tab layout (Admin overview / My dashboard / Training) + Settings page

Frontend:
- App shell with 3 tabs: Overview (admin), My Dashboard (all incl admin), Training + Settings icon
- New Settings.vue: edit profile (name/email) + change password (verify current)
- New MyBoard.vue: personal win/lose + weak areas + summary (all roles)
- New Training.vue: product/group list -> personas; admin manage + add product
- Dashboard.vue -> admin overview with date-range filter + per-trainee stats
- Added i18n keys (EN/TH) and tab/settings styling (ui-ux-pro-max design system)

Backend:
- /api/auth/change-password (verify current pw) + PUT /api/auth/profile
- Allow admin to train: board/weak-areas/chat/personas/sessions open to user+admin+super_admin
- /api/analytics now accepts ?from=&to= date filter + returns per_user summary

Verified: frontend builds; change-password (wrong/ok), profile, board all pass via test client; security tests pass
This commit is contained in:
Macky
2026-08-07 19:11:11 +07:00
parent 681561c22a
commit fc041b8f37
13 changed files with 707 additions and 97 deletions

View File

@@ -1,9 +1,11 @@
"""Admin analytics: aggregate trainee results."""
"""Admin analytics: aggregate trainee results, with optional date-range filter."""
from __future__ import annotations
from flask import Blueprint, jsonify
import datetime
from .helpers import ApiError, current_user, require_auth, require_roles
from flask import Blueprint, jsonify, request
from .helpers import current_user, require_auth, require_roles
analytics_bp = Blueprint("analytics", __name__)
@@ -18,35 +20,77 @@ def _stores():
}
def _parse_date(val: str):
if not val:
return None
try:
return datetime.datetime.fromisoformat(val)
except ValueError:
try:
return datetime.datetime.strptime(val, "%Y-%m-%d")
except ValueError:
return None
@analytics_bp.get("")
@require_auth
@require_roles("admin")
def analytics():
s = _stores()
actor = current_user()
# Optional date-range filter (inclusive): ?from=YYYY-MM-DD&to=YYYY-MM-DD
d_from = _parse_date((request.args.get("from") or "").strip())
d_to = _parse_date((request.args.get("to") or "").strip())
if d_to:
# include the whole "to" day
d_to = d_to.replace(hour=23, minute=59, second=59, microsecond=999999)
if actor.get("role") == "super_admin":
sessions = s["sessions"].sessions.all()
users = s["users"].list_users()
else:
org_id = actor.get("org_id")
# users in this org
users = s["users"].list_users(org_id=org_id)
user_ids = {u["id"] for u in users}
sessions = [
x for x in s["sessions"].sessions.all() if x.get("user_id") in user_ids
]
if d_from or d_to:
filtered = []
for x in sessions:
ts = x.get("created_at") or x.get("finished_at") or ""
if not ts:
continue
try:
dt = datetime.datetime.fromisoformat(ts)
except ValueError:
continue
if d_from and dt < d_from:
continue
if d_to and dt > d_to:
continue
filtered.append(x)
sessions = filtered
overall = {
"total_sessions": len(sessions),
"wins": sum(1 for x in sessions if x.get("outcome") == "won"),
"losses": sum(1 for x in sessions if x.get("outcome") == "lost"),
}
overall["close_rate"] = round(
overall["wins"] / overall["total_sessions"] * 100, 1
) if overall["total_sessions"] else 0
overall["close_rate"] = (
round(overall["wins"] / overall["total_sessions"] * 100, 1)
if overall["total_sessions"]
else 0
)
# average score
scores = [ (x.get("debrief") or {}).get("score", 0) for x in sessions if x.get("outcome") ]
scores = [
(x.get("debrief") or {}).get("score", 0)
for x in sessions
if x.get("outcome")
]
overall["avg_score"] = round(sum(scores) / len(scores), 1) if scores else 0
# hardest personas = personas with most losses (lowest avg score)
@@ -69,15 +113,39 @@ def analytics():
"plays": v["plays"],
"wins": v["wins"],
"losses": v["losses"],
"avg_score": round(sum(v["scores"]) / len(v["scores"]), 1) if v["scores"] else 0,
"avg_score": round(sum(v["scores"]) / len(v["scores"]), 1)
if v["scores"]
else 0,
}
for k, v in by_persona.items()
),
key=lambda r: (r["losses"], -r["avg_score"]),
)[:10]
# per-user summary so the admin overview can show per-trainee results
by_user: dict = {}
for x in sessions:
uid = x.get("user_id", "?")
if uid not in by_user:
by_user[uid] = {"sessions": 0, "wins": 0, "losses": 0}
by_user[uid]["sessions"] += 1
if x.get("outcome") == "won":
by_user[uid]["wins"] += 1
elif x.get("outcome") == "lost":
by_user[uid]["losses"] += 1
name_by_id = {u.get("id"): u.get("name") or u.get("username") for u in users}
per_user = [
{
"user_id": uid,
"name": name_by_id.get(uid, uid),
**v,
}
for uid, v in by_user.items()
]
return jsonify({
"overall": overall,
"trainee_count": len(users),
"hardest_personas": hardest,
"per_user": per_user,
})