diff --git a/backend/app/api/analytics_routes.py b/backend/app/api/analytics_routes.py index 4c92183..4e9cf8a 100644 --- a/backend/app/api/analytics_routes.py +++ b/backend/app/api/analytics_routes.py @@ -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, }) diff --git a/backend/app/api/auth_routes.py b/backend/app/api/auth_routes.py index 23a1431..1e65499 100644 --- a/backend/app/api/auth_routes.py +++ b/backend/app/api/auth_routes.py @@ -65,3 +65,49 @@ def setup(): "user": _store().public_user(updated), "must_setup": False, }) + + +@auth_bp.post("/change-password") +@require_auth +def change_password(): + """Change own password — requires the current password to be correct.""" + user = current_user() + data = request.get_json(silent=True) or {} + current_pw = data.get("current_password") or "" + new_pw = data.get("new_password") or "" + if not current_pw or not new_pw: + raise ApiError("current and new password are required") + # verify the current password first + try: + _store().verify(user["username"], current_pw) + except AuthError: + raise ApiError("current password is incorrect", 400) + if len(new_pw) < 4: + raise ApiError("new password must be at least 4 characters", 400) + updated = _store().set_password(user["username"], new_pw) + return jsonify({"ok": True, "user": _store().public_user(updated)}) + + +@auth_bp.put("/profile") +@require_auth +def update_profile(): + """Update own profile: display name and/or email.""" + user = current_user() + data = request.get_json(silent=True) or {} + updates = {} + if "name" in data: + name = (data.get("name") or "").strip() + if name: + updates["name"] = name + _store().users.update(user["username"], name=name) + if "email" in data: + email = (data.get("email") or "").strip() + try: + _store().set_email(user["username"], email) + except AuthError as exc: + raise ApiError(str(exc), 400) + updates["email"] = (email or "").lower() + if not updates: + raise ApiError("nothing to update", 400) + updated = _store().get_user(user["username"]) + return jsonify({"ok": True, "user": _store().public_user(updated)}) diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py index 274fba5..b8207b3 100644 --- a/backend/app/api/chat_routes.py +++ b/backend/app/api/chat_routes.py @@ -45,7 +45,7 @@ def _get_ready_group(s, gid: str) -> dict: @chat_bp.post("//personas//chat/start") @require_auth -@require_roles("user") +@require_roles("user", "admin", "super_admin") def start_session(gid: str, pid: str): s = _stores() group = _get_ready_group(s, gid) @@ -85,7 +85,7 @@ def start_session(gid: str, pid: str): @chat_bp.post("//personas//chat/send") @require_auth -@require_roles("user") +@require_roles("user", "admin", "super_admin") def send_message(gid: str, pid: str): s = _stores() actor = current_user() @@ -125,7 +125,7 @@ def send_message(gid: str, pid: str): @chat_bp.post("//personas//chat/finish") @require_auth -@require_roles("user") +@require_roles("user", "admin", "super_admin") def finish_session(gid: str, pid: str): """End the chat and produce the debrief via the judge-LLM (reveals latent fields).""" s = _stores() @@ -168,7 +168,7 @@ def finish_session(gid: str, pid: str): @chat_bp.get("/sessions") @require_auth -@require_roles("user") +@require_roles("user", "admin", "super_admin") def my_sessions(): s = _stores() uid = current_user()["id"] @@ -178,7 +178,7 @@ def my_sessions(): @chat_bp.get("/sessions/") @require_auth -@require_roles("user") +@require_roles("user", "admin", "super_admin") def get_session(sid: str): s = _stores() session = s["sessions"].get_or_none(sid) diff --git a/backend/app/api/me_routes.py b/backend/app/api/me_routes.py index 8dc0184..e356b11 100644 --- a/backend/app/api/me_routes.py +++ b/backend/app/api/me_routes.py @@ -23,15 +23,20 @@ def _stores(): @me_bp.get("/board") @require_auth -@require_roles("user") +@require_roles("user", "admin", "super_admin") def win_lose_board(): - """Per-persona won/lost/not-tried across all groups the user sees.""" + """Per-persona won/lost/not-tried across all groups the user sees. + + Available to trainees AND admins (admins may also train/practice). + """ s = _stores() - uid = current_user()["id"] + actor = current_user() + uid = actor["id"] + role = actor.get("role", "user") my_sessions = s["sessions"].list_for_user(uid) outcome_by = {(x.get("group_id"), x.get("persona_id")): x.get("outcome") for x in my_sessions} - groups = s["groups"].list_visible_to(role="user", org_id=current_user().get("org_id")) + groups = s["groups"].list_visible_to(role=role, org_id=actor.get("org_id")) # Only the owner sees their personal groups (IDOR defense). groups = [g for g in groups if not g.get("owner_user_id") or g.get("owner_user_id") == uid] items = [] @@ -51,7 +56,7 @@ def win_lose_board(): @me_bp.get("/weak-areas") @require_auth -@require_roles("user") +@require_roles("user", "admin", "super_admin") def weak_areas(): s = _stores() uid = current_user()["id"] @@ -83,7 +88,7 @@ def _personal_group(s, actor) -> dict: @me_bp.get("/personas") @require_auth -@require_roles("user") +@require_roles("user", "admin", "super_admin") def my_personas(): s = _stores() uid = current_user()["id"] @@ -93,7 +98,7 @@ def my_personas(): @me_bp.post("/personas/generate") @require_auth -@require_roles("user") +@require_roles("user", "admin", "super_admin") def generate_persona(): s = _stores() actor = current_user() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a03651e..dd9dfff 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,26 +1,69 @@ - - diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6de0928..1e8ed88 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -36,6 +36,8 @@ export const api = { login: (username, password) => request('POST', '/api/auth/login', { username, password }), me: () => request('GET', '/api/auth/me'), setup: (b) => request('POST', '/api/auth/setup', b), + changePassword: (b) => request('POST', '/api/auth/change-password', b), + updateProfile: (b) => request('PUT', '/api/auth/profile', b), adminCreateUser: (b) => request('POST', '/api/admin/users', b), adminListUsers: () => request('GET', '/api/admin/users'), adminUpdateUser: (username, b) => request('PUT', `/api/admin/users/${username}`, b), @@ -55,4 +57,5 @@ export const api = { myPersonas: () => request('GET', '/api/me/personas'), generatePersona: (b) => request('POST', '/api/me/personas/generate', b), analytics: () => request('GET', '/api/analytics'), + analyticsWithQuery: (qs) => request('GET', `/api/analytics${qs}`), } diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js index a806b0d..b794675 100644 --- a/frontend/src/i18n/index.js +++ b/frontend/src/i18n/index.js @@ -60,6 +60,25 @@ const messages = { openSaleTask: 'The customer did NOT message first. You must open the sale.', sellerInitiated: 'You must open the sale (outbound)', customerInitiated: 'The customer will message you first', + tabAdminDash: 'Overview', + tabMyDash: 'My Dashboard', + tabTraining: 'Training', + settings: 'Settings', + settingsTitle: 'Settings', + settingsProfile: 'Profile', + settingsSecurity: 'Change password', + settingsProfileDesc: 'Update your display name and email.', + settingsSecurityDesc: 'Change your account password.', + displayName: 'Display name', + currentPassword: 'Current password', + changePassword: 'Change password', + passwordChanged: 'Password changed successfully', + profileSaved: 'Profile saved successfully', + currentPasswordWrong: 'Current password is incorrect', + account: 'Account', + status: 'Status', + role: 'Role', + noData: 'No data yet', }, th: { app: 'ตัวฝึกขาย', @@ -119,6 +138,25 @@ const messages = { openSaleTask: 'ลูกค้ายังไม่ได้ทักมา คุณต้องเป็นฝ่ายเปิดการขายเอง', sellerInitiated: 'คุณต้องเปิดการขาย (เชิงรุก)', customerInitiated: 'ลูกค้าจะทักมาเองก่อน', + tabAdminDash: 'ภาพรวม', + tabMyDash: 'แดชบอร์ดของฉัน', + tabTraining: 'การฝึก', + settings: 'ตั้งค่า', + settingsTitle: 'ตั้งค่า', + settingsProfile: 'โปรไฟล์', + settingsSecurity: 'เปลี่ยนรหัสผ่าน', + settingsProfileDesc: 'อัปเดตชื่อและอีเมลของคุณ', + settingsSecurityDesc: 'เปลี่ยนรหัสผ่านบัญชีของคุณ', + displayName: 'ชื่อที่แสดง', + currentPassword: 'รหัสผ่านปัจจุบัน', + changePassword: 'เปลี่ยนรหัสผ่าน', + passwordChanged: 'เปลี่ยนรหัสผ่านสำเร็จ', + profileSaved: 'บันทึกโปรไฟล์สำเร็จ', + currentPasswordWrong: 'รหัสผ่านปัจจุบันไม่ถูกต้อง', + account: 'บัญชี', + status: 'สถานะ', + role: 'บทบาท', + noData: 'ยังไม่มีข้อมูล', }, } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 1cc88aa..5932535 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -4,12 +4,20 @@ import { auth } from '../store/auth' const routes = [ { path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } }, { path: '/setup', component: () => import('../views/Setup.vue') }, - { path: '/', component: () => import('../views/Dashboard.vue') }, - { path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') }, - { path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') }, + // Tab 1: Admin overview dashboard (admin only) + { path: '/', component: () => import('../views/Dashboard.vue'), meta: { admin: true } }, + // Tab 2: Personal dashboard (everyone, incl admin) + { path: '/my/board', component: () => import('../views/MyBoard.vue') }, { path: '/my/sessions', component: () => import('../views/MySessions.vue') }, { path: '/my/weak-areas', component: () => import('../views/WeakAreas.vue') }, { path: '/my/generate', component: () => import('../views/GenPersona.vue') }, + // Tab 3: Training — product list -> personas -> chat + { path: '/training', component: () => import('../views/Training.vue') }, + { path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') }, + { path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') }, + // Settings + { path: '/settings', component: () => import('../views/Settings.vue') }, + // Admin management { path: '/admin/new-group', component: () => import('../views/GroupBuilder.vue'), meta: { admin: true } }, { path: '/admin/groups/:gid/edit', component: () => import('../views/GroupEdit.vue'), meta: { admin: true } }, { path: '/admin/users', component: () => import('../views/AdminUsers.vue'), meta: { admin: true } }, @@ -21,6 +29,10 @@ const router = createRouter({ routes, }) +function needSetup(user) { + return !!(user && user.must_setup) +} + router.beforeEach(async (to) => { if (to.meta.public) return true if (!auth.user) { @@ -30,11 +42,11 @@ router.beforeEach(async (to) => { return { path: '/login', query: { redirect: to.fullPath } } } // Force the mandatory first-time setup (set email + change password) before use. - if (auth.mustSetup && to.path !== '/setup') { + if (needSetup(auth.user) && to.path !== '/setup') { return { path: '/setup' } } if (to.meta.admin && !auth.isAdmin) { - return { path: '/' } + return { path: '/my/board' } } return true }) diff --git a/frontend/src/style.css b/frontend/src/style.css index 4a00e45..e235bd1 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,16 +1,20 @@ :root { - --bg: #f6f7fb; + --bg: #f3f5fa; --card: #ffffff; --border: #e5e8ef; - --ink: #1a1d29; + --ink: #111827; --muted: #6b7280; --accent: #4f46e5; --accent-2: #7c3aed; + --accent-soft: #eef2ff; --green: #16a34a; --red: #dc2626; --amber: #d97706; --radius: 14px; + --radius-sm: 10px; --shadow: 0 1px 3px rgba(20, 24, 40, 0.08); + --shadow-md: 0 6px 20px rgba(20, 24, 40, 0.10); + --tab-height: 52px; } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } @@ -152,3 +156,65 @@ button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; } .row { gap: 10px; } .msg-seller, .msg-customer { max-width: 84%; } } + +/* ── App shell: 3-tab navigation ─────────────────────────────── */ +.shell { min-height: 100vh; display: flex; flex-direction: column; } +.shell-header { + display: flex; align-items: center; justify-content: space-between; + padding: 0 24px; height: 60px; + background: var(--card); border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 20; +} +.shell-brand { font-weight: 800; font-size: 16px; color: var(--ink); text-decoration: none; display:flex; align-items:center; gap:10px; } +.shell-brand .logo { width: 30px; height: 30px; border-radius: 9px; background: linear-gradient(135deg,var(--accent),var(--accent-2)); display:grid; place-items:center; color:#fff; font-size:15px; } +.shell-actions { display:flex; align-items:center; gap:10px; } +.shell-user { display:flex; align-items:center; gap:10px; font-size:14px; } +.avatar { width:32px;height:32px;border-radius:50%;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-weight:700;font-size:14px; } +.icon-btn { display:inline-flex; align-items:center; justify-content:center; width:38px; height:38px; border-radius:10px; border:1px solid var(--border); background:var(--card); color:var(--ink); cursor:pointer; transition:all .15s ease; } +.icon-btn:hover { border-color: var(--accent); color: var(--accent); } + +/* Tab bar */ +.tabbar { + display: flex; gap: 4px; padding: 10px 24px 0; + background: var(--card); border-bottom: 1px solid var(--border); + position: sticky; top: 60px; z-index: 15; overflow-x: auto; +} +.tab { + display: inline-flex; align-items: center; gap: 8px; + padding: 12px 18px; border: none; background: transparent; + color: var(--muted); font-size: 14px; font-weight: 600; cursor: pointer; + border-bottom: 3px solid transparent; margin-bottom: -1px; white-space: nowrap; + min-height: 48px; transition: color .15s ease; +} +.tab:hover { color: var(--ink); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--accent-soft); border-radius: 10px 10px 0 0; } +.tab .tab-icon { width:18px;height:18px; } +.shell-main { flex:1; width:100%; max-width: 1180px; margin: 0 auto; padding: 24px; } + +/* Settings section styling */ +.setting-grid { display:grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap:20px; } +.settings-section .s-title { margin:0 0 4px; font-size:16px; } +.settings-section .s-desc { margin:0 0 16px; color:var(--muted); font-size:13px; } +.form-grid { display:grid; gap:4px; } +.form-grid label { margin:12px 0 4px; } +.form-row { display:flex; gap:12px; flex-wrap:wrap; } +.form-row > * { flex:1; min-width:200px; } + +/* Stat cards */ +.stat-grid { display:grid; grid-template-columns: repeat(auto-fit,minmax(180px,1fr)); gap:16px; } +.stat-card { background:var(--card); border:1px solid var(--border); border-radius:var(--radius); padding:18px; box-shadow:var(--shadow); } +.stat-card .stat-label { font-size:13px; color:var(--muted); } +.stat-card .stat-value { font-size:28px; font-weight:800; color:var(--accent); margin-top:4px; } + +/* Date filter */ +.filter-bar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:16px; } +.filter-bar input[type="date"] { width:auto; min-width:150px; } + +/* ✕ svg icon helper */ +.icon-16 { width:16px;height:16px; } + +@media (max-width: 640px) { + .shell-header { padding: 0 12px; } + .tab { padding: 10px 12px; font-size:13px; } + .shell-main { padding: 16px; } +} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index fc9a360..3f52e1e 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,47 +1,75 @@ @@ -51,21 +79,27 @@ import { api } from '../api' import { auth } from '../store/auth' import { i18n } from '../i18n' -const groups = ref([]) +const a = ref({ + overall: { total_sessions: 0, wins: 0, losses: 0, close_rate: 0, avg_score: 0 }, + trainee_count: 0, + hardest_personas: [], + per_user: [], +}) +const dateFrom = ref('') +const dateTo = ref('') const loading = ref(true) -onMounted(async () => { +async function load() { + loading.value = true try { - groups.value = (await api.listGroups()).groups + const q = new URLSearchParams() + if (dateFrom.value) q.set('from', dateFrom.value) + if (dateTo.value) q.set('to', dateTo.value) + const qs = q.toString() + a.value = await api.analyticsWithQuery(qs ? `?${qs}` : '') } finally { loading.value = false } -}) +} +onMounted(load) - - diff --git a/frontend/src/views/MyBoard.vue b/frontend/src/views/MyBoard.vue new file mode 100644 index 0000000..4ae671a --- /dev/null +++ b/frontend/src/views/MyBoard.vue @@ -0,0 +1,93 @@ + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..eaa5e82 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,139 @@ + + + diff --git a/frontend/src/views/Training.vue b/frontend/src/views/Training.vue new file mode 100644 index 0000000..06c8f84 --- /dev/null +++ b/frontend/src/views/Training.vue @@ -0,0 +1,81 @@ + + + + +