Files
service-finder/frontend_admin/pages/gamification/index.vue
2026-06-30 08:56:55 +00:00

223 lines
12 KiB
Vue

<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.dashboard.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.dashboard.subtitle') }}</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="text-slate-400 flex items-center gap-3">
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{{ t('gamification.dashboard.loading') }}</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
<p class="text-rose-400">{{ t('gamification.dashboard.load_error') }}</p>
</div>
<!-- Dashboard Content -->
<template v-else>
<!-- Stat Tiles -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-amber-500/50 transition group">
<div class="flex items-center justify-between mb-4">
<div class="p-2 rounded-lg bg-amber-500/10 text-amber-400 group-hover:bg-amber-500/20 transition">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.dashboard.total_users') }}</h3>
<p class="text-3xl font-bold text-white mt-2">{{ stats.total_users }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-emerald-500/50 transition group">
<div class="flex items-center justify-between mb-4">
<div class="p-2 rounded-lg bg-emerald-500/10 text-emerald-400 group-hover:bg-emerald-500/20 transition">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
</div>
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.dashboard.active_season') }}</h3>
<p class="text-3xl font-bold text-white mt-2">{{ stats.active_season || t('gamification.dashboard.none') }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-violet-500/50 transition group">
<div class="flex items-center justify-between mb-4">
<div class="p-2 rounded-lg bg-violet-500/10 text-violet-400 group-hover:bg-violet-500/20 transition">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.dashboard.pending_contributions') }}</h3>
<p class="text-3xl font-bold text-white mt-2">{{ stats.pending_contributions }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-cyan-500/50 transition group">
<div class="flex items-center justify-between mb-4">
<div class="p-2 rounded-lg bg-cyan-500/10 text-cyan-400 group-hover:bg-cyan-500/20 transition">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
</div>
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.dashboard.today_xp') }}</h3>
<p class="text-3xl font-bold text-white mt-2">{{ stats.today_xp }}</p>
</div>
</div>
<!-- Quick Links -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('gamification.dashboard.quick_actions') }}</h2>
<div class="space-y-3">
<NuxtLink to="/gamification/point-rules" class="flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition">
<svg class="w-5 h-5 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('gamification.dashboard.manage_point_rules') }}
</NuxtLink>
<NuxtLink to="/gamification/badges" class="flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition">
<svg class="w-5 h-5 text-violet-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
{{ t('gamification.dashboard.manage_badges') }}
</NuxtLink>
<NuxtLink to="/gamification/seasons" class="flex items-center gap-3 px-4 py-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg text-sm text-slate-300 hover:text-white transition">
<svg class="w-5 h-5 text-emerald-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{{ t('gamification.dashboard.manage_seasons') }}
</NuxtLink>
</div>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('gamification.dashboard.recent_ledger') }}</h2>
<div v-if="recentLedger.length === 0" class="text-sm text-slate-500 text-center py-8">
{{ t('gamification.dashboard.no_ledger_entries') }}
</div>
<div v-else class="space-y-3">
<div v-for="entry in recentLedger" :key="entry.id" class="flex items-center justify-between py-2 border-b border-slate-700/50 last:border-0">
<div>
<p class="text-sm text-slate-300">{{ entry.reason }}</p>
<p class="text-xs text-slate-500">{{ entry.created_at }}</p>
</div>
<span class="text-sm font-semibold" :class="entry.points >= 0 ? 'text-emerald-400' : 'text-rose-400'">
{{ entry.points >= 0 ? '+' : '' }}{{ entry.points }}
</span>
</div>
</div>
</div>
</div>
<!-- Top 5 Leaderboard -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">{{ t('gamification.dashboard.top_5_leaderboard') }}</h2>
<NuxtLink to="/gamification/leaderboard" class="text-sm text-indigo-400 hover:text-indigo-300 transition">
{{ t('gamification.dashboard.full_leaderboard') }}
</NuxtLink>
</div>
<div v-if="topLeaderboard.length === 0" class="text-sm text-slate-500 text-center py-8">
{{ t('gamification.dashboard.no_leaderboard_data') }}
</div>
<div v-else class="space-y-2">
<div v-for="(entry, idx) in topLeaderboard" :key="idx" class="flex items-center gap-4 px-4 py-3 rounded-lg" :class="idx < 3 ? 'bg-slate-700/50' : ''">
<span class="w-8 text-center text-lg font-bold" :class="idx === 0 ? 'text-amber-400' : idx === 1 ? 'text-slate-300' : idx === 2 ? 'text-amber-700' : 'text-slate-500'">
#{{ idx + 1 }}
</span>
<div class="flex-1">
<p class="text-sm font-medium text-white">{{ entry.user_name }}</p>
<p class="text-xs text-slate-500">{{ t('gamification.dashboard.level') }} {{ entry.level }} · {{ entry.xp }} {{ t('gamification.dashboard.xp_abbr') }}</p>
</div>
<span class="text-sm font-semibold text-emerald-400">{{ entry.total_points }} {{ t('gamification.dashboard.points') }}</span>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface DashboardStats {
total_users: number
active_season: string | null
pending_contributions: number
today_xp: number
}
interface LedgerEntry {
id: number
user_name: string
points: number
reason: string
created_at: string
}
interface LeaderboardEntry {
user_name: string
level: number
xp: number
total_points: number
}
const loading = ref(true)
const error = ref(false)
const stats = ref<DashboardStats>({
total_users: 0,
active_season: null,
pending_contributions: 0,
today_xp: 0,
})
const recentLedger = ref<LedgerEntry[]>([])
const topLeaderboard = ref<LeaderboardEntry[]>([])
onMounted(async () => {
try {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const [statsData, ledgerData, leaderboardData] = await Promise.all([
$fetch('/api/v1/admin/gamification/dashboard-summary', { headers }).catch(() => ({ total: 0 })),
$fetch('/api/v1/admin/gamification/points-ledger?limit=5', { headers }).catch(() => []),
$fetch('/api/v1/gamification/leaderboard?limit=5', { headers }).catch(() => []),
])
stats.value = {
total_users: statsData.total ?? 0,
active_season: statsData.active_season ?? null,
pending_contributions: statsData.pending_contributions ?? 0,
today_xp: statsData.today_xp ?? 0,
}
recentLedger.value = Array.isArray(ledgerData) ? ledgerData.slice(0, 5) : []
topLeaderboard.value = Array.isArray(leaderboardData) ? leaderboardData.slice(0, 5).map((entry: any) => ({
user_name: entry.username || entry.user_name || `User #${entry.user_id}`,
level: entry.current_level || entry.level || 0,
xp: entry.total_xp || entry.xp || 0,
total_points: entry.total_points || entry.total_xp || 0,
})) : []
} catch (e) {
console.error('Failed to load gamification dashboard:', e)
error.value = true
} finally {
loading.value = false
}
})
</script>