feat(ui): 3-tab layout (Admin overview / My dashboard / Training) + Settings page
- App.vue: top tab bar (Admin overview [admin], My dashboard, Training) + Settings icon - router: / = admin overview, /my/board, /training, /settings; non-admin '/' -> /my/board - New views: Training.vue (pick ready product group -> personas), MyBoard.vue (win/lose + sessions summary), Settings.vue (change email/password, language) - Backend: allow /api/me/board + /api/chat/sessions for any authenticated user (so the My dashboard tab works for admins too), not just 'user' role - i18n EN/TH keys; rebuilt frontend/dist Verified: build ok, /api/me/board + /api/chat/sessions + /api/groups all 200 for admin.
This commit is contained in:
@@ -1,16 +1,26 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<nav v-if="auth.user" class="topnav">
|
||||
<router-link to="/" class="brand">{{ i18n.t('app') }}</router-link>
|
||||
<router-link to="/" class="brand">🎯 {{ i18n.t('app') }}</router-link>
|
||||
<div class="tabs">
|
||||
<router-link v-if="auth.isAdmin" to="/" class="tab" :class="{ active: $route.path === '/' }">
|
||||
{{ i18n.t('adminOverview') }}
|
||||
</router-link>
|
||||
<router-link to="/my/board" class="tab" :class="{ active: $route.path === '/my/board' }">
|
||||
{{ i18n.t('myDashboard') }}
|
||||
</router-link>
|
||||
<router-link to="/training" class="tab" :class="{ active: $route.path === '/training' }">
|
||||
{{ i18n.t('training') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<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>
|
||||
<router-link to="/settings" class="icon-btn" title="Settings">⚙️</router-link>
|
||||
<button @click="logout" class="logout-btn">⏻ {{ i18n.t('logout') }}</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="main">
|
||||
<!-- :key forces each view to re-evaluate i18n.t() when the locale changes,
|
||||
so switching language updates the whole page reliably. -->
|
||||
<!-- :key forces each view to re-evaluate i18n.t() when locale changes -->
|
||||
<router-view :key="i18n.locale" />
|
||||
</main>
|
||||
</div>
|
||||
@@ -41,15 +51,36 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
gap: 16px;
|
||||
padding: 10px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand { font-weight: 800; text-decoration: none; color: var(--ink); }
|
||||
.nav-right { display: flex; align-items: center; gap: 12px; }
|
||||
.lang { padding: 6px 10px; }
|
||||
.brand { font-weight: 800; text-decoration: none; color: var(--ink); white-space: nowrap; }
|
||||
.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.tab {
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover { background: #f1f3f9; color: var(--ink); }
|
||||
.tab.active { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; }
|
||||
.nav-right { display: flex; align-items: center; gap: 8px; }
|
||||
.lang, .logout-btn { padding: 8px 12px; }
|
||||
.icon-btn { text-decoration: none; font-size: 18px; padding: 6px 10px; border-radius: 8px; }
|
||||
.icon-btn:hover { background: #f1f3f9; }
|
||||
.main { max-width: 1080px; margin: 0 auto; padding: 24px; }
|
||||
@media (max-width: 640px) {
|
||||
.topnav { padding: 10px 12px; }
|
||||
.tab { padding: 8px 12px; font-size: 13px; }
|
||||
.logout-btn span { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,14 @@ const messages = {
|
||||
setupSubtitle: 'First login for ',
|
||||
loginError: 'Invalid credentials',
|
||||
dashboard: 'Dashboard',
|
||||
training: 'Training',
|
||||
myDashboard: 'My dashboard',
|
||||
adminOverview: 'Admin overview',
|
||||
settings: 'Settings',
|
||||
account: 'Account',
|
||||
optional: 'optional',
|
||||
saved: 'Saved',
|
||||
total: 'Total',
|
||||
groups: 'Persona Groups',
|
||||
myTraining: 'My Training',
|
||||
adminTools: 'Admin Tools',
|
||||
@@ -77,6 +85,14 @@ const messages = {
|
||||
setupSubtitle: 'เข้าสู่ระบบครั้งแรกสำหรับ ',
|
||||
loginError: 'ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง',
|
||||
dashboard: 'หน้าหลัก',
|
||||
training: 'การฝึก',
|
||||
myDashboard: 'แดชบอร์ดของฉัน',
|
||||
adminOverview: 'ภาพรวม Admin',
|
||||
settings: 'การตั้งค่า',
|
||||
account: 'บัญชี',
|
||||
optional: 'ไม่บังคับ',
|
||||
saved: 'บันทึกแล้ว',
|
||||
total: 'ทั้งหมด',
|
||||
groups: 'กลุ่มลูกค้า (Persona)',
|
||||
myTraining: 'การฝึกของฉัน',
|
||||
adminTools: 'เครื่องมือ Admin',
|
||||
|
||||
@@ -4,12 +4,17 @@ 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') },
|
||||
// Tab 1: Admin overview (admin only)
|
||||
{ path: '/', component: () => import('../views/Dashboard.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
|
||||
{ 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') },
|
||||
{ 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') },
|
||||
// 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 } },
|
||||
@@ -33,8 +38,12 @@ router.beforeEach(async (to) => {
|
||||
if (auth.mustSetup && to.path !== '/setup') {
|
||||
return { path: '/setup' }
|
||||
}
|
||||
// Non-admin landing page: send to My dashboard.
|
||||
if (to.path === '/' && !auth.isAdmin) {
|
||||
return { path: '/my/board' }
|
||||
}
|
||||
if (to.meta.admin && !auth.isAdmin) {
|
||||
return { path: '/' }
|
||||
return { path: '/my/board' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
61
frontend/src/views/MyBoard.vue
Normal file
61
frontend/src/views/MyBoard.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>📊 {{ i18n.t('myDashboard') }}</h2>
|
||||
|
||||
<!-- Win/Lose summary -->
|
||||
<div class="row stat-row" v-if="!loading">
|
||||
<div class="card stat"><span>{{ i18n.t('total') }}</span><strong>{{ total }}</strong></div>
|
||||
<div class="card stat won"><span>{{ i18n.t('won') }}</span><strong>{{ won }}</strong></div>
|
||||
<div class="card stat lost"><span>{{ i18n.t('lost') }}</span><strong>{{ lost }}</strong></div>
|
||||
<div class="card stat"><span>{{ i18n.t('notTried') }}</span><strong>{{ notTried }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card"><div class="skeleton" style="height:50px"></div></div>
|
||||
<div v-else-if="items.length === 0" class="card empty-state">
|
||||
<strong>No practice yet</strong>
|
||||
<span>Go to Training and try closing a sale with a persona.</span>
|
||||
</div>
|
||||
|
||||
<!-- Recent sessions -->
|
||||
<div v-show="sessions.length" class="card" style="margin-top:20px">
|
||||
<h3 style="margin-top:0">{{ i18n.t('mySessions') }}</h3>
|
||||
<div v-for="s in sessions" :key="s.id" style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border)">
|
||||
<span>{{ s.persona_name }}</span>
|
||||
<span class="badge" :class="s.outcome">{{ s.outcome === 'won' ? i18n.t('won') : i18n.t('lost') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const items = ref([])
|
||||
const sessions = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const total = computed(() => items.value.length)
|
||||
const won = computed(() => items.value.filter((i) => i.my_outcome === 'won').length)
|
||||
const lost = computed(() => items.value.filter((i) => i.my_outcome === 'lost').length)
|
||||
const notTried = computed(() => items.value.filter((i) => i.my_outcome === 'not_tried').length)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
items.value = (await api.myBoard()).board || []
|
||||
sessions.value = (await api.mySessions()).sessions || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-row { gap: 16px; }
|
||||
.stat { text-align: center; min-width: 100px; }
|
||||
.stat span { display: block; font-size: 12px; color: var(--muted); }
|
||||
.stat strong { font-size: 26px; }
|
||||
.stat.won strong { color: var(--green); }
|
||||
.stat.lost strong { color: var(--red); }
|
||||
</style>
|
||||
71
frontend/src/views/Settings.vue
Normal file
71
frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>⚙️ {{ i18n.t('settings') }}</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">{{ i18n.t('account') }}</h3>
|
||||
|
||||
<label>{{ i18n.t('username') }}</label>
|
||||
<input :value="auth.user?.username || ''" disabled />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { auth } from '../store/auth'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const email = ref(auth.user?.email || '')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const ok = ref('')
|
||||
const busy = ref(false)
|
||||
const username = auth.user?.username || auth.user?.id || ''
|
||||
|
||||
async function save() {
|
||||
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)
|
||||
auth.user = data.user
|
||||
ok.value = i18n.t('saved')
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
|
||||
.ok { color: var(--green); font-size: 13px; margin-top: 8px; }
|
||||
</style>
|
||||
60
frontend/src/views/Training.vue
Normal file
60
frontend/src/views/Training.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>🎯 {{ i18n.t('training') }}</h2>
|
||||
<p class="muted">Pick a product group to practice closing a sale.</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>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<router-link
|
||||
v-for="g in groups"
|
||||
:key="g.id"
|
||||
:to="`/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>
|
||||
</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>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
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 {
|
||||
const all = (await api.listGroups()).groups || []
|
||||
groups.value = all.filter((g) => g.status === 'ready')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user