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,392 @@
<template>
<div>
<!-- Back Button & Header -->
<div class="mb-8">
<button
@click="goBack"
class="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-white transition mb-4"
>
<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="M15 19l-7-7 7-7" />
</svg>
{{ t('gamification.user_detail.back') }}
</button>
<h1 class="text-2xl font-bold text-white">{{ t('gamification.user_detail.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.user_detail.user_id') }} #{{ $route.params.id }}</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.user_detail.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.user_detail.load_error') }}</p>
<button @click="fetchData" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.user_detail.retry') }}</button>
</div>
<!-- Not Found -->
<div v-else-if="!stats" class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<p class="text-slate-400 text-center py-12">{{ t('gamification.user_detail.not_found') }}</p>
</div>
<template v-else>
<!-- Stats Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_total_xp') }}</h3>
<p class="text-2xl font-bold text-white">{{ formatNumber(stats.total_xp) }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_level') }}</h3>
<p class="text-2xl font-bold text-indigo-400">{{ stats.current_level }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_total_points') }}</h3>
<p class="text-2xl font-bold text-emerald-400">{{ formatNumber(stats.total_points) }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_social_points') }}</h3>
<p class="text-2xl font-bold text-cyan-400">{{ formatNumber(stats.social_points) }}</p>
</div>
</div>
<!-- Penalty & Activity Stats -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_penalty_points') }}</h3>
<p class="text-2xl font-bold" :class="stats.penalty_points > 0 ? 'text-rose-400' : 'text-slate-400'">
{{ stats.penalty_points > 0 ? `-${stats.penalty_points}` : '0' }}
</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_restriction') }}</h3>
<p class="text-2xl font-bold" :class="stats.restriction_level > 0 ? 'text-amber-400' : 'text-slate-400'">
{{ stats.restriction_level > 0 ? restrictionLabel(stats.restriction_level) : t('gamification.user_detail.none') }}
</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_penalty_quota') }}</h3>
<p class="text-2xl font-bold text-slate-300">{{ stats.penalty_quota_remaining }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_banned') }}</h3>
<p class="text-2xl font-bold" :class="stats.banned_until ? 'text-rose-400' : 'text-slate-400'">
{{ stats.banned_until ? formatDate(stats.banned_until) : t('gamification.user_detail.no') }}
</p>
</div>
</div>
<!-- Activity Stats -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_services') }}</h3>
<p class="text-2xl font-bold text-white">{{ stats.services_submitted }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_discovered') }}</h3>
<p class="text-2xl font-bold text-white">{{ stats.places_discovered }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_validated') }}</h3>
<p class="text-2xl font-bold text-white">{{ stats.places_validated }}</p>
</div>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_providers') }}</h3>
<p class="text-2xl font-bold text-white">{{ stats.providers_added_count }}</p>
</div>
</div>
<!-- Actions: Penalty & Reward -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Penalty Form -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
{{ t('gamification.user_detail.penalty_title') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.penalty_points_label') }}</label>
<input v-model.number="penaltyForm.amount" type="number" min="1" max="10000" placeholder="110000" 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-rose-500 focus:border-transparent" />
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.penalty_reason_label') }}</label>
<textarea v-model="penaltyForm.reason" rows="2" :placeholder="t('gamification.user_detail.penalty_reason_placeholder')" 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-rose-500 focus:border-transparent resize-none"></textarea>
</div>
<button @click="applyPenalty" :disabled="penaltySubmitting || !penaltyForm.amount || !penaltyForm.reason" class="w-full px-4 py-2.5 bg-rose-600 hover:bg-rose-500 disabled:bg-rose-600/50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition flex items-center justify-center gap-2">
<svg v-if="penaltySubmitting" class="w-4 h-4 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>
{{ penaltySubmitting ? t('gamification.user_detail.penalty_submitting') : t('gamification.user_detail.penalty_submit') }}
</button>
<p v-if="penaltyError" class="text-xs text-rose-400">{{ penaltyError }}</p>
<p v-if="penaltySuccess" class="text-xs text-emerald-400">{{ penaltySuccess }}</p>
</div>
</div>
<!-- Reward Form -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-emerald-400" 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.user_detail.reward_title') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.reward_xp_label') }}</label>
<input v-model.number="rewardForm.xp_amount" type="number" min="0" max="100000" placeholder="0100000" 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-emerald-500 focus:border-transparent" />
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.reward_social_label') }}</label>
<input v-model.number="rewardForm.social_amount" type="number" min="0" max="100000" placeholder="0100000" 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-emerald-500 focus:border-transparent" />
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.reward_reason_label') }}</label>
<textarea v-model="rewardForm.reason" rows="2" :placeholder="t('gamification.user_detail.reward_reason_placeholder')" 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-emerald-500 focus:border-transparent resize-none"></textarea>
</div>
<button @click="applyReward" :disabled="rewardSubmitting || !rewardForm.xp_amount || !rewardForm.reason" class="w-full px-4 py-2.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-600/50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition flex items-center justify-center gap-2">
<svg v-if="rewardSubmitting" class="w-4 h-4 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>
{{ rewardSubmitting ? t('gamification.user_detail.reward_submitting') : t('gamification.user_detail.reward_submit') }}
</button>
<p v-if="rewardError" class="text-xs text-rose-400">{{ rewardError }}</p>
<p v-if="rewardSuccess" class="text-xs text-emerald-400">{{ rewardSuccess }}</p>
</div>
</div>
</div>
<!-- Points Ledger -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">{{ t('gamification.user_detail.ledger_title') }}</h2>
<span class="text-xs text-slate-500">{{ t('gamification.user_detail.ledger_count', { count: ledger.length }) }}</span>
</div>
<div v-if="ledgerLoading" class="flex items-center justify-center py-8">
<div class="text-slate-400 flex items-center gap-2">
<svg class="w-4 h-4 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.user_detail.ledger_loading') }}</span>
</div>
</div>
<div v-else-if="ledger.length === 0" class="text-sm text-slate-500 text-center py-8">
{{ t('gamification.user_detail.ledger_empty') }}
</div>
<div v-else class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_date') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_points') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_penalty') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_xp') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_reason') }}</th>
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_source') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="entry in ledger" :key="entry.id" class="hover:bg-slate-700/30 transition">
<td class="px-3 py-2.5 text-xs text-slate-400 whitespace-nowrap">{{ formatDateTime(entry.created_at) }}</td>
<td class="px-3 py-2.5 text-sm font-medium" :class="entry.points >= 0 ? 'text-emerald-400' : 'text-rose-400'">
{{ entry.points >= 0 ? '+' : '' }}{{ entry.points }}
</td>
<td class="px-3 py-2.5 text-sm" :class="entry.penalty_change ? 'text-rose-400' : 'text-slate-500'">
{{ entry.penalty_change ? `-${entry.penalty_change}` : '—' }}
</td>
<td class="px-3 py-2.5 text-sm text-slate-300">{{ entry.xp ?? '—' }}</td>
<td class="px-3 py-2.5 text-sm text-slate-300 max-w-xs truncate">{{ entry.reason }}</td>
<td class="px-3 py-2.5 text-xs text-slate-500">
<span v-if="entry.source_type" class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-slate-700 text-slate-400">
{{ entry.source_type }}{{ entry.source_id ? ` #${entry.source_id}` : '' }}
</span>
<span v-else></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface UserStats {
user_id: number
total_xp: number
total_points: number
social_points: number
current_level: number
penalty_points: number
restriction_level: number
penalty_quota_remaining: number
places_discovered: number
places_validated: number
providers_added_count: number
services_submitted: number
banned_until: string | null
updated_at: string | null
}
interface LedgerEntry {
id: number
user_id: number
user_name: string
points: number
penalty_change: number | null
reason: string
xp: number | null
source_type: string | null
source_id: number | null
created_at: string
}
const { formatNumber, formatDate, formatDateTime } = useFormatter()
const router = useRouter()
const route = useRoute()
const userId = computed(() => Number(route.params.id))
const loading = ref(true)
const error = ref(false)
const stats = ref<UserStats | null>(null)
const ledger = ref<LedgerEntry[]>([])
const ledgerLoading = ref(false)
// Penalty form
const penaltyForm = ref({ amount: 0, reason: '' })
const penaltySubmitting = ref(false)
const penaltyError = ref('')
const penaltySuccess = ref('')
// Reward form
const rewardForm = ref({ xp_amount: 0, social_amount: 0, reason: '' })
const rewardSubmitting = ref(false)
const rewardError = ref('')
const rewardSuccess = ref('')
function restrictionLabel(level: number): string {
switch (level) {
case 1: return t('gamification.user_detail.restriction_mild')
case 2: return t('gamification.user_detail.restriction_moderate')
case 3: return t('gamification.user_detail.restriction_severe')
default: return `${level}. ${t('gamification.user_detail.restriction_level')}`
}
}
function goBack() {
router.push('/gamification/users')
}
function getHeaders() {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function fetchData() {
loading.value = true
error.value = false
try {
const headers = getHeaders()
const data = await $fetch<UserStats>(`/api/v1/admin/gamification/user-stats/${userId.value}`, { headers })
stats.value = data
await fetchLedger()
} catch (e: any) {
console.error('Failed to load user stats:', e)
error.value = true
} finally {
loading.value = false
}
}
async function fetchLedger() {
ledgerLoading.value = true
try {
const headers = getHeaders()
const data = await $fetch<LedgerEntry[]>(`/api/v1/admin/gamification/points-ledger?user_id=${userId.value}&limit=50`, { headers })
ledger.value = data
} catch (e) {
console.error('Failed to load ledger:', e)
ledger.value = []
} finally {
ledgerLoading.value = false
}
}
async function applyPenalty() {
if (!penaltyForm.value.amount || !penaltyForm.value.reason) return
penaltySubmitting.value = true
penaltyError.value = ''
penaltySuccess.value = ''
try {
const headers = getHeaders()
await $fetch(`/api/v1/admin/gamification/user-stats/${userId.value}/penalty`, {
method: 'POST',
headers,
body: {
amount: penaltyForm.value.amount,
reason: penaltyForm.value.reason,
},
})
penaltySuccess.value = t('gamification.user_detail.penalty_success', { amount: penaltyForm.value.amount })
penaltyForm.value = { amount: 0, reason: '' }
await fetchData()
} catch (e: any) {
penaltyError.value = e?.data?.detail || e?.message || t('gamification.user_detail.penalty_error')
} finally {
penaltySubmitting.value = false
}
}
async function applyReward() {
if (!rewardForm.value.xp_amount || !rewardForm.value.reason) return
rewardSubmitting.value = true
rewardError.value = ''
rewardSuccess.value = ''
try {
const headers = getHeaders()
await $fetch(`/api/v1/admin/gamification/user-stats/${userId.value}/reward`, {
method: 'POST',
headers,
body: {
xp_amount: rewardForm.value.xp_amount,
social_amount: rewardForm.value.social_amount || 0,
reason: rewardForm.value.reason,
},
})
rewardSuccess.value = t('gamification.user_detail.reward_success', { xp: rewardForm.value.xp_amount, social: rewardForm.value.social_amount || 0 })
rewardForm.value = { xp_amount: 0, social_amount: 0, reason: '' }
await fetchData()
} catch (e: any) {
rewardError.value = e?.data?.detail || e?.message || t('gamification.user_detail.reward_error')
} finally {
rewardSubmitting.value = false
}
}
onMounted(() => {
fetchData()
})
</script>

View File

@@ -0,0 +1,252 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.users.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.users.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">
<!-- Level Filter -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_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.users.all_levels') }}</option>
<option v-for="lvl in 20" :key="lvl" :value="lvl">{{ lvl }}. {{ t('gamification.users.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.users.filter_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>
<!-- Max XP -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_max_xp') }}</label>
<input v-model.number="filters.max_xp" type="number" min="0" placeholder="100000" 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>
<!-- Has Penalty -->
<div>
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_penalty') }}</label>
<select v-model="filters.has_penalty" 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.users.penalty_any') }}</option>
<option :value="true">{{ t('gamification.users.penalty_only') }}</option>
<option :value="false">{{ t('gamification.users.penalty_none') }}</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.users.filter_apply') }}
</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.users.filter_reset') }}
</button>
<span class="text-xs text-slate-500 ml-auto">{{ t('gamification.users.total_count', { count: totalCount }) }}</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.users.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.users.load_error') }}</p>
<button @click="fetchUsers" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.users.retry') }}</button>
</div>
<!-- Empty State -->
<div v-else-if="users.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.users.empty') }}</p>
</div>
<!-- Users 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">{{ t('gamification.users.col_user_id') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_xp') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_level') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_points') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_penalty') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_restriction') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_services') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_discoveries') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_updated') }}</th>
<th class="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="user in users" :key="user.user_id" class="hover:bg-slate-700/30 transition">
<td class="px-4 py-3 text-sm text-white font-mono">#{{ user.user_id }}</td>
<td class="px-4 py-3 text-sm text-white">{{ formatNumber(user.total_xp) }}</td>
<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">
{{ user.current_level }}
</span>
</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ formatNumber(user.total_points) }}</td>
<td class="px-4 py-3">
<span v-if="user.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">
-{{ user.penalty_points }}
</span>
<span v-else class="text-sm text-slate-500"></span>
</td>
<td class="px-4 py-3">
<span v-if="user.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(user.restriction_level) }}
</span>
<span v-else class="text-sm text-slate-500">{{ t('gamification.users.none') }}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ user.services_submitted }}</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ user.places_discovered }}</td>
<td class="px-4 py-3 text-sm text-slate-500">{{ user.updated_at ? formatDate(user.updated_at) : '—' }}</td>
<td class="px-4 py-3 text-right">
<NuxtLink :to="`/gamification/users/${user.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.users.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">
<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.users.prev') }}
</button>
<span class="text-sm text-slate-500">{{ offset + 1 }}{{ Math.min(offset + limit, totalCount) }} / {{ totalCount }}</span>
<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.users.next') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface UserStats {
user_id: number
total_xp: number
total_points: number
social_points: number
current_level: number
penalty_points: number
restriction_level: number
penalty_quota_remaining: number
places_discovered: number
places_validated: number
providers_added_count: number
services_submitted: number
banned_until: string | null
updated_at: string | null
}
const { formatNumber, formatDate } = useFormatter()
const loading = ref(false)
const error = ref(false)
const users = ref<UserStats[]>([])
const totalCount = ref(0)
const offset = ref(0)
const limit = ref(50)
const filters = ref({
level: '' as number | string,
min_xp: null as number | null,
max_xp: null as number | null,
has_penalty: '' as boolean | string,
})
function restrictionLabel(level: number): string {
switch (level) {
case 1: return t('gamification.users.restriction_mild')
case 2: return t('gamification.users.restriction_moderate')
case 3: return t('gamification.users.restriction_severe')
default: return `${level}. ${t('gamification.users.restriction_level')}`
}
}
function buildQueryParams(): string {
const params = new URLSearchParams()
params.set('limit', String(limit.value))
params.set('offset', String(offset.value))
if (filters.value.level !== '') params.set('level', String(filters.value.level))
if (filters.value.min_xp !== null) params.set('min_xp', String(filters.value.min_xp))
if (filters.value.max_xp !== null) params.set('max_xp', String(filters.value.max_xp))
if (filters.value.has_penalty !== '') params.set('has_penalty', String(filters.value.has_penalty))
return params.toString()
}
async function fetchUsers() {
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<UserStats[]>(`/api/v1/admin/gamification/user-stats?${buildQueryParams()}`, { headers })
users.value = data
totalCount.value = data.length < limit.value ? offset.value + data.length : offset.value + limit.value + 1
} catch (e) {
console.error('Failed to load user stats:', e)
error.value = true
} finally {
loading.value = false
}
}
function applyFilters() {
offset.value = 0
fetchUsers()
}
function resetFilters() {
filters.value = { level: '', min_xp: null, max_xp: null, has_penalty: '' }
offset.value = 0
fetchUsers()
}
function prevPage() {
if (offset.value > 0) {
offset.value = Math.max(0, offset.value - limit.value)
fetchUsers()
}
}
function nextPage() {
offset.value += limit.value
fetchUsers()
}
onMounted(() => {
fetchUsers()
})
</script>