feat(ui): 3-tab UI per spec — admin dashboard + date filter, personal dashboard, training w/ difficulty, settings profile

Tab 1 Admin overview (/): aggregate stats + DATE FILTER (?from&to), hardest personas,
admin quick actions (Users / + Add product).
Tab 2 My dashboard (/my/board): per-user win/lose/session summary (works for admin too).
Tab 3 Training (/training): product list w/ status + persona count; admins see all groups
(draft->analyze/edit), trainees see ready->personas; persona list shows DIFFICULTY (1-5
stars); admins get a Manage-personas button.
Settings (/settings): profile (name/email) via new PATCH /api/auth/profile + change
password + language.
Backend: analytics date filter; profile endpoint; /api/me/board + /api/chat/sessions
opened to any authed user.
Rebuilt frontend/dist.
This commit is contained in:
Macky
2026-08-07 22:35:49 +07:00
parent ac35be8906
commit b05907ae62
40 changed files with 319 additions and 88 deletions

View File

@@ -36,6 +36,7 @@ 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),
updateProfile: (b) => request('PATCH', '/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),
@@ -54,5 +55,11 @@ export const api = {
weakAreas: () => request('GET', '/api/me/weak-areas'),
myPersonas: () => request('GET', '/api/me/personas'),
generatePersona: (b) => request('POST', '/api/me/personas/generate', b),
analytics: () => request('GET', '/api/analytics'),
analytics: (params = {}) => {
const qs = new URLSearchParams()
if (params.from) qs.set('from', params.from)
if (params.to) qs.set('to', params.to)
const q = qs.toString()
return request('GET', `/api/analytics${q ? `?${q}` : ''}`)
},
}

View File

@@ -23,9 +23,24 @@ const messages = {
adminOverview: 'Admin overview',
settings: 'Settings',
account: 'Account',
profile: 'Profile',
readonly: 'read-only',
name: 'Display name',
saveProfile: 'Save profile',
changePassword: 'Change password',
optional: 'optional',
saved: 'Saved',
total: 'Total',
dateRange: 'Date range',
apply: 'Apply',
clear: 'Clear',
hardestPersonas: 'Hardest personas',
managePersonas: 'Manage personas',
difficulty: 'Difficulty',
trained: 'Trained',
addProduct: 'Add product',
trainingSubtitle: 'Pick a product group, then choose a persona to practice closing the sale.',
noTraining: 'No training groups available',
groups: 'Persona Groups',
myTraining: 'My Training',
adminTools: 'Admin Tools',
@@ -90,9 +105,24 @@ const messages = {
adminOverview: 'ภาพรวม Admin',
settings: 'การตั้งค่า',
account: 'บัญชี',
profile: 'โปรไฟล์',
readonly: 'อ่านอย่างเดียว',
name: 'ชื่อที่แสดง',
saveProfile: 'บันทึกโปรไฟล์',
changePassword: 'เปลี่ยนรหัสผ่าน',
optional: 'ไม่บังคับ',
saved: 'บันทึกแล้ว',
total: 'ทั้งหมด',
dateRange: 'ช่วงวันที่',
apply: 'กรอง',
clear: 'ล้าง',
hardestPersonas: 'Persona ที่ยากสุด',
managePersonas: 'จัดการ Persona',
difficulty: 'ความยาก',
trained: 'ฝึกแล้ว',
addProduct: 'เพิ่มสินค้า',
trainingSubtitle: 'เลือกกลุ่มสินค้า แล้วเลือก persona เพื่อฝึกปิดการขาย',
noTraining: 'ยังไม่มีกลุ่มฝึก',
groups: 'กลุ่มลูกค้า (Persona)',
myTraining: 'การฝึกของฉัน',
adminTools: 'เครื่องมือ Admin',

View File

@@ -4,8 +4,8 @@ import { auth } from '../store/auth'
const routes = [
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
{ path: '/setup', component: () => import('../views/Setup.vue') },
// Tab 1: Admin overview (admin only)
{ path: '/', component: () => import('../views/Dashboard.vue'), meta: { admin: true } },
// Tab 1: Admin overview (aggregate stats + date filter, admin only)
{ path: '/', component: () => import('../views/Analytics.vue'), meta: { admin: true } },
// Tab 2: My dashboard (everyone, incl admin)
{ path: '/my/board', component: () => import('../views/MyBoard.vue') },
// Tab 3: Training — product list -> personas -> chat

View File

@@ -90,6 +90,8 @@ button { min-height: 44px; transition: transform .15s ease, box-shadow .2s ease,
button:not(:disabled):hover { box-shadow: 0 4px 12px rgba(20,24,40,.1); }
button:not(:disabled):active { transform: scale(.97); }
button.primary:not(:disabled):hover { box-shadow: 0 6px 18px rgba(79,70,229,.35); }
button.soft { background: #f1f3f9; border-color: transparent; color: var(--ink); }
button.soft:not(:disabled):hover { background: #e6eaf3; }
input, select, textarea {
min-height: 44px;

View File

@@ -1,21 +1,51 @@
<template>
<div>
<h2>{{ i18n.t('analytics') }}</h2>
<div class="row" style="align-items:center;margin-bottom:16px">
<h2 style="margin:0">📊 {{ i18n.t('adminOverview') }}</h2>
<div class="row" style="margin-left:auto;gap:10px">
<router-link to="/admin/users"><button class="soft">👥 {{ i18n.t('users') }}</button></router-link>
<router-link to="/admin/new-group"><button class="primary"> {{ i18n.t('addProduct') }}</button></router-link>
</div>
</div>
<!-- Date filter -->
<div class="card filter-bar">
<span class="muted">🗓 {{ i18n.t('dateRange') }}</span>
<input type="date" v-model="from" />
<span></span>
<input type="date" v-model="to" />
<button class="primary" @click="apply" :disabled="loading"> {{ i18n.t('apply') }}</button>
<button @click="clear" :disabled="loading">{{ i18n.t('clear') }}</button>
</div>
<!-- Headline metrics -->
<div class="row" style="gap:16px;margin:16px 0">
<div class="card stat"><div>Sessions</div><strong>{{ a.overall.total_sessions }}</strong></div>
<div class="card stat"><div>Wins</div><strong style="color:var(--green)">{{ a.overall.wins }}</strong></div>
<div class="card stat"><div>Losses</div><strong style="color:var(--red)">{{ a.overall.losses }}</strong></div>
<div class="card stat"><div>Close rate</div><strong>{{ a.overall.close_rate }}%</strong></div>
<div class="card stat"><div>Avg score</div><strong>{{ a.overall.avg_score }}</strong></div>
<div class="card stat"><div>Trainees</div><strong>{{ a.trainee_count }}</strong></div>
</div>
<h3>Trainees: {{ a.trainee_count }}</h3>
<h3>Hardest personas</h3>
<div class="card" v-for="(p, i) in a.hardest_personas" :key="i" style="margin-bottom:8px">
<strong>{{ p.persona_name }}</strong>
<span class="badge lost">{{ p.losses }}L</span>
<span class="badge won">{{ p.wins }}W</span>
<span class="muted">· avg {{ p.avg_score }}</span>
<!-- Hardest personas -->
<h3>{{ i18n.t('hardestPersonas') }}</h3>
<div v-if="a.hardest_personas.length === 0" class="card empty-state">
<strong>No data</strong><span>No sessions in this date range.</span>
</div>
<div class="grid">
<div v-for="(p, i) in a.hardest_personas" :key="i" class="card lift tier-card">
<div class="row" style="justify-content:space-between">
<strong>{{ p.persona_name }}</strong>
<span class="muted">#{{ i + 1 }}</span>
</div>
<div class="row" style="margin-top:10px;gap:8px">
<span class="badge won">{{ p.wins }}W</span>
<span class="badge lost">{{ p.losses }}L</span>
<span class="badge not_tried">{{ p.plays }} plays</span>
</div>
<div class="muted" style="margin-top:8px">{{ i18n.t('score') }}: {{ p.avg_score }}</div>
</div>
</div>
</div>
</template>
@@ -26,11 +56,32 @@ import { api } from '../api'
import { i18n } from '../i18n'
const a = ref({ overall: { total_sessions: 0, wins: 0, losses: 0, close_rate: 0, avg_score: 0 }, trainee_count: 0, hardest_personas: [] })
onMounted(async () => { a.value = await api.analytics() })
const from = ref('')
const to = ref('')
const loading = ref(false)
async function load() {
loading.value = true
try {
a.value = await api.analytics({ from: from.value, to: to.value })
} finally {
loading.value = false
}
}
function apply() { load() }
function clear() { from.value = ''; to.value = ''; load() }
onMounted(load)
</script>
<style scoped>
.stat { text-align: center; min-width: 110px; }
.stat div { color: var(--muted); font-size: 12px; }
.stat strong { font-size: 20px; }
.filter-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.filter-bar input[type=date] { width: auto; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.tier-card { display: flex; flex-direction: column; }
.badge.won { background: #dcfce7; color: #166534; }
.badge.lost { background: #fee2e2; color: #991b1b; }
.badge.not_tried { background: #eef2ff; color: #4338ca; }
</style>

View File

@@ -1,29 +1,47 @@
<template>
<div>
<router-link to="/" class="btn-back"> {{ i18n.t('dashboard') }}</router-link>
<router-link to="/training" class="btn-back"> {{ i18n.t('training') }}</router-link>
<div class="row" style="align-items:center">
<h2 style="margin:0">{{ i18n.t('personas') }}</h2>
<span class="muted" style="margin-left:auto">Levels: choose one to practice (one-shot)</span>
<span class="muted" style="margin-left:auto">{{ i18n.t('selectPersona') }}</span>
</div>
<div class="row" v-if="auth.isAdmin" style="margin:12px 0;gap:10px">
<router-link :to="`/admin/groups/${gid}/edit`"><button class="primary">🛠 {{ i18n.t('managePersonas') }}</button></router-link>
<span class="muted">Admin: full persona data + edit available here.</span>
</div>
<div v-for="tier in ['A','B','C']" :key="tier" style="margin:20px 0">
<h4>{{ tierLabel(tier) }}</h4>
<div class="grid">
<div v-for="p in byTier(tier)" :key="p.id" class="card pcard lift">
<div class="row">
<div class="row" style="justify-content:space-between">
<strong>{{ p.name }}</strong>
<span class="badge" :class="p.my_outcome">{{ outcomeLabel(p.my_outcome) }}</span>
</div>
<!-- Difficulty -->
<div class="diff">
<span v-for="n in 5" :key="n" class="star" :class="{ on: n <= (p.difficulty || 1) }"></span>
<span class="muted"> {{ i18n.t('difficulty') }} {{ p.difficulty || 1 }}/5</span>
</div>
<div class="muted">
{{ p.profession }} · {{ p.age_group }} · {{ p.location }}<br />
<span class="badge" :class="p.channel">{{ p.channel }}</span>
<span class="muted"> · {{ p.initiation_mode === 'seller' ? i18n.t('sellerInitiated') : i18n.t('customerInitiated') }}</span>
</div>
<div class="muted" style="margin-top:6px">{{ p.product_context }}</div>
<router-link v-if="p.my_outcome === 'not_tried'" :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:auto">
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
</router-link>
<div v-else class="muted" style="margin-top:auto;font-size:12px"> Trained ({{ outcomeLabel(p.my_outcome) }})</div>
<template v-if="auth.isAdmin">
<router-link :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:auto">
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
</router-link>
</template>
<template v-else>
<router-link v-if="p.my_outcome === 'not_tried'" :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:auto">
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
</router-link>
<div v-else class="muted" style="margin-top:auto;font-size:12px"> {{ i18n.t('trained') }} ({{ outcomeLabel(p.my_outcome) }})</div>
</template>
</div>
</div>
</div>
@@ -34,6 +52,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api'
import { auth } from '../store/auth'
import { i18n } from '../i18n'
const route = useRoute()
@@ -55,5 +74,11 @@ onMounted(load)
<style scoped>
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.pcard { display: flex; flex-direction: column; min-height: 170px; }
.pcard { display: flex; flex-direction: column; min-height: 190px; }
.diff { margin: 8px 0; }
.star { color: #d8dbe3; }
.star.on { color: #f59e0b; }
.badge.won { background: #dcfce7; color: #166534; }
.badge.lost { background: #fee2e2; color: #991b1b; }
.badge.not_tried { background: #eef2ff; color: #4338ca; }
</style>

View File

@@ -2,33 +2,50 @@
<div>
<h2> {{ i18n.t('settings') }}</h2>
<!-- Profile -->
<div class="card">
<h3 style="margin-top:0">{{ i18n.t('account') }}</h3>
<label>{{ i18n.t('username') }}</label>
<h3 style="margin-top:0">👤 {{ i18n.t('profile') }}</h3>
<label>{{ i18n.t('username') }} <span class="muted">({{ i18n.t('readonly') }})</span></label>
<input :value="auth.user?.username || ''" disabled />
<label>{{ i18n.t('name') }}</label>
<input v-model="name" type="text" />
<label>{{ i18n.t('email') }}</label>
<input v-model="email" type="email" />
<label>{{ i18n.t('newPassword') }} <span class="muted">({{ i18n.t('optional') }})</span></label>
<input v-model="password" type="password" autocomplete="new-password" />
<div class="error" v-if="error" role="alert">{{ error }}</div>
<div class="ok" v-if="ok" role="status"> {{ ok }}</div>
<button class="primary" style="margin-top:16px" :disabled="busy" @click="save">
<span v-if="busy" class="spinner"></span>{{ i18n.t('save') }}
</button>
</div>
<div class="card" style="margin-top:16px">
<h3 style="margin-top:0">{{ i18n.t('language') }}</h3>
<div class="row">
<button :class="{ active: i18n.locale === 'th' }" @click="i18n.set('th')">ไทย</button>
<button :class="{ active: i18n.locale === 'en' }" @click="i18n.set('en')">English</button>
<div class="row" style="gap:10px;margin-top:16px">
<button class="primary" :disabled="busy" @click="saveProfile">
<span v-if="busy" class="spinner"></span>{{ i18n.t('saveProfile') }}
</button>
</div>
</div>
<!-- Password -->
<div class="card" style="margin-top:16px">
<h3 style="margin-top:0">🔒 {{ i18n.t('changePassword') }}</h3>
<label>{{ i18n.t('newPassword') }}</label>
<input v-model="password" type="password" autocomplete="new-password" />
<label>{{ i18n.t('confirmPassword') }}</label>
<input v-model="confirm" type="password" autocomplete="new-password" />
<div class="row" style="gap:10px;margin-top:16px">
<button class="primary" :disabled="pwBusy || !password || password !== confirm" @click="savePassword">
<span v-if="pwBusy" class="spinner"></span>{{ i18n.t('save') }}
</button>
</div>
</div>
<!-- Language -->
<div class="card" style="margin-top:16px">
<h3 style="margin-top:0">🌐 {{ i18n.t('language') }}</h3>
<div class="row">
<button :class="{ active: i18n.locale === 'th' }" @click="setLang('th')">ไทย</button>
<button :class="{ active: i18n.locale === 'en' }" @click="setLang('en')">English</button>
</div>
</div>
<div class="error" v-if="error" role="alert">{{ error }}</div>
<div class="ok" v-if="ok" role="status"> {{ ok }}</div>
</div>
</template>
@@ -38,22 +55,25 @@ import { auth } from '../store/auth'
import { api } from '../api'
import { i18n } from '../i18n'
const name = ref(auth.user?.name || '')
const email = ref(auth.user?.email || '')
const password = ref('')
const confirm = ref('')
const error = ref('')
const ok = ref('')
const busy = ref(false)
const username = auth.user?.username || auth.user?.id || ''
const pwBusy = ref(false)
async function save() {
async function saveProfile() {
error.value = ''
ok.value = ''
busy.value = true
try {
if (email.value || password.value) {
const body = { username, email: email.value }
if (password.value) body.password = password.value
const data = await api.setup(body)
const body = {}
if (name.value && name.value !== auth.user?.name) body.name = name.value
if (email.value && email.value !== auth.user?.email) body.email = email.value
if (Object.keys(body).length) {
const data = await api.updateProfile(body)
auth.user = data.user
ok.value = i18n.t('saved')
}
@@ -63,9 +83,32 @@ async function save() {
busy.value = false
}
}
async function savePassword() {
error.value = ''
ok.value = ''
if (password.value.length < 4) { error.value = i18n.t('passwordTooShort'); return }
if (password.value !== confirm.value) { error.value = i18n.t('passwordMismatch'); return }
pwBusy.value = true
try {
const username = auth.user?.username || auth.user?.id || ''
const data = await api.setup({ username, email: email.value || auth.user?.email, password: password.value })
auth.user = data.user
password.value = ''
confirm.value = ''
ok.value = i18n.t('saved')
} catch (e) {
error.value = e.message
} finally {
pwBusy.value = false
}
}
function setLang(l) { i18n.set(l) }
</script>
<style scoped>
.active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.ok { color: var(--green); font-size: 13px; margin-top: 8px; }
.ok { color: var(--green); font-size: 13px; margin-top: 10px; }
.error { margin-top: 10px; }
</style>

View File

@@ -1,30 +1,43 @@
<template>
<div>
<h2>🎯 {{ i18n.t('training') }}</h2>
<p class="muted">Pick a product group to practice closing a sale.</p>
<div class="row" style="align-items:center;margin-bottom:16px">
<h2 style="margin:0">🎯 {{ i18n.t('training') }}</h2>
<div style="margin-left:auto">
<router-link v-if="auth.isAdmin" to="/admin/new-group">
<button class="primary"> {{ i18n.t('addProduct') }}</button>
</router-link>
</div>
</div>
<p class="muted">{{ i18n.t('trainingSubtitle') }}</p>
<div v-if="loading" class="card" style="min-height:80px">
<div class="skeleton" style="height:50px"></div>
</div>
<div v-else-if="groups.length === 0" class="card empty-state">
<strong>No training groups available</strong>
<span>Ask an admin to create a persona group first.</span>
<strong>{{ i18n.t('noTraining') }}</strong>
<span v-if="auth.isAdmin">Click "{{ i18n.t('addProduct') }}" to create a persona group.</span>
<span v-else>Ask an admin to create a persona group first.</span>
</div>
<div class="grid">
<router-link
v-for="g in groups"
:key="g.id"
:to="`/groups/${g.id}/personas`"
:to="auth.isAdmin ? `/admin/groups/${g.id}/edit` : `/groups/${g.id}/personas`"
style="text-decoration:none"
>
<div class="card lift train-card">
<div class="row" style="justify-content:space-between">
<strong>{{ g.title }}</strong>
<span class="badge ready">ready</span>
<span class="badge" :class="g.status === 'ready' ? 'ready' : 'draft'">{{ g.status }}</span>
</div>
<div class="muted" style="margin:6px 0 12px">{{ productName(g) }}</div>
<div class="badge" :class="g.channel">{{ g.channel || 'line' }}</div>
<button class="primary" style="width:100%;margin-top:12px">{{ i18n.t('selectPersona') }}</button>
<div class="row" style="gap:8px">
<span class="badge" :class="g.channel || 'line'">{{ g.channel || 'line' }}</span>
<span class="muted">{{ personaCount(g) }} {{ i18n.t('personas').toLowerCase() }}</span>
</div>
<button class="primary" style="width:100%;margin-top:12px">
{{ auth.isAdmin ? (g.status === 'ready' ? i18n.t('managePersonas') : i18n.t('analyze')) : i18n.t('selectPersona') }}
</button>
</div>
</router-link>
</div>
@@ -34,6 +47,7 @@
<script setup>
import { onMounted, ref } from 'vue'
import { api } from '../api'
import { auth } from '../store/auth'
import { i18n } from '../i18n'
const groups = ref([])
@@ -42,11 +56,15 @@ const loading = ref(true)
function productName(g) {
return (g.sales_kit && g.sales_kit.productName) || (g.input && g.input.product) || ''
}
function personaCount(g) {
return (g.personas || g.persona_count || 0)
}
onMounted(async () => {
try {
const all = (await api.listGroups()).groups || []
groups.value = all.filter((g) => g.status === 'ready')
// Admins see all groups (incl. drafts to manage/analyze); trainees only ready ones.
groups.value = auth.isAdmin ? all : all.filter((g) => g.status === 'ready')
} finally {
loading.value = false
}
@@ -57,4 +75,8 @@ onMounted(async () => {
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.train-card { display: flex; flex-direction: column; height: 100%; }
.badge.ready { background: #dcfce7; color: #166534; }
.badge.draft { background: #fef9c3; color: #854d0e; }
button.soft { background: #f1f3f9; border-color: transparent; }
.badge.facebook { background: #e0f2fe; color: #0369a1; }
.badge.line { background: #dcfce7; color: #15803d; }
</style>