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 __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__) 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("") @analytics_bp.get("")
@require_auth @require_auth
@require_roles("admin") @require_roles("admin")
def analytics(): def analytics():
s = _stores() s = _stores()
actor = current_user() 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": if actor.get("role") == "super_admin":
sessions = s["sessions"].sessions.all() sessions = s["sessions"].sessions.all()
users = s["users"].list_users() users = s["users"].list_users()
else: else:
org_id = actor.get("org_id") org_id = actor.get("org_id")
# users in this org
users = s["users"].list_users(org_id=org_id) users = s["users"].list_users(org_id=org_id)
user_ids = {u["id"] for u in users} user_ids = {u["id"] for u in users}
sessions = [ sessions = [
x for x in s["sessions"].sessions.all() if x.get("user_id") in user_ids 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 = { overall = {
"total_sessions": len(sessions), "total_sessions": len(sessions),
"wins": sum(1 for x in sessions if x.get("outcome") == "won"), "wins": sum(1 for x in sessions if x.get("outcome") == "won"),
"losses": sum(1 for x in sessions if x.get("outcome") == "lost"), "losses": sum(1 for x in sessions if x.get("outcome") == "lost"),
} }
overall["close_rate"] = round( overall["close_rate"] = (
overall["wins"] / overall["total_sessions"] * 100, 1 round(overall["wins"] / overall["total_sessions"] * 100, 1)
) if overall["total_sessions"] else 0 if overall["total_sessions"]
else 0
)
# average score # 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 overall["avg_score"] = round(sum(scores) / len(scores), 1) if scores else 0
# hardest personas = personas with most losses (lowest avg score) # hardest personas = personas with most losses (lowest avg score)
@@ -69,15 +113,39 @@ def analytics():
"plays": v["plays"], "plays": v["plays"],
"wins": v["wins"], "wins": v["wins"],
"losses": v["losses"], "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() for k, v in by_persona.items()
), ),
key=lambda r: (r["losses"], -r["avg_score"]), key=lambda r: (r["losses"], -r["avg_score"]),
)[:10] )[: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({ return jsonify({
"overall": overall, "overall": overall,
"trainee_count": len(users), "trainee_count": len(users),
"hardest_personas": hardest, "hardest_personas": hardest,
"per_user": per_user,
}) })

View File

@@ -65,3 +65,49 @@ def setup():
"user": _store().public_user(updated), "user": _store().public_user(updated),
"must_setup": False, "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)})

View File

@@ -45,7 +45,7 @@ def _get_ready_group(s, gid: str) -> dict:
@chat_bp.post("/<gid>/personas/<pid>/chat/start") @chat_bp.post("/<gid>/personas/<pid>/chat/start")
@require_auth @require_auth
@require_roles("user") @require_roles("user", "admin", "super_admin")
def start_session(gid: str, pid: str): def start_session(gid: str, pid: str):
s = _stores() s = _stores()
group = _get_ready_group(s, gid) 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") @chat_bp.post("/<gid>/personas/<pid>/chat/send")
@require_auth @require_auth
@require_roles("user") @require_roles("user", "admin", "super_admin")
def send_message(gid: str, pid: str): def send_message(gid: str, pid: str):
s = _stores() s = _stores()
actor = current_user() actor = current_user()
@@ -125,7 +125,7 @@ def send_message(gid: str, pid: str):
@chat_bp.post("/<gid>/personas/<pid>/chat/finish") @chat_bp.post("/<gid>/personas/<pid>/chat/finish")
@require_auth @require_auth
@require_roles("user") @require_roles("user", "admin", "super_admin")
def finish_session(gid: str, pid: str): def finish_session(gid: str, pid: str):
"""End the chat and produce the debrief via the judge-LLM (reveals latent fields).""" """End the chat and produce the debrief via the judge-LLM (reveals latent fields)."""
s = _stores() s = _stores()
@@ -168,7 +168,7 @@ def finish_session(gid: str, pid: str):
@chat_bp.get("/sessions") @chat_bp.get("/sessions")
@require_auth @require_auth
@require_roles("user") @require_roles("user", "admin", "super_admin")
def my_sessions(): def my_sessions():
s = _stores() s = _stores()
uid = current_user()["id"] uid = current_user()["id"]
@@ -178,7 +178,7 @@ def my_sessions():
@chat_bp.get("/sessions/<sid>") @chat_bp.get("/sessions/<sid>")
@require_auth @require_auth
@require_roles("user") @require_roles("user", "admin", "super_admin")
def get_session(sid: str): def get_session(sid: str):
s = _stores() s = _stores()
session = s["sessions"].get_or_none(sid) session = s["sessions"].get_or_none(sid)

View File

@@ -23,15 +23,20 @@ def _stores():
@me_bp.get("/board") @me_bp.get("/board")
@require_auth @require_auth
@require_roles("user") @require_roles("user", "admin", "super_admin")
def win_lose_board(): 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() 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) 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} 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). # 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] groups = [g for g in groups if not g.get("owner_user_id") or g.get("owner_user_id") == uid]
items = [] items = []
@@ -51,7 +56,7 @@ def win_lose_board():
@me_bp.get("/weak-areas") @me_bp.get("/weak-areas")
@require_auth @require_auth
@require_roles("user") @require_roles("user", "admin", "super_admin")
def weak_areas(): def weak_areas():
s = _stores() s = _stores()
uid = current_user()["id"] uid = current_user()["id"]
@@ -83,7 +88,7 @@ def _personal_group(s, actor) -> dict:
@me_bp.get("/personas") @me_bp.get("/personas")
@require_auth @require_auth
@require_roles("user") @require_roles("user", "admin", "super_admin")
def my_personas(): def my_personas():
s = _stores() s = _stores()
uid = current_user()["id"] uid = current_user()["id"]
@@ -93,7 +98,7 @@ def my_personas():
@me_bp.post("/personas/generate") @me_bp.post("/personas/generate")
@require_auth @require_auth
@require_roles("user") @require_roles("user", "admin", "super_admin")
def generate_persona(): def generate_persona():
s = _stores() s = _stores()
actor = current_user() actor = current_user()

View File

@@ -1,26 +1,69 @@
<template> <template>
<div class="app"> <div class="shell">
<nav v-if="auth.user" class="topnav"> <!-- Header -->
<router-link to="/" class="brand">{{ i18n.t('app') }}</router-link> <header v-if="auth.user" class="shell-header">
<div class="nav-right"> <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> <button @click="toggleLang" class="lang">{{ i18n.locale === 'th' ? 'EN' : 'TH' }}</button>
<span class="muted">{{ auth.user.name }} ({{ auth.role }})</span> <div class="shell-user">
<button @click="logout"> {{ i18n.t('logout') }}</button> <span class="avatar">{{ initials }}</span>
<span class="muted">{{ auth.user.name }}</span>
<span v-if="auth.isAdmin" class="badge">{{ auth.role }}</span>
</div> </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> </nav>
<main class="main">
<main class="shell-main">
<router-view /> <router-view />
</main> </main>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { auth } from './store/auth' import { auth } from './store/auth'
import { i18n } from './i18n' import { i18n } from './i18n'
const router = useRouter() 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() { function toggleLang() {
i18n.set(i18n.locale === 'th' ? 'en' : 'th') i18n.set(i18n.locale === 'th' ? 'en' : 'th')
@@ -33,21 +76,3 @@ onMounted(() => {
if (auth.token && !auth.user) auth.load() if (auth.token && !auth.user) auth.load()
}) })
</script> </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>

View File

@@ -36,6 +36,8 @@ export const api = {
login: (username, password) => request('POST', '/api/auth/login', { username, password }), login: (username, password) => request('POST', '/api/auth/login', { username, password }),
me: () => request('GET', '/api/auth/me'), me: () => request('GET', '/api/auth/me'),
setup: (b) => request('POST', '/api/auth/setup', b), 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), adminCreateUser: (b) => request('POST', '/api/admin/users', b),
adminListUsers: () => request('GET', '/api/admin/users'), adminListUsers: () => request('GET', '/api/admin/users'),
adminUpdateUser: (username, b) => request('PUT', `/api/admin/users/${username}`, b), adminUpdateUser: (username, b) => request('PUT', `/api/admin/users/${username}`, b),
@@ -55,4 +57,5 @@ export const api = {
myPersonas: () => request('GET', '/api/me/personas'), myPersonas: () => request('GET', '/api/me/personas'),
generatePersona: (b) => request('POST', '/api/me/personas/generate', b), generatePersona: (b) => request('POST', '/api/me/personas/generate', b),
analytics: () => request('GET', '/api/analytics'), analytics: () => request('GET', '/api/analytics'),
analyticsWithQuery: (qs) => request('GET', `/api/analytics${qs}`),
} }

View File

@@ -60,6 +60,25 @@ const messages = {
openSaleTask: 'The customer did NOT message first. You must open the sale.', openSaleTask: 'The customer did NOT message first. You must open the sale.',
sellerInitiated: 'You must open the sale (outbound)', sellerInitiated: 'You must open the sale (outbound)',
customerInitiated: 'The customer will message you first', 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: { th: {
app: 'ตัวฝึกขาย', app: 'ตัวฝึกขาย',
@@ -119,6 +138,25 @@ const messages = {
openSaleTask: 'ลูกค้ายังไม่ได้ทักมา คุณต้องเป็นฝ่ายเปิดการขายเอง', openSaleTask: 'ลูกค้ายังไม่ได้ทักมา คุณต้องเป็นฝ่ายเปิดการขายเอง',
sellerInitiated: 'คุณต้องเปิดการขาย (เชิงรุก)', sellerInitiated: 'คุณต้องเปิดการขาย (เชิงรุก)',
customerInitiated: 'ลูกค้าจะทักมาเองก่อน', customerInitiated: 'ลูกค้าจะทักมาเองก่อน',
tabAdminDash: 'ภาพรวม',
tabMyDash: 'แดชบอร์ดของฉัน',
tabTraining: 'การฝึก',
settings: 'ตั้งค่า',
settingsTitle: 'ตั้งค่า',
settingsProfile: 'โปรไฟล์',
settingsSecurity: 'เปลี่ยนรหัสผ่าน',
settingsProfileDesc: 'อัปเดตชื่อและอีเมลของคุณ',
settingsSecurityDesc: 'เปลี่ยนรหัสผ่านบัญชีของคุณ',
displayName: 'ชื่อที่แสดง',
currentPassword: 'รหัสผ่านปัจจุบัน',
changePassword: 'เปลี่ยนรหัสผ่าน',
passwordChanged: 'เปลี่ยนรหัสผ่านสำเร็จ',
profileSaved: 'บันทึกโปรไฟล์สำเร็จ',
currentPasswordWrong: 'รหัสผ่านปัจจุบันไม่ถูกต้อง',
account: 'บัญชี',
status: 'สถานะ',
role: 'บทบาท',
noData: 'ยังไม่มีข้อมูล',
}, },
} }

View File

@@ -4,12 +4,20 @@ import { auth } from '../store/auth'
const routes = [ const routes = [
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } }, { path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
{ path: '/setup', component: () => import('../views/Setup.vue') }, { path: '/setup', component: () => import('../views/Setup.vue') },
{ path: '/', component: () => import('../views/Dashboard.vue') }, // Tab 1: Admin overview dashboard (admin only)
{ path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') }, { path: '/', component: () => import('../views/Dashboard.vue'), meta: { admin: true } },
{ path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') }, // 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/sessions', component: () => import('../views/MySessions.vue') },
{ path: '/my/weak-areas', component: () => import('../views/WeakAreas.vue') }, { path: '/my/weak-areas', component: () => import('../views/WeakAreas.vue') },
{ path: '/my/generate', component: () => import('../views/GenPersona.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/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/groups/:gid/edit', component: () => import('../views/GroupEdit.vue'), meta: { admin: true } },
{ path: '/admin/users', component: () => import('../views/AdminUsers.vue'), meta: { admin: true } }, { path: '/admin/users', component: () => import('../views/AdminUsers.vue'), meta: { admin: true } },
@@ -21,6 +29,10 @@ const router = createRouter({
routes, routes,
}) })
function needSetup(user) {
return !!(user && user.must_setup)
}
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
if (to.meta.public) return true if (to.meta.public) return true
if (!auth.user) { if (!auth.user) {
@@ -30,11 +42,11 @@ router.beforeEach(async (to) => {
return { path: '/login', query: { redirect: to.fullPath } } return { path: '/login', query: { redirect: to.fullPath } }
} }
// Force the mandatory first-time setup (set email + change password) before use. // 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' } return { path: '/setup' }
} }
if (to.meta.admin && !auth.isAdmin) { if (to.meta.admin && !auth.isAdmin) {
return { path: '/' } return { path: '/my/board' }
} }
return true return true
}) })

View File

@@ -1,16 +1,20 @@
:root { :root {
--bg: #f6f7fb; --bg: #f3f5fa;
--card: #ffffff; --card: #ffffff;
--border: #e5e8ef; --border: #e5e8ef;
--ink: #1a1d29; --ink: #111827;
--muted: #6b7280; --muted: #6b7280;
--accent: #4f46e5; --accent: #4f46e5;
--accent-2: #7c3aed; --accent-2: #7c3aed;
--accent-soft: #eef2ff;
--green: #16a34a; --green: #16a34a;
--red: #dc2626; --red: #dc2626;
--amber: #d97706; --amber: #d97706;
--radius: 14px; --radius: 14px;
--radius-sm: 10px;
--shadow: 0 1px 3px rgba(20, 24, 40, 0.08); --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; } * { box-sizing: border-box; }
html, body { margin: 0; padding: 0; } html, body { margin: 0; padding: 0; }
@@ -152,3 +156,65 @@ button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.row { gap: 10px; } .row { gap: 10px; }
.msg-seller, .msg-customer { max-width: 84%; } .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; }
}

View File

@@ -1,48 +1,76 @@
<template> <template>
<div> <div>
<div class="row" style="margin-bottom:16px"> <div class="row" style="margin-bottom:16px;align-items:center">
<h2 style="margin:0">{{ i18n.t('dashboard') }}</h2> <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"> <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> </div>
<div class="row" v-if="auth.isAdmin" style="gap:16px;margin-bottom:20px"> <!-- Date range filter -->
<router-link to="/admin/users" style="text-decoration:none"><div class="card link-card">👥 {{ i18n.t('users') }}</div></router-link> <div class="filter-bar">
<router-link to="/admin/analytics" style="text-decoration:none"><div class="card link-card">📊 {{ i18n.t('analytics') }}</div></router-link> <label style="margin:0">From</label>
</div> <input type="date" v-model="dateFrom" />
<div v-if="auth.role === 'user'" class="row" style="gap:16px;margin-bottom:20px"> <label style="margin:0">To</label>
<router-link to="/my/sessions" style="text-decoration:none"><div class="card link-card">🎯 {{ i18n.t('myTraining') }}</div></router-link> <input type="date" v-model="dateTo" />
<router-link to="/my/weak-areas" style="text-decoration:none"><div class="card link-card"> {{ i18n.t('weakAreas') }}</div></router-link> <button class="primary" @click="load" style="min-height:44px">Apply</button>
<router-link to="/my/generate" style="text-decoration:none"><div class="card link-card"> {{ i18n.t('generatePersona') }}</div></router-link>
</div> </div>
<h3>{{ i18n.t('groups') }}</h3> <!-- Overall stat cards -->
<div v-if="loading" class="card" style="min-height:120px"> <div v-if="loading" class="card" style="min-height:120px">
<div class="skeleton" style="height:60px"></div> <div class="skeleton" style="height:60px"></div>
<div class="skeleton" style="height:60px;margin-top:10px"></div> <div class="skeleton" style="height:60px;margin-top:10px"></div>
</div> </div>
<div v-else-if="groups.length === 0" class="card empty-state"> <template v-else>
<strong>{{ auth.isAdmin ? 'No persona groups yet' : 'No groups available' }}</strong> <div class="stat-grid" style="margin-bottom:20px">
<span v-if="auth.isAdmin">{{ i18n.t('groupBuilder') }} to start.</span> <div class="stat-card"><div class="stat-label">Sessions</div><div class="stat-value">{{ a.overall?.total_sessions || 0 }}</div></div>
<span v-else>Ask an admin to create a group.</span> <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>
<div class="grid">
<div v-for="g in groups" :key="g.id" class="card group-card lift"> <!-- Per-user overview -->
<div class="row" style="justify-content:space-between"> <h3 style="margin:20px 0 12px">Per trainee</h3>
<strong>{{ g.title }}</strong> <div class="card" style="padding:8px 20px">
<span class="badge" :class="g.status">{{ g.status }}</span> <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> </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`"> <!-- Hardest personas -->
<button>{{ i18n.t('personas') }} / {{ i18n.t('create') }}</button> <h3 style="margin:20px 0 12px">Hardest personas</h3>
</router-link> <div class="card" v-for="(p,i) in (a.hardest_personas||[])" :key="i" style="margin-bottom:8px">
<router-link v-else-if="g.status === 'ready'" :to="`/groups/${g.id}/personas`"> <div class="row" style="justify-content:space-between;align-items:center">
<button class="primary">{{ i18n.t('selectPersona') }}</button> <strong>{{ p.persona_name }}</strong>
</router-link> <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>
</div> </div>
<div v-if="!(a.hardest_personas||[]).length" class="card empty-state"><strong>{{ i18n.t('noData') }}</strong></div>
</template>
</div>
</template> </template>
<script setup> <script setup>
@@ -51,21 +79,27 @@ import { api } from '../api'
import { auth } from '../store/auth' import { auth } from '../store/auth'
import { i18n } from '../i18n' 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) const loading = ref(true)
onMounted(async () => { async function load() {
loading.value = true
try { 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 { } finally {
loading.value = false loading.value = false
} }
}) }
onMounted(load)
</script> </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>

View 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>

View 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>

View 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>