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:
@@ -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}` : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user