gemification_admin bekötve

This commit is contained in:
Roo
2026-06-30 08:56:55 +00:00
parent df4a0f07ff
commit cbfb955580
313 changed files with 18490 additions and 2895 deletions

View File

@@ -0,0 +1,400 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.leaderboard.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.leaderboard.subtitle') }}</p>
</div>
<!-- Filters -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Search -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.leaderboard.search') }}</label>
<div class="relative">
<input
v-model="filters.search"
type="text"
:placeholder="t('gamification.leaderboard.search_placeholder')"
class="w-full bg-slate-700 border border-slate-600 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
@keyup.enter="applyFilters"
/>
<svg class="absolute left-2.5 top-2.5 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
<!-- Level Filter -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.leaderboard.level') }}</label>
<select v-model="filters.level" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent">
<option value="">{{ t('gamification.leaderboard.all_levels') }}</option>
<option v-for="lvl in 20" :key="lvl" :value="lvl">{{ lvl }}. {{ t('gamification.leaderboard.level_suffix') }}</option>
</select>
</div>
<!-- Min XP -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.leaderboard.min_xp') }}</label>
<input v-model.number="filters.min_xp" type="number" min="0" placeholder="0" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent" />
</div>
<!-- Sort By -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.leaderboard.sort_by') }}</label>
<select v-model="sortBy" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent">
<option value="total_xp">{{ t('gamification.leaderboard.sort_xp') }}</option>
<option value="total_points">{{ t('gamification.leaderboard.sort_points') }}</option>
<option value="social_points">{{ t('gamification.leaderboard.sort_social') }}</option>
<option value="current_level">{{ t('gamification.leaderboard.sort_level') }}</option>
</select>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-3 mt-4 pt-4 border-t border-slate-700/50">
<button @click="applyFilters" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition">
{{ t('gamification.leaderboard.filter') }}
</button>
<button @click="resetFilters" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm font-medium rounded-lg transition">
{{ t('gamification.leaderboard.reset') }}
</button>
<button @click="toggleSortOrder" class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm font-medium rounded-lg transition flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="sortOrder === 'desc'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9m-9 4h9m5-4v12m0 0l-4-4m4 4l4-4" />
</svg>
{{ sortOrder === 'desc' ? t('gamification.leaderboard.desc') : t('gamification.leaderboard.asc') }}
</button>
<span class="text-xs text-slate-500 ml-auto">{{ totalCount }} {{ t('gamification.leaderboard.users_count') }}</span>
</div>
</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.leaderboard.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.leaderboard.load_error') }}</p>
<button @click="fetchLeaderboard" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.leaderboard.retry') }}</button>
</div>
<!-- Empty State -->
<div v-else-if="entries.length === 0" class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<p class="text-slate-400 text-center py-12">{{ t('gamification.leaderboard.empty') }}</p>
</div>
<!-- Leaderboard Table -->
<div v-else class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider w-16">#</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_user') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_email') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_xp') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_level') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_points') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_social') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_penalty') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_restriction') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_discoveries') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_services') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_updated') }}</th>
<th class="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.leaderboard.col_actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr
v-for="entry in entries"
:key="entry.user_id"
class="hover:bg-slate-700/30 transition"
:class="getRowClass(entry.rank)"
>
<!-- Rank -->
<td class="px-4 py-3">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold" :class="getRankBadgeClass(entry.rank)">
{{ entry.rank }}
</span>
</td>
<!-- User Name -->
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs font-bold text-white flex-shrink-0">
{{ getInitials(entry.user_name) }}
</div>
<span class="text-sm font-medium text-white">{{ entry.user_name }}</span>
</div>
</td>
<!-- Email -->
<td class="px-4 py-3 text-sm text-slate-400 font-mono">{{ entry.user_email || '—' }}</td>
<!-- XP -->
<td class="px-4 py-3">
<div class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path 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>
<span class="text-sm font-semibold text-white">{{ formatNumber(entry.total_xp) }}</span>
</div>
</td>
<!-- Level -->
<td class="px-4 py-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
{{ entry.current_level }}
</span>
</td>
<!-- Points -->
<td class="px-4 py-3 text-sm text-slate-300">{{ formatNumber(entry.total_points) }}</td>
<!-- Social Points -->
<td class="px-4 py-3 text-sm text-slate-300">{{ formatNumber(entry.social_points) }}</td>
<!-- Penalty -->
<td class="px-4 py-3">
<span v-if="entry.penalty_points > 0" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-rose-500/10 text-rose-400 border border-rose-500/20">
-{{ entry.penalty_points }}
</span>
<span v-else class="text-sm text-slate-500"></span>
</td>
<!-- Restriction -->
<td class="px-4 py-3">
<span v-if="entry.restriction_level > 0" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
{{ restrictionLabel(entry.restriction_level) }}
</span>
<span v-else class="text-sm text-slate-500"></span>
</td>
<!-- Discoveries -->
<td class="px-4 py-3 text-sm text-slate-300">{{ entry.places_discovered }}</td>
<!-- Services -->
<td class="px-4 py-3 text-sm text-slate-300">{{ entry.services_submitted }}</td>
<!-- Updated At -->
<td class="px-4 py-3 text-sm text-slate-500">{{ entry.updated_at ? formatDateTime(entry.updated_at) : '—' }}</td>
<!-- Actions -->
<td class="px-4 py-3 text-right">
<NuxtLink :to="`/gamification/users/${entry.user_id}`" class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600/10 hover:bg-indigo-600/20 text-indigo-400 text-xs font-medium rounded-lg transition">
{{ t('gamification.leaderboard.details') }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</NuxtLink>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between px-4 py-3 border-t border-slate-700">
<div class="flex items-center gap-2">
<button @click="firstPage" :disabled="offset === 0" class="px-2.5 py-1.5 text-sm rounded-lg transition" :class="offset === 0 ? 'text-slate-600 cursor-not-allowed' : 'text-slate-300 hover:bg-slate-700'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<button @click="prevPage" :disabled="offset === 0" class="px-3 py-1.5 text-sm rounded-lg transition" :class="offset === 0 ? 'text-slate-600 cursor-not-allowed' : 'text-slate-300 hover:bg-slate-700'">
{{ t('gamification.leaderboard.prev') }}
</button>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">{{ offset + 1 }}{{ Math.min(offset + limit, totalCount) }} / {{ totalCount }}</span>
<select v-model.number="limit" @change="onLimitChange" class="bg-slate-700 border border-slate-600 rounded-lg px-2 py-1.5 text-xs text-slate-300 focus:ring-2 focus:ring-indigo-500 focus:border-transparent">
<option :value="25">25 / {{ t('gamification.leaderboard.page') }}</option>
<option :value="50">50 / {{ t('gamification.leaderboard.page') }}</option>
<option :value="100">100 / {{ t('gamification.leaderboard.page') }}</option>
<option :value="250">250 / {{ t('gamification.leaderboard.page') }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<button @click="nextPage" :disabled="offset + limit >= totalCount" class="px-3 py-1.5 text-sm rounded-lg transition" :class="offset + limit >= totalCount ? 'text-slate-600 cursor-not-allowed' : 'text-slate-300 hover:bg-slate-700'">
{{ t('gamification.leaderboard.next') }}
</button>
<button @click="lastPage" :disabled="offset + limit >= totalCount" class="px-2.5 py-1.5 text-sm rounded-lg transition" :class="offset + limit >= totalCount ? 'text-slate-600 cursor-not-allowed' : 'text-slate-300 hover:bg-slate-700'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface LeaderboardEntry {
rank: number
user_id: number
user_name: string
user_email: string | null
total_xp: number
total_points: number
social_points: number
current_level: number
penalty_points: number
restriction_level: number
places_discovered: number
services_submitted: number
updated_at: string | null
}
interface LeaderboardResponse {
total: number
offset: number
limit: number
entries: LeaderboardEntry[]
}
const { formatNumber, formatDateTime } = useFormatter()
const loading = ref(false)
const error = ref(false)
const entries = ref<LeaderboardEntry[]>([])
const totalCount = ref(0)
const offset = ref(0)
const limit = ref(50)
const sortBy = ref('total_xp')
const sortOrder = ref('desc')
const filters = ref({
search: '',
level: '' as number | string,
min_xp: null as number | null,
})
function restrictionLabel(level: number): string {
switch (level) {
case 1: return t('gamification.leaderboard.restriction_mild')
case 2: return t('gamification.leaderboard.restriction_moderate')
case 3: return t('gamification.leaderboard.restriction_severe')
default: return `${level}. ${t('gamification.leaderboard.restriction_level')}`
}
}
function getInitials(name: string): string {
return name
.split(' ')
.map(part => part.charAt(0).toUpperCase())
.slice(0, 2)
.join('')
}
function getRankBadgeClass(rank: number): string {
if (rank === 1) return 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
if (rank === 2) return 'bg-slate-300/10 text-slate-300 border border-slate-400/20'
if (rank === 3) return 'bg-amber-700/20 text-amber-600 border border-amber-700/30'
return 'bg-slate-700/50 text-slate-400 border border-slate-600/30'
}
function getRowClass(rank: number): string {
if (rank === 1) return 'bg-amber-500/5'
if (rank === 2) return 'bg-slate-300/5'
if (rank === 3) return 'bg-amber-700/5'
return ''
}
function toggleSortOrder() {
sortOrder.value = sortOrder.value === 'desc' ? 'asc' : 'desc'
applyFilters()
}
function buildQueryParams(): string {
const params = new URLSearchParams()
params.set('limit', String(limit.value))
params.set('offset', String(offset.value))
params.set('sort_by', sortBy.value)
params.set('sort_order', sortOrder.value)
if (filters.value.level !== '') params.set('level', String(filters.value.level))
if (filters.value.min_xp !== null && filters.value.min_xp > 0) params.set('min_xp', String(filters.value.min_xp))
if (filters.value.search && filters.value.search.length >= 2) params.set('search', filters.value.search.trim())
return params.toString()
}
async function fetchLeaderboard() {
loading.value = true
error.value = false
try {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const data = await $fetch<LeaderboardResponse>(`/api/v1/admin/gamification/leaderboard?${buildQueryParams()}`, { headers })
entries.value = data.entries || []
totalCount.value = data.total || 0
} catch (e) {
console.error('Failed to load leaderboard:', e)
error.value = true
} finally {
loading.value = false
}
}
function applyFilters() {
offset.value = 0
fetchLeaderboard()
}
function resetFilters() {
filters.value = { search: '', level: '', min_xp: null }
sortBy.value = 'total_xp'
sortOrder.value = 'desc'
offset.value = 0
fetchLeaderboard()
}
function onLimitChange() {
offset.value = 0
fetchLeaderboard()
}
function firstPage() {
offset.value = 0
fetchLeaderboard()
}
function prevPage() {
if (offset.value > 0) {
offset.value = Math.max(0, offset.value - limit.value)
fetchLeaderboard()
}
}
function nextPage() {
offset.value += limit.value
fetchLeaderboard()
}
function lastPage() {
const lastOffset = Math.max(0, Math.floor((totalCount.value - 1) / limit.value) * limit.value)
offset.value = lastOffset
fetchLeaderboard()
}
onMounted(() => {
fetchLeaderboard()
})
</script>