feat(i18n + export + guide): locale-aware scenarios/debrief; CSV export; how-to-use page

Backend:
- SCENARIOS localized (th/en): scenario preamble + persona tone adaptation follow the
  trainee's locale; start accepts {locale} and stores it on the session; send/auto-finish
  use it so debrief text (why/coaching/turning points) is in the active language.
- /api/analytics/export returns per-trainee finished-session CSV (admin).

Frontend:
- Scenario picker labels localize by i18n.locale.
- New /guide page (non-IT how-to), linked from Training.
- Analytics: date-filter bar uses line icon + 'Download CSV' button (fetch w/ auth).
Rebuilt dist.
This commit is contained in:
Macky
2026-08-08 15:06:48 +07:00
parent aa2eb8dd37
commit 15c60ae400
40 changed files with 302 additions and 107 deletions

View File

@@ -47,7 +47,7 @@ export const api = {
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
updatePersona: (gid, pid, b) => request('PUT', `/api/groups/${gid}/personas/${pid}`, b),
chatStart: (gid, pid, scenario) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`, scenario || {}),
chatStart: (gid, pid, scenario, locale) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`, { scenario, locale }),
chatResume: (gid, pid) => request('GET', `/api/chat/${gid}/personas/${pid}/chat/resume`),
chatSend: (gid, pid, text) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/send`, { text }),
chatFinish: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/finish`),

View File

@@ -21,6 +21,7 @@ const messages = {
training: 'Training',
myDashboard: 'My dashboard',
adminOverview: 'Admin overview',
guideTitle: 'How to use / คู่มือใช้งาน',
settings: 'Settings',
account: 'Account',
profile: 'Profile',

View File

@@ -14,6 +14,8 @@ const routes = [
{ path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') },
// Settings
{ path: '/settings', component: () => import('../views/Settings.vue') },
// Guide (how to use, non-IT friendly)
{ path: '/guide', component: () => import('../views/Guide.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 } },

View File

@@ -13,12 +13,13 @@
<!-- Date filter -->
<div class="card filter-bar">
<span class="muted">🗓 {{ i18n.t('dateRange') }}</span>
<span class="muted"><Download :size="15" :stroke-width="1.8" style="vertical-align:-2px" /> {{ 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>
<button class="soft" @click="download"><Download :size="16" :stroke-width="1.8" /> CSV</button>
</div>
<!-- Headline metrics -->
@@ -66,8 +67,9 @@
<script setup>
import { onMounted, ref } from 'vue'
import { BarChart3, Users as UsersIcon, Plus } from 'lucide-vue-next'
import { BarChart3, Users as UsersIcon, Plus, Download } from 'lucide-vue-next'
import { api } from '../api'
import { auth } from '../store/auth'
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: [] })
@@ -85,6 +87,21 @@ async function load() {
}
function apply() { load() }
function clear() { from.value = ''; to.value = ''; load() }
async function download() {
try {
const resp = await fetch('/api/analytics/export', { headers: { Authorization: `Bearer ${auth.token}` } })
if (!resp.ok) throw new Error('export failed')
const blob = await resp.blob()
const url = URL.createObjectURL(blob)
const aEl = document.createElement('a')
aEl.href = url
aEl.download = 'sales-trainer-results.csv'
aEl.click()
URL.revokeObjectURL(url)
} catch (e) {
alert(e.message)
}
}
onMounted(load)
</script>

View File

@@ -86,7 +86,7 @@
</template>
<script setup>
import { onMounted, nextTick, ref } from 'vue'
import { computed, onMounted, nextTick, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Target, ArrowLeft, Search } from 'lucide-vue-next'
import { api } from '../api'
@@ -105,11 +105,14 @@ const debrief = ref(null)
const sessionId = ref(null)
const picked = ref('social')
const scenarios = [
{ id: 'social', emoji: '💬', init: 'customer', label: 'Social Media (แชท)', desc: 'ลูกค้าทักมาหาคุณก่อน — โทนสั้น ทักๆ ตามสไตล์แชท' },
{ id: 'f2f_call', emoji: '📞', init: 'seller', label: 'พบหน้า / โทรศัพท์', desc: 'คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (ผู้ฝึกทักก่อน)' },
{ id: 'recontact', emoji: '', init: 'customer', label: 'ลูกค้ากลับมาติดต่อ (1-3 เดือน)', desc: 'เคยได้รับข้อมูลไปแล้ว ตอนนี้กลับมาติดต่อ พร้อมตัดสินใจมากขึ้น' },
]
const scenarios = computed(() => {
const en = i18n.locale === 'en'
return [
{ id: 'social', emoji: '💬', init: 'customer', label: en ? 'Social Media (chat)' : 'Social Media (แชท)', desc: en ? 'The customer messages you first — short, chat-style replies' : 'ลูกค้าทักมาหาคุณก่อน — โทนสั้น ทักๆ ตามสไตล์แชท' },
{ id: 'f2f_call', emoji: '📞', init: 'seller', label: en ? 'Face-to-face / Phone' : 'พบหน้า / โทรศัพท์', desc: en ? 'You must proactively open the conversation (seller starts)' : 'คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (ผู้ฝึกทักก่อน)' },
{ id: 'recontact', emoji: '⏳', init: 'customer', label: en ? 'Re-contact (1-3 months)' : 'ลูกค้ากลับมาติดต่อ (1-3 เดือน)', desc: en ? 'Customer contacted you before, now returns more ready to decide' : 'เคยได้รับข้อมูลไปแล้ว ตอนนี้กลับมาติดต่อ พร้อมตัดสินใจมากขึ้น' },
]
})
function scrollDown() {
nextTick(() => {
@@ -120,7 +123,7 @@ const thread = ref(null)
async function begin() {
phase.value = 'chat'
const res = await api.chatStart(gid, pid, { scenario: picked.value })
const res = await api.chatStart(gid, pid, picked.value, i18n.locale)
sessionId.value = res.session.id
messages.value = res.session.messages || []
if (messages.value.length) scrollDown()

View File

@@ -0,0 +1,51 @@
<template>
<div>
<h2><BookOpen :size="22" :stroke-width="1.8" style="vertical-align:-4px" /> {{ i18n.t('guideTitle') }}</h2>
<p class="muted">อสนๆ สำหรบพนกงานใหม เขาใจระบบไดใน 5 นาท</p>
<div class="card">
<h3 style="margin-top:0"><LayoutDashboard :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> 1. หน 3 แท (menu านบน)</h3>
<ul style="line-height:1.9">
<li><strong>ภาพรวมผแล</strong> เฉพาะ admin สถผลการฝกของท + กรองตามชวงเวลา</li>
<li><strong>ภาพรวมผลการฝ</strong> ของตวคณเอง: ชนะ/แพ/งไมได</li>
<li><strong>การฝ</strong> เลอกสนค เลอกลกค (คคลตนแบบ) เรมแชท</li>
<li><strong>การตงค ()</strong> แก/เมล, เปลยนรหสผาน, เปลยนภาษา</li>
</ul>
</div>
<div class="card">
<h3 style="margin-top:0"><Target :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> 2. (start a session)</h3>
<ol style="line-height:1.9;padding-left:20px">
<li>เขาแท <strong>การฝ</strong> เลอกสนคาทอยากฝ</li>
<li>เลอกลกค (คคลตนแบบ) ระด A ายส / C ยากส (ดาวความยาก)</li>
<li>เลอก <strong>สถานการณ</strong>: โซเชยล (กคาทกกอน) / พบหน-โทร (ณทกกอน) / กลบมาตดต</li>
<li>แชทกบลกค กปดการขาย กคาจะตดสนใจเองวาซอหรอไม</li>
<li>หลงจบ ผล + อเสนอแนะ + อมลลกคาทกซอนไว</li>
</ol>
</div>
<div class="card">
<h3 style="margin-top:0"><Sparkles :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> 3. เคลดล</h3>
<ul style="line-height:1.9">
<li>กคาแตละคน หร "บุคคลต้นแบบ" กได <strong>คนละคร</strong> (one-shot) กจนจบแลวลองคนอ</li>
<li>าออกกลางค กลบมาได ยตอจากเด (งไมบวาจบ)</li>
<li>ดการขาย = แกญหาจรงของลกค + บมอขอโตแย + ดราคา</li>
</ul>
</div>
<div class="card">
<h3 style="margin-top:0"><Wrench :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> 4. สำหร Admin</h3>
<ul style="line-height:1.9">
<li> <strong>เพมสนค</strong> (หน การฝ) สรางกลมบคคลตนแบบใหม</li>
<li>หน <strong>ดการบคคลตนแบบ</strong> แกไขรายละเอยดลกคาแตละคน แลวเปดใชงาน</li>
<li>หน <strong>ใชงาน</strong> สรางบญชให (username + รหสผานเรมต)</li>
<li> <strong>CSV</strong> (หน ภาพรวม) ดาวนโหลดผลการฝกทงหมด</li>
</ul>
</div>
</div>
</template>
<script setup>
import { BookOpen, LayoutDashboard, Target, Sparkles, Wrench } from 'lucide-vue-next'
import { i18n } from '../i18n'
</script>

View File

@@ -2,7 +2,8 @@
<div>
<div class="row" style="align-items:center;margin-bottom:16px">
<h2 style="margin:0"><Target :size="22" :stroke-width="1.8" style="vertical-align:-4px" /> {{ i18n.t('training') }}</h2>
<div style="margin-left:auto">
<div class="row" style="margin-left:auto;gap:10px">
<router-link to="/guide"><button class="soft"><BookOpen :size="18" :stroke-width="1.8" /> </button></router-link>
<router-link v-if="auth.isAdmin" to="/admin/new-group">
<button class="primary"><Plus :size="18" :stroke-width="2" /> {{ i18n.t('addProduct') }}</button>
</router-link>
@@ -46,7 +47,7 @@
<script setup>
import { onMounted, ref } from 'vue'
import { Target, Plus, Settings as SettingsIcon } from 'lucide-vue-next'
import { Target, Plus, BookOpen } from 'lucide-vue-next'
import { api } from '../api'
import { auth } from '../store/auth'
import { i18n } from '../i18n'