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

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