gemification_admin bekötve
This commit is contained in:
392
frontend_admin/pages/gamification/users/[id].vue
Normal file
392
frontend_admin/pages/gamification/users/[id].vue
Normal 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="1–10000" 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="0–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-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="0–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-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>
|
||||
252
frontend_admin/pages/gamification/users/index.vue
Normal file
252
frontend_admin/pages/gamification/users/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user