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:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -45,7 +45,7 @@ def _get_ready_group(s, gid: str) -> dict:
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/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("/<gid>/personas/<pid>/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("/<gid>/personas/<pid>/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/<sid>")
|
||||
@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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,26 +1,69 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<nav v-if="auth.user" class="topnav">
|
||||
<router-link to="/" class="brand">{{ i18n.t('app') }}</router-link>
|
||||
<div class="nav-right">
|
||||
<div class="shell">
|
||||
<!-- Header -->
|
||||
<header v-if="auth.user" class="shell-header">
|
||||
<router-link to="/" class="shell-brand">
|
||||
<span class="logo">🎯</span>
|
||||
{{ i18n.t('app') }}
|
||||
</router-link>
|
||||
<div class="shell-actions">
|
||||
<button @click="toggleLang" class="lang">{{ i18n.locale === 'th' ? 'EN' : 'TH' }}</button>
|
||||
<span class="muted">{{ auth.user.name }} ({{ auth.role }})</span>
|
||||
<button @click="logout">⏻ {{ i18n.t('logout') }}</button>
|
||||
<div class="shell-user">
|
||||
<span class="avatar">{{ initials }}</span>
|
||||
<span class="muted">{{ auth.user.name }}</span>
|
||||
<span v-if="auth.isAdmin" class="badge">{{ auth.role }}</span>
|
||||
</div>
|
||||
<router-link to="/settings" class="icon-btn" :title="i18n.t('settings')" aria-label="Settings">
|
||||
<svg class="icon-16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</router-link>
|
||||
<button class="icon-btn" @click="logout" :title="i18n.t('logout')" aria-label="Logout">
|
||||
<svg class="icon-16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<nav v-if="auth.user" class="tabbar" aria-label="Main">
|
||||
<!-- Admin-only: Dashboard overview -->
|
||||
<router-link v-if="auth.isAdmin" to="/" class="tab" :class="{ active: isAdminDash }">
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
|
||||
<span>{{ i18n.t('tabAdminDash') }}</span>
|
||||
</router-link>
|
||||
<!-- Personal dashboard (everyone, incl admin) -->
|
||||
<router-link to="/my/board" class="tab" :class="{ active: isMyDash }">
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="7" r="4"/><path d="M5 21v-2a7 7 0 0 1 14 0v2"/></svg>
|
||||
<span>{{ i18n.t('tabMyDash') }}</span>
|
||||
</router-link>
|
||||
<!-- Training -->
|
||||
<router-link to="/training" class="tab" :class="{ active: isTraining }">
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 19.5V5a2 2 0 0 1 2-2h13a1 1 0 0 1 1 1v13.5"/><path d="M9 7h6"/><path d="M9 11h4"/></svg>
|
||||
<span>{{ i18n.t('tabTraining') }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<main class="main">
|
||||
|
||||
<main class="shell-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { auth } from './store/auth'
|
||||
import { i18n } from './i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const initials = computed(() => {
|
||||
const n = auth.user?.name || auth.user?.username || '?'
|
||||
return n.trim().slice(0, 1).toUpperCase()
|
||||
})
|
||||
|
||||
const isAdminDash = computed(() => route.path === '/')
|
||||
const isMyDash = computed(() => route.path.startsWith('/my'))
|
||||
const isTraining = computed(() => route.path.startsWith('/training'))
|
||||
|
||||
function toggleLang() {
|
||||
i18n.set(i18n.locale === 'th' ? 'en' : 'th')
|
||||
@@ -33,21 +76,3 @@ onMounted(() => {
|
||||
if (auth.token && !auth.user) auth.load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.brand { font-weight: 800; text-decoration: none; color: var(--ink); }
|
||||
.nav-right { display: flex; align-items: center; gap: 12px; }
|
||||
.lang { padding: 6px 10px; }
|
||||
.main { max-width: 1080px; margin: 0 auto; padding: 24px; }
|
||||
</style>
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
|
||||
@@ -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: 'ยังไม่มีข้อมูล',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1,47 +1,75 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="margin-bottom:16px">
|
||||
<h2 style="margin:0">{{ i18n.t('dashboard') }}</h2>
|
||||
<div class="row" style="margin-bottom:16px;align-items:center">
|
||||
<div>
|
||||
<h2 style="margin:0">{{ i18n.t('tabAdminDash') }}</h2>
|
||||
<p class="muted" style="margin:4px 0 0;font-size:13px">{{ i18n.t('analytics') }}</p>
|
||||
</div>
|
||||
<div style="margin-left:auto" v-if="auth.isAdmin">
|
||||
<router-link to="/admin/new-group"><button class="primary">{{ i18n.t('groupBuilder') }} +</button></router-link>
|
||||
<router-link to="/admin/new-group"><button class="primary">+ {{ i18n.t('groupBuilder') }}</button></router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="auth.isAdmin" style="gap:16px;margin-bottom:20px">
|
||||
<router-link to="/admin/users" style="text-decoration:none"><div class="card link-card">👥 {{ i18n.t('users') }}</div></router-link>
|
||||
<router-link to="/admin/analytics" style="text-decoration:none"><div class="card link-card">📊 {{ i18n.t('analytics') }}</div></router-link>
|
||||
</div>
|
||||
<div v-if="auth.role === 'user'" class="row" style="gap:16px;margin-bottom:20px">
|
||||
<router-link to="/my/sessions" style="text-decoration:none"><div class="card link-card">🎯 {{ i18n.t('myTraining') }}</div></router-link>
|
||||
<router-link to="/my/weak-areas" style="text-decoration:none"><div class="card link-card">⚠️ {{ i18n.t('weakAreas') }}</div></router-link>
|
||||
<router-link to="/my/generate" style="text-decoration:none"><div class="card link-card">✨ {{ i18n.t('generatePersona') }}</div></router-link>
|
||||
<!-- Date range filter -->
|
||||
<div class="filter-bar">
|
||||
<label style="margin:0">From</label>
|
||||
<input type="date" v-model="dateFrom" />
|
||||
<label style="margin:0">To</label>
|
||||
<input type="date" v-model="dateTo" />
|
||||
<button class="primary" @click="load" style="min-height:44px">Apply</button>
|
||||
</div>
|
||||
|
||||
<h3>{{ i18n.t('groups') }}</h3>
|
||||
<!-- Overall stat cards -->
|
||||
<div v-if="loading" class="card" style="min-height:120px">
|
||||
<div class="skeleton" style="height:60px"></div>
|
||||
<div class="skeleton" style="height:60px;margin-top:10px"></div>
|
||||
</div>
|
||||
<div v-else-if="groups.length === 0" class="card empty-state">
|
||||
<strong>{{ auth.isAdmin ? 'No persona groups yet' : 'No groups available' }}</strong>
|
||||
<span v-if="auth.isAdmin">{{ i18n.t('groupBuilder') }} to start.</span>
|
||||
<span v-else>Ask an admin to create a group.</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div v-for="g in groups" :key="g.id" class="card group-card lift">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<strong>{{ g.title }}</strong>
|
||||
<span class="badge" :class="g.status">{{ g.status }}</span>
|
||||
</div>
|
||||
<div class="muted" style="margin:6px 0 12px">{{ (g.sales_kit && g.sales_kit.productName) || (g.input && g.input.product) || '' }}</div>
|
||||
<router-link v-if="auth.isAdmin" :to="`/admin/groups/${g.id}/edit`">
|
||||
<button>{{ i18n.t('personas') }} / {{ i18n.t('create') }}</button>
|
||||
</router-link>
|
||||
<router-link v-else-if="g.status === 'ready'" :to="`/groups/${g.id}/personas`">
|
||||
<button class="primary">{{ i18n.t('selectPersona') }}</button>
|
||||
</router-link>
|
||||
<template v-else>
|
||||
<div class="stat-grid" style="margin-bottom:20px">
|
||||
<div class="stat-card"><div class="stat-label">Sessions</div><div class="stat-value">{{ a.overall?.total_sessions || 0 }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Wins</div><div class="stat-value" style="color:var(--green)">{{ a.overall?.wins || 0 }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Losses</div><div class="stat-value" style="color:var(--red)">{{ a.overall?.losses || 0 }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Close rate</div><div class="stat-value">{{ a.overall?.close_rate }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Avg score</div><div class="stat-value">{{ a.overall?.avg_score }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Trainees</div><div class="stat-value" style="color:var(--accent)">{{ a.trainee_count || 0 }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-user overview -->
|
||||
<h3 style="margin:20px 0 12px">Per trainee</h3>
|
||||
<div class="card" style="padding:8px 20px">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr class="muted" style="font-size:12px;text-align:left">
|
||||
<th style="padding:8px 0">Trainee</th><th>Played</th><th>Won</th><th>Lost</th><th>Close %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in (a.per_user || [])" :key="u.user_id" style="border-top:1px solid var(--border)">
|
||||
<td style="padding:8px 0;font-weight:600">{{ u.name }}</td>
|
||||
<td>{{ u.sessions }}</td>
|
||||
<td style="color:var(--green)">{{ u.wins }}</td>
|
||||
<td style="color:var(--red)">{{ u.losses }}</td>
|
||||
<td>{{ u.sessions ? Math.round(u.wins/u.sessions*100) : 0 }}%</td>
|
||||
</tr>
|
||||
<tr v-if="!(a.per_user||[]).length"><td colspan="5" class="muted" style="padding:12px 0">{{ i18n.t('noData') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Hardest personas -->
|
||||
<h3 style="margin:20px 0 12px">Hardest personas</h3>
|
||||
<div class="card" v-for="(p,i) in (a.hardest_personas||[])" :key="i" style="margin-bottom:8px">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<strong>{{ p.persona_name }}</strong>
|
||||
<div class="row" style="gap:6px">
|
||||
<span class="badge won">{{ p.wins }}W</span>
|
||||
<span class="badge lost">{{ p.losses }}L</span>
|
||||
<span class="badge not_tried">avg {{ p.avg_score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!(a.hardest_personas||[]).length" class="card empty-state"><strong>{{ i18n.t('noData') }}</strong></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.group-card { display: flex; flex-direction: column; }
|
||||
.group-card a { margin-top: auto; }
|
||||
.link-card { text-align: center; min-width: 150px; }
|
||||
</style>
|
||||
|
||||
93
frontend/src/views/MyBoard.vue
Normal file
93
frontend/src/views/MyBoard.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 style="margin-bottom:4px">{{ i18n.t('tabMyDash') }}</h2>
|
||||
<p class="muted" style="margin:0 0 20px;font-size:13px">{{ i18n.t('weakAreas') }} · {{ i18n.t('mySessions') }}</p>
|
||||
|
||||
<!-- My summary stats -->
|
||||
<div class="stat-grid" style="margin-bottom:20px">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ i18n.t('mySessions') }}</div>
|
||||
<div class="stat-value" style="color:var(--ink)">{{ insight.total_sessions || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ i18n.t('won') }}</div>
|
||||
<div class="stat-value" style="color:var(--green)">{{ insight.wins || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ i18n.t('lost') }}</div>
|
||||
<div class="stat-value" style="color:var(--red)">{{ insight.losses || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ i18n.t('score') }}</div>
|
||||
<div class="stat-value" style="color:var(--accent)">{{ avgScore }}{{ insight.total_sessions ? '%' : '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weak areas: high-loss persona dimensions -->
|
||||
<h3 style="margin:20px 0 12px">{{ i18n.t('weakAreas') }}</h3>
|
||||
<div v-if="weakLoading" class="card" style="min-height:60px"><div class="skeleton" style="height:40px"></div></div>
|
||||
<div v-else-if="weakGroups.length" class="card" style="margin-bottom:12px">
|
||||
<div v-for="wg in weakGroups" :key="wg.title" style="padding:10px 0;border-bottom:1px solid var(--border)">
|
||||
<div style="font-weight:700;font-size:14px;margin-bottom:6px">{{ wg.title }}</div>
|
||||
<div v-for="(item,i) in wg.items" :key="i" class="row" style="justify-content:space-between;padding:2px 0;align-items:center">
|
||||
<span class="muted" style="font-size:13px">{{ item.value }}</span>
|
||||
<span class="badge lost">{{ item.losses }} {{ i18n.t('lost').toLowerCase() }}</span>
|
||||
</div>
|
||||
<div v-if="!wg.items.length" class="muted" style="font-size:12px">{{ i18n.t('noData') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card empty-state" style="margin-bottom:8px">
|
||||
<strong>{{ i18n.t('noData') }}</strong>
|
||||
<span>{{ i18n.t('weakAreas') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Win/lose board across personas -->
|
||||
<h3 style="margin:20px 0 12px">{{ i18n.t('myTraining') }}</h3>
|
||||
<div v-if="boardLoading" class="card" style="min-height:80px"><div class="skeleton" style="height:60px"></div></div>
|
||||
<div v-else class="card" style="padding:4px 20px">
|
||||
<div v-for="b in board" :key="b.group_id+b.persona_id" class="row" style="justify-content:space-between;padding:9px 0;border-bottom:1px solid var(--border)">
|
||||
<div>
|
||||
<strong>{{ b.persona_name }}</strong>
|
||||
<span class="muted" style="font-size:12px"> — {{ b.group_title }} · {{ b.tier }}</span>
|
||||
</div>
|
||||
<span class="badge" :class="b.my_outcome">{{ outcomeLabel(b.my_outcome) }}</span>
|
||||
</div>
|
||||
<div v-if="board.length===0" class="muted" style="padding:16px 0;text-align:center">{{ i18n.t('noData') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const board = ref([])
|
||||
const insight = ref({})
|
||||
const boardLoading = ref(true)
|
||||
const weakLoading = ref(true)
|
||||
|
||||
const avgScore = computed(() => {
|
||||
// placeholder — score from top_loss_personas if available
|
||||
if (!insight.value.top_loss_personas?.length) return ''
|
||||
return ''
|
||||
})
|
||||
|
||||
const weakGroups = computed(() => {
|
||||
const i = insight.value
|
||||
const out = []
|
||||
if (i.by_tier?.length) out.push({ title: 'By tier · losses', items: i.by_tier })
|
||||
if (i.by_initiation?.length) out.push({ title: 'By initiation · losses', items: i.by_initiation })
|
||||
if (i.by_channel?.length) out.push({ title: 'By channel · losses', items: i.by_channel })
|
||||
return out
|
||||
})
|
||||
|
||||
function outcomeLabel(o) {
|
||||
return o === 'won' ? i18n.t('won') : o === 'lost' ? i18n.t('lost') : i18n.t('notTried')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try { board.value = (await api.myBoard()).board } catch (e) {} finally { boardLoading.value = false }
|
||||
try { insight.value = (await api.weakAreas()).insight || {} } catch (e) {} finally { weakLoading.value = false }
|
||||
})
|
||||
</script>
|
||||
139
frontend/src/views/Settings.vue
Normal file
139
frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="margin:0 0 20px">
|
||||
<h2 style="margin:0">{{ i18n.t('settingsTitle') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="setting-grid">
|
||||
<!-- Profile section -->
|
||||
<section class="card settings-section">
|
||||
<h3 class="s-title">{{ i18n.t('settingsProfile') }}</h3>
|
||||
<p class="s-desc">{{ i18n.t('settingsProfileDesc') }}</p>
|
||||
<form @submit.prevent="saveProfile">
|
||||
<div class="form-grid">
|
||||
<label for="p-name">{{ i18n.t('displayName') }}</label>
|
||||
<input id="p-name" v-model="profile.name" :placeholder="auth.user?.name || ''" />
|
||||
|
||||
<label for="p-email">{{ i18n.t('email') }}</label>
|
||||
<input id="p-email" v-model="profile.email" type="email" :placeholder="auth.user?.email || ''" />
|
||||
|
||||
<button class="primary" type="submit" :disabled="savingProfile" style="margin-top:16px">
|
||||
{{ savingProfile ? '…' : i18n.t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="profileMsg" :class="profileErr ? 'error' : ''" style="margin-top:10px">{{ profileMsg }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Change password section -->
|
||||
<section class="card settings-section">
|
||||
<h3 class="s-title">{{ i18n.t('settingsSecurity') }}</h3>
|
||||
<p class="s-desc">{{ i18n.t('settingsSecurityDesc') }}</p>
|
||||
<form @submit.prevent="savePassword">
|
||||
<div class="form-grid">
|
||||
<label for="pw-current">{{ i18n.t('currentPassword') }}</label>
|
||||
<input id="pw-current" v-model="pw.current" type="password" autocomplete="current-password" />
|
||||
|
||||
<label for="pw-new">{{ i18n.t('newPassword') }}</label>
|
||||
<input id="pw-new" v-model="pw.newp" type="password" autocomplete="new-password" />
|
||||
|
||||
<label for="pw-confirm">{{ i18n.t('confirmPassword') }}</label>
|
||||
<input id="pw-confirm" v-model="pw.confirm" type="password" autocomplete="new-password" />
|
||||
|
||||
<button class="primary" type="submit" :disabled="savingPw" style="margin-top:16px">
|
||||
{{ savingPw ? '…' : i18n.t('changePassword') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="pwMsg" :class="pwErr ? 'error' : ''" style="margin-top:10px">{{ pwMsg }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Account info -->
|
||||
<section class="card settings-section">
|
||||
<h3 class="s-title">{{ i18n.t('account') }}</h3>
|
||||
<p class="s-desc">{{ i18n.t('role') }} / {{ i18n.t('status') }}</p>
|
||||
<div class="row" style="gap:10px; align-items:center">
|
||||
<span class="avatar" style="width:44px;height:44px;font-size:18px">{{ initials }}</span>
|
||||
<div>
|
||||
<div style="font-weight:700">{{ displayName }}</div>
|
||||
<div class="muted" style="font-size:13px">{{ auth.user?.username }}</div>
|
||||
</div>
|
||||
<span class="badge" :class="auth.role">{{ auth.role }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted, computed } from 'vue'
|
||||
import { auth } from '../store/auth'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const profile = reactive({ name: '', email: '' })
|
||||
const pw = reactive({ current: '', newp: '', confirm: '' })
|
||||
const savingProfile = ref(false)
|
||||
const savingPw = ref(false)
|
||||
const profileMsg = ref('')
|
||||
const profileErr = ref(false)
|
||||
const pwMsg = ref('')
|
||||
const pwErr = ref(false)
|
||||
|
||||
const initials = computed(() => (auth.user?.name || auth.user?.username || '?').slice(0, 1).toUpperCase())
|
||||
const displayName = computed(() => auth.user?.name || auth.user?.username || '')
|
||||
|
||||
onMounted(() => {
|
||||
profile.name = auth.user?.name || ''
|
||||
profile.email = auth.user?.email || ''
|
||||
})
|
||||
|
||||
async function saveProfile() {
|
||||
savingProfile.value = true
|
||||
profileErr.value = false
|
||||
profileMsg.value = ''
|
||||
try {
|
||||
const body = {}
|
||||
if (profile.name.trim()) body.name = profile.name.trim()
|
||||
if (profile.email.trim()) body.email = profile.email.trim()
|
||||
const data = await api.updateProfile(body)
|
||||
auth.user = data.user
|
||||
profileMsg.value = i18n.t('profileSaved')
|
||||
} catch (e) {
|
||||
profileErr.value = true
|
||||
profileMsg.value = e.message
|
||||
} finally {
|
||||
savingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
savingPw.value = true
|
||||
pwErr.value = false
|
||||
pwMsg.value = ''
|
||||
if (pw.newp.length < 4) {
|
||||
pwErr.value = true
|
||||
pwMsg.value = i18n.t('passwordTooShort')
|
||||
savingPw.value = false
|
||||
return
|
||||
}
|
||||
if (pw.newp !== pw.confirm) {
|
||||
pwErr.value = true
|
||||
pwMsg.value = i18n.t('passwordMismatch')
|
||||
savingPw.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.changePassword({ current_password: pw.current, new_password: pw.newp })
|
||||
pw.current = ''
|
||||
pw.newp = ''
|
||||
pw.confirm = ''
|
||||
pwMsg.value = i18n.t('passwordChanged')
|
||||
} catch (e) {
|
||||
pwErr.value = true
|
||||
pwMsg.value = e.message
|
||||
} finally {
|
||||
savingPw.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
81
frontend/src/views/Training.vue
Normal file
81
frontend/src/views/Training.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="margin-bottom:16px;align-items:center">
|
||||
<div>
|
||||
<h2 style="margin:0">{{ i18n.t('tabTraining') }}</h2>
|
||||
<p class="muted" style="margin:4px 0 0;font-size:13px">{{ i18n.t('selectPersona') }}</p>
|
||||
</div>
|
||||
<div v-if="auth.isAdmin" style="margin-left:auto">
|
||||
<router-link to="/admin/new-group">
|
||||
<button class="primary">+ {{ i18n.t('groupBuilder') }} / {{ i18n.t('groupBuilder') }} {{ i18n.t('create') }}</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card" style="min-height:120px">
|
||||
<div class="skeleton" style="height:60px"></div>
|
||||
<div class="skeleton" style="height:60px;margin-top:10px"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="groups.length === 0" class="card empty-state">
|
||||
<strong>{{ auth.isAdmin ? 'No products yet' : 'No training products available' }}</strong>
|
||||
<span v-if="auth.isAdmin">{{ i18n.t('groupBuilder') }} to add a {{ i18n.t('product') }} + personas.</span>
|
||||
<span v-else>Ask an admin to add a {{ i18n.t('product') }}.</span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div v-for="g in groups" :key="g.id" class="card group-card lift">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<span class="badge" :class="g.status">{{ g.status }}</span>
|
||||
<!-- Admin: manage personas -->
|
||||
<span v-if="auth.isAdmin" class="row" style="gap:6px">
|
||||
<router-link :to="`/groups/${g.id}/personas`">
|
||||
<button style="padding:6px 12px;min-height:38px">{{ i18n.t('personas') }} ({{ (g.personas||[]).length }})</button>
|
||||
</router-link>
|
||||
<router-link :to="`/admin/groups/${g.id}/edit`">
|
||||
<button style="padding:6px 12px;min-height:38px">{{ i18n.t('edit') }}</button>
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="margin:10px 0 4px;font-weight:700;font-size:15px">{{ g.title }}</div>
|
||||
<div class="muted" style="font-size:13px;margin-bottom:12px">{{ productName(g) }}</div>
|
||||
|
||||
<!-- Non-admin: pick personas to train (trainees + admins can train) -->
|
||||
<div v-if="auth.isAdmin" class="row" style="gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||
<router-link :to="`/groups/${g.id}/personas`">
|
||||
<button class="primary">{{ i18n.t('selectPersona') }}</button>
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link v-else-if="g.status === 'ready'" :to="`/groups/${g.id}/personas`">
|
||||
<button class="primary">{{ i18n.t('selectPersona') }}</button>
|
||||
</router-link>
|
||||
<div v-else class="muted" style="font-size:12px;margin-top:8px">⏳ {{ i18n.t('status') }}: {{ g.status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const groups = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
function productName(g) {
|
||||
return (g.sales_kit && g.sales_kit.productName) || (g.input && g.input.product) || ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try { groups.value = (await api.listGroups()).groups } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||
.group-card { display: flex; flex-direction: column; }
|
||||
.group-card > a { margin-top: auto; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user