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,365 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.badges.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.badges.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.badges.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.badges.error') }}</p>
<button @click="fetchBadges" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.badges.retry') }}</button>
</div>
<template v-else>
<!-- Create Button -->
<div class="mb-6 flex justify-end">
<button @click="openCreateModal" class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<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="M12 4v16m8-8H4" />
</svg>
{{ t('gamification.badges.create') }}
</button>
</div>
<!-- Badges Table -->
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.badges.id') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.badges.name') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.badges.description') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.badges.icon') }}</th>
<th class="text-right px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.badges.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="badge in badges" :key="badge.id" class="hover:bg-slate-700/30 transition">
<td class="px-6 py-4 text-sm text-slate-400">{{ badge.id }}</td>
<td class="px-6 py-4">
<span class="text-sm font-semibold text-white">{{ badge.name }}</span>
</td>
<td class="px-6 py-4 text-sm text-slate-300 max-w-xs truncate">{{ badge.description || '—' }}</td>
<td class="px-6 py-4">
<div v-if="badge.icon_url" class="flex items-center gap-2">
<img :src="badge.icon_url" alt="" class="w-8 h-8 rounded-full object-cover bg-slate-700" />
<span class="text-xs text-slate-400 truncate max-w-[120px]">{{ badge.icon_url }}</span>
</div>
<span v-else class="text-sm text-slate-500"></span>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button @click="openEditModal(badge)" class="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition" :title="t('gamification.badges.edit')">
<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 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="confirmDelete(badge)" class="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 transition" :title="t('gamification.badges.delete')">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="badges.length === 0">
<td colspan="5" class="px-6 py-12 text-center text-sm text-slate-500">
{{ t('gamification.badges.no_items') }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- Create/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeModal">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-lg mx-4 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-6">{{ editingBadge ? t('gamification.badges.edit_title') : t('gamification.badges.create_title') }}</h2>
<form @submit.prevent="saveBadge" class="space-y-4">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.badges.name') }} <span class="text-rose-400">*</span></label>
<input
v-model="form.name"
type="text"
required
maxlength="100"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="t('gamification.badges.name_placeholder')"
/>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.badges.description') }} <span class="text-rose-400">*</span></label>
<textarea
v-model="form.description"
required
maxlength="500"
rows="3"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
:placeholder="t('gamification.badges.desc_placeholder')"
></textarea>
</div>
<!-- Icon URL -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.badges.icon') }}</label>
<input
v-model="form.icon_url"
type="url"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="t('gamification.badges.icon_placeholder')"
/>
<p class="text-xs text-slate-500 mt-1">{{ t('gamification.badges.icon_hint') }}</p>
</div>
<!-- Icon Preview -->
<div v-if="form.icon_url" class="flex items-center gap-3 p-3 bg-slate-700/50 rounded-lg">
<img :src="form.icon_url" alt="Preview" class="w-10 h-10 rounded-full object-cover bg-slate-600" @error="iconPreviewError = true" />
<div class="text-xs text-slate-400">
<span class="text-emerald-400"></span> {{ t('gamification.badges.icon_preview') }}
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-end gap-3 pt-2">
<button type="button" @click="closeModal" class="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition">
{{ t('gamification.badges.cancel') }}
</button>
<button
type="submit"
:disabled="saving"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition flex items-center gap-2"
>
<svg v-if="saving" 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>
{{ editingBadge ? t('gamification.badges.save') : t('gamification.badges.create_btn') }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteModal = false">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-md mx-4 shadow-2xl">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-full bg-rose-500/10 text-rose-400">
<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 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>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('gamification.badges.delete_title') }}</h2>
<p class="text-sm text-slate-400 mt-1">{{ t('gamification.badges.delete_confirm') }}</p>
</div>
</div>
<div class="bg-slate-700/50 rounded-lg p-4 mb-6">
<p class="text-sm font-medium text-white">{{ deletingBadge?.name }}</p>
<p class="text-xs text-slate-400 mt-1">{{ deletingBadge?.description }}</p>
</div>
<div class="flex items-center justify-end gap-3">
<button @click="showDeleteModal = false" class="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition">
{{ t('gamification.badges.cancel') }}
</button>
<button
@click="deleteBadge"
:disabled="saving"
class="px-4 py-2 bg-rose-600 hover:bg-rose-500 disabled:bg-rose-600/50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition flex items-center gap-2"
>
<svg v-if="saving" 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>
{{ t('gamification.badges.delete_btn') }}
</button>
</div>
</div>
</div>
<!-- Toast Notification -->
<div v-if="toast" class="fixed bottom-6 right-6 z-50 px-4 py-3 rounded-lg shadow-xl text-sm font-medium transition-all duration-300"
:class="toast.type === 'success' ? 'bg-emerald-600 text-white' : 'bg-rose-600 text-white'">
{{ toast.message }}
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface Badge {
id: number
name: string
description: string
icon_url: string | null
}
interface Toast {
message: string
type: 'success' | 'error'
}
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
const badges = ref<Badge[]>([])
const showModal = ref(false)
const showDeleteModal = ref(false)
const editingBadge = ref<Badge | null>(null)
const deletingBadge = ref<Badge | null>(null)
const iconPreviewError = ref(false)
const toast = ref<Toast | null>(null)
const form = reactive({
name: '',
description: '',
icon_url: '',
})
function getAuthHeaders() {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}` } : {}
}
function showToast(message: string, type: 'success' | 'error') {
toast.value = { message, type }
setTimeout(() => { toast.value = null }, 4000)
}
async function fetchBadges() {
loading.value = true
error.value = false
try {
const data = await $fetch<Badge[]>('/api/v1/admin/gamification/badges', {
headers: { ...getAuthHeaders() },
})
badges.value = data
} catch (err) {
console.error('[Badges] Failed to fetch:', err)
error.value = true
} finally {
loading.value = false
}
}
function openCreateModal() {
editingBadge.value = null
form.name = ''
form.description = ''
form.icon_url = ''
iconPreviewError.value = false
showModal.value = true
}
function openEditModal(badge: Badge) {
editingBadge.value = badge
form.name = badge.name
form.description = badge.description
form.icon_url = badge.icon_url || ''
iconPreviewError.value = false
showModal.value = true
}
function closeModal() {
showModal.value = false
editingBadge.value = null
}
async function saveBadge() {
saving.value = true
try {
if (editingBadge.value) {
// Update existing badge
await $fetch(`/api/v1/admin/gamification/badges/${editingBadge.value.id}`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: {
name: form.name,
description: form.description,
icon_url: form.icon_url || null,
},
})
showToast(t('gamification.badges.updated'), 'success')
} else {
// Create new badge
await $fetch('/api/v1/admin/gamification/badges', {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
},
body: {
name: form.name,
description: form.description,
icon_url: form.icon_url || null,
},
})
showToast(t('gamification.badges.created'), 'success')
}
closeModal()
await fetchBadges()
} catch (err: any) {
console.error('[Badges] Save failed:', err)
const detail = err?.data?.detail || err?.message || t('gamification.badges.unknown_error')
showToast(`${t('gamification.badges.save_error')}: ${detail}`, 'error')
} finally {
saving.value = false
}
}
function confirmDelete(badge: Badge) {
deletingBadge.value = badge
showDeleteModal.value = true
}
async function deleteBadge() {
if (!deletingBadge.value) return
saving.value = true
try {
await $fetch(`/api/v1/admin/gamification/badges/${deletingBadge.value.id}`, {
method: 'DELETE',
headers: { ...getAuthHeaders() },
})
showToast(t('gamification.badges.deleted'), 'success')
showDeleteModal.value = false
deletingBadge.value = null
await fetchBadges()
} catch (err: any) {
console.error('[Badges] Delete failed:', err)
const detail = err?.data?.detail || err?.message || t('gamification.badges.unknown_error')
showToast(`${t('gamification.badges.delete_error')}: ${detail}`, 'error')
} finally {
saving.value = false
}
}
onMounted(() => {
fetchBadges()
})
</script>

View File

@@ -0,0 +1,496 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white">{{ t('gamification.competitions.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.competitions.subtitle') }}</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition"
@click="openCreateModal"
>
<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="M12 4v16m8-8H4" />
</svg>
{{ t('gamification.competitions.create') }}
</button>
</div>
<!-- Season Filter -->
<div class="mb-6">
<div class="flex items-center gap-3 flex-wrap">
<label class="text-sm text-slate-400">{{ t('gamification.competitions.season_filter') }}</label>
<select
v-model="selectedSeasonId"
class="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
@change="fetchCompetitions"
>
<option :value="null">{{ t('gamification.competitions.all_seasons') }}</option>
<option v-for="s in seasons" :key="s.id" :value="s.id">
{{ s.name }} {{ s.is_active ? t('gamification.competitions.active_badge') : '' }}
</option>
</select>
</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.competitions.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.competitions.error') }}</p>
<button class="mt-2 text-sm text-rose-300 hover:text-rose-200 underline" @click="fetchCompetitions">{{ t('gamification.competitions.retry') }}</button>
</div>
<!-- Competitions List -->
<template v-else>
<!-- Empty State -->
<div v-if="competitions.length === 0" class="bg-slate-800 rounded-xl border border-slate-700 p-12 text-center">
<svg class="w-12 h-12 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" />
</svg>
<p class="text-slate-400">{{ t('gamification.competitions.no_items') }}</p>
<button
class="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition"
@click="openCreateModal"
>
{{ t('gamification.competitions.create_first') }}
</button>
</div>
<!-- Competition Cards -->
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div
v-for="comp in competitions"
:key="comp.id"
class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-slate-600 transition"
>
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="text-lg font-semibold text-white">{{ comp.name }}</h3>
<p v-if="comp.description" class="text-sm text-slate-400 mt-1 line-clamp-2">{{ comp.description }}</p>
</div>
<span
class="px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ml-2"
:class="getStatusClass(comp.status)"
>
{{ getStatusLabel(comp.status) }}
</span>
</div>
<div class="space-y-2 text-sm text-slate-400 mb-4">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 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>
<span>{{ formatDate(comp.start_date) }} {{ formatDate(comp.end_date) }}</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span>{{ t('gamification.competitions.season_id_label') }} {{ comp.season_id }}</span>
</div>
</div>
<!-- Rules Preview -->
<div v-if="comp.rules && Object.keys(comp.rules).length > 0" class="mb-4">
<details class="text-sm">
<summary class="text-indigo-400 cursor-pointer hover:text-indigo-300">{{ t('gamification.competitions.view_rules') }}</summary>
<pre class="mt-2 p-3 bg-slate-900 rounded-lg text-xs text-slate-300 overflow-x-auto">{{ JSON.stringify(comp.rules, null, 2) }}</pre>
</details>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-2 border-t border-slate-700/50">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-indigo-400 hover:bg-indigo-500/10 transition"
@click="openEditModal(comp)"
>
<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 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{{ t('gamification.competitions.edit') }}
</button>
</div>
</div>
</div>
</template>
<!-- Create/Edit Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div class="fixed inset-0 bg-black/60" @click="closeModal" />
<div class="relative bg-slate-800 rounded-xl border border-slate-700 w-full max-w-lg p-6 shadow-2xl max-h-[90vh] overflow-y-auto">
<h2 class="text-lg font-semibold text-white mb-6">
{{ editingCompetition ? t('gamification.competitions.edit_title') : t('gamification.competitions.create_title') }}
</h2>
<form @submit.prevent="saveCompetition" class="space-y-4">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.name') }}</label>
<input
v-model="form.name"
type="text"
required
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="t('gamification.competitions.name_placeholder')"
/>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.description') }}</label>
<textarea
v-model="form.description"
rows="3"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="t('gamification.competitions.desc_placeholder')"
/>
</div>
<!-- Season -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.season') }}</label>
<select
v-model="form.season_id"
required
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="" disabled>{{ t('gamification.competitions.select_season') }}</option>
<option v-for="s in seasons" :key="s.id" :value="s.id">
{{ s.name }} {{ s.is_active ? t('gamification.competitions.active_badge') : '' }}
</option>
</select>
</div>
<!-- Start Date -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.start_date') }}</label>
<input
v-model="form.start_date"
type="date"
required
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<!-- End Date -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.end_date') }}</label>
<input
v-model="form.end_date"
type="date"
required
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<!-- Status -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.status') }}</label>
<select
v-model="form.status"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="draft">{{ t('gamification.competitions.status_draft') }}</option>
<option value="active">{{ t('gamification.competitions.status_active') }}</option>
<option value="completed">{{ t('gamification.competitions.status_completed') }}</option>
<option value="cancelled">{{ t('gamification.competitions.status_cancelled') }}</option>
</select>
</div>
<!-- Rules (JSON) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.competitions.rules') }}</label>
<textarea
v-model="form.rulesJson"
rows="5"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="t('gamification.competitions.rules_placeholder')"
/>
<p v-if="rulesParseError" class="text-xs text-rose-400 mt-1">{{ t('gamification.competitions.invalid_json') }}</p>
</div>
<!-- Error -->
<div v-if="formError" class="bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
<p class="text-sm text-rose-400">{{ formError }}</p>
</div>
<!-- Buttons -->
<div class="flex items-center justify-end gap-3 pt-2">
<button
type="button"
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
@click="closeModal"
>
{{ t('gamification.competitions.cancel') }}
</button>
<button
type="submit"
:disabled="saving || rulesParseError"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white text-sm font-medium rounded-lg transition flex items-center gap-2"
>
<svg v-if="saving" 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>
{{ editingCompetition ? t('gamification.competitions.save') : t('gamification.competitions.create_btn') }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface Season {
id: number
name: string
start_date: string
end_date: string
is_active: boolean
created_at: string | null
}
interface Competition {
id: number
name: string
description: string | null
season_id: number
start_date: string
end_date: string
rules: Record<string, any> | null
status: string
created_at: string | null
updated_at: string | null
}
interface CompetitionForm {
name: string
description: string
season_id: number | ''
start_date: string
end_date: string
status: string
rulesJson: string
}
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
const showModal = ref(false)
const seasons = ref<Season[]>([])
const competitions = ref<Competition[]>([])
const editingCompetition = ref<Competition | null>(null)
const selectedSeasonId = ref<number | null>(null)
const formError = ref('')
const rulesParseError = ref(false)
const form = reactive<CompetitionForm>({
name: '',
description: '',
season_id: '',
start_date: '',
end_date: '',
status: 'draft',
rulesJson: '',
})
function getAuthHeaders() {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}` } : {}
}
function formatDate(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('hu-HU', { year: 'numeric', month: 'long', day: 'numeric' })
}
function getStatusClass(status: string): string {
switch (status) {
case 'active': return 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30'
case 'draft': return 'bg-slate-700 text-slate-400 border border-slate-600'
case 'completed': return 'bg-blue-500/10 text-blue-400 border border-blue-500/30'
case 'cancelled': return 'bg-rose-500/10 text-rose-400 border border-rose-500/30'
default: return 'bg-slate-700 text-slate-400 border border-slate-600'
}
}
function getStatusLabel(status: string): string {
switch (status) {
case 'active': return t('gamification.competitions.status_active')
case 'draft': return t('gamification.competitions.status_draft')
case 'completed': return t('gamification.competitions.status_completed')
case 'cancelled': return t('gamification.competitions.status_cancelled')
default: return status
}
}
function resetForm() {
form.name = ''
form.description = ''
form.season_id = ''
form.start_date = ''
form.end_date = ''
form.status = 'draft'
form.rulesJson = ''
formError.value = ''
rulesParseError.value = false
}
function openCreateModal() {
editingCompetition.value = null
resetForm()
showModal.value = true
}
function openEditModal(comp: Competition) {
editingCompetition.value = comp
form.name = comp.name
form.description = comp.description || ''
form.season_id = comp.season_id
form.start_date = comp.start_date
form.end_date = comp.end_date
form.status = comp.status
form.rulesJson = comp.rules ? JSON.stringify(comp.rules, null, 2) : ''
formError.value = ''
rulesParseError.value = false
showModal.value = true
}
function closeModal() {
showModal.value = false
editingCompetition.value = null
resetForm()
}
function parseRules(): Record<string, any> | null {
if (!form.rulesJson.trim()) return null
try {
const parsed = JSON.parse(form.rulesJson)
rulesParseError.value = false
return parsed
} catch {
rulesParseError.value = true
return null
}
}
async function fetchSeasons() {
try {
const data = await $fetch('/api/v1/admin/gamification/seasons', {
headers: getAuthHeaders(),
})
seasons.value = data as Season[]
} catch (e) {
console.error('Failed to fetch seasons:', e)
}
}
async function fetchCompetitions() {
loading.value = true
error.value = false
try {
const params: Record<string, any> = {}
if (selectedSeasonId.value) {
params.season_id = selectedSeasonId.value
}
const query = new URLSearchParams(params).toString()
const url = `/api/v1/admin/gamification/competitions${query ? `?${query}` : ''}`
const data = await $fetch(url, {
headers: getAuthHeaders(),
})
competitions.value = data as Competition[]
} catch (e) {
console.error('Failed to fetch competitions:', e)
error.value = true
} finally {
loading.value = false
}
}
async function saveCompetition() {
saving.value = true
formError.value = ''
// Validate rules JSON if provided
let rules = null
if (form.rulesJson.trim()) {
rules = parseRules()
if (rulesParseError.value) {
saving.value = false
return
}
}
try {
if (editingCompetition.value) {
// Update existing competition
const payload: Record<string, any> = {}
if (form.name !== editingCompetition.value.name) payload.name = form.name
if (form.description !== (editingCompetition.value.description || '')) payload.description = form.description || null
if (form.season_id !== editingCompetition.value.season_id) payload.season_id = form.season_id
if (form.start_date !== editingCompetition.value.start_date) payload.start_date = form.start_date
if (form.end_date !== editingCompetition.value.end_date) payload.end_date = form.end_date
if (form.status !== editingCompetition.value.status) payload.status = form.status
if (rules !== null) payload.rules = rules
if (Object.keys(payload).length > 0) {
await $fetch(`/api/v1/admin/gamification/competitions/${editingCompetition.value.id}`, {
method: 'PUT',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: payload,
})
}
} else {
// Create new competition
await $fetch('/api/v1/admin/gamification/competitions', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: {
name: form.name,
description: form.description || null,
season_id: form.season_id,
start_date: form.start_date,
end_date: form.end_date,
status: form.status,
rules: rules,
},
})
}
closeModal()
await fetchCompetitions()
} catch (e: any) {
console.error('Failed to save competition:', e)
formError.value = e?.data?.detail || e?.message || t('gamification.competitions.save_error')
} finally {
saving.value = false
}
}
onMounted(async () => {
await fetchSeasons()
await fetchCompetitions()
})
</script>

View File

@@ -0,0 +1,545 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.config.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.config.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.config.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.config.load_error') }}</p>
<button @click="fetchConfig" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.config.retry') }}</button>
</div>
<template v-else>
<!-- Save Button Bar -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">{{ t('gamification.config.last_saved') }} {{ lastSaved ? lastSaved : '—' }}</span>
</div>
<button @click="saveConfig" :disabled="saving" class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="saving" 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>
{{ saving ? t('gamification.config.saving') : t('gamification.config.save_config') }}
</button>
</div>
<!-- Config Sections Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- XP Logic -->
<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-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{{ t('gamification.config.xp_logic') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.base_xp') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="xpLogic.baseXp"
type="range"
min="0"
max="10000"
step="100"
class="flex-1 accent-amber-500"
/>
<input
v-model.number="xpLogic.baseXp"
type="number"
min="0"
max="10000"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.exponent') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="xpLogic.exponent"
type="range"
min="0.1"
max="5.0"
step="0.1"
class="flex-1 accent-amber-500"
/>
<input
v-model.number="xpLogic.exponent"
type="number"
min="0.1"
max="5.0"
step="0.1"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
</div>
</div>
</div>
</div>
<!-- Conversion Logic -->
<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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
{{ t('gamification.config.conversion_logic') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.social_credit_rate') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="conversionLogic.socialToCreditRate"
type="range"
min="0"
max="10000"
step="10"
class="flex-1 accent-emerald-500"
/>
<input
v-model.number="conversionLogic.socialToCreditRate"
type="number"
min="0"
max="10000"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-emerald-500/50"
/>
</div>
</div>
</div>
</div>
<!-- Penalty Logic -->
<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.config.penalty_logic') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.recovery_rate') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="penaltyLogic.recoveryRate"
type="range"
min="0"
max="1"
step="0.05"
class="flex-1 accent-rose-500"
/>
<input
v-model.number="penaltyLogic.recoveryRate"
type="number"
min="0"
max="1"
step="0.05"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">{{ t('gamification.config.thresholds') }}</label>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="block text-xs text-slate-500 mb-1">{{ t('gamification.config.level_1') }}</label>
<input v-model.number="penaltyLogic.thresholds.level1" type="number" min="0" class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50" />
</div>
<div>
<label class="block text-xs text-slate-500 mb-1">{{ t('gamification.config.level_2') }}</label>
<input v-model.number="penaltyLogic.thresholds.level2" type="number" min="0" class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50" />
</div>
<div>
<label class="block text-xs text-slate-500 mb-1">{{ t('gamification.config.level_3') }}</label>
<input v-model.number="penaltyLogic.thresholds.level3" type="number" min="0" class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50" />
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">{{ t('gamification.config.multipliers') }}</label>
<div class="grid grid-cols-4 gap-2">
<div v-for="key in multiplierKeys" :key="key">
<label class="block text-xs text-slate-500 mb-1">{{ key }}</label>
<input v-model.number="penaltyLogic.multipliers[key]" type="number" min="0" max="1" step="0.1" class="w-full px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-rose-500/50" />
</div>
</div>
</div>
</div>
</div>
<!-- Level Rewards -->
<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-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
{{ t('gamification.config.level_rewards') }}
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.config.credits_per_10_levels') }}</label>
<div class="flex items-center gap-2">
<input
v-model.number="levelRewards.creditsPer10Levels"
type="range"
min="0"
max="10000"
step="10"
class="flex-1 accent-violet-500"
/>
<input
v-model.number="levelRewards.creditsPer10Levels"
type="number"
min="0"
max="10000"
class="w-20 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm text-center focus:outline-none focus:ring-2 focus:ring-violet-500/50"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Impact Calculator -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
{{ t('gamification.config.impact_calculator') }}
</h2>
<p class="text-sm text-slate-500 mb-6">{{ t('gamification.config.calculator_hint') }}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Calculator Inputs -->
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
{{ t('gamification.config.user_level') }}: <span class="text-cyan-400 font-bold">{{ calculator.level }}</span>
</label>
<input v-model.number="calculator.level" type="range" min="1" max="100" class="w-full accent-cyan-500" />
<div class="flex justify-between text-xs text-slate-600 mt-1">
<span>1</span><span>100</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
{{ t('gamification.config.daily_activities') }}: <span class="text-cyan-400 font-bold">{{ calculator.activities }}</span>
</label>
<input v-model.number="calculator.activities" type="range" min="1" max="20" class="w-full accent-cyan-500" />
<div class="flex justify-between text-xs text-slate-600 mt-1">
<span>1</span><span>20</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
{{ t('gamification.config.social_points') }}: <span class="text-cyan-400 font-bold">{{ calculator.socialPoints }}</span>
</label>
<input v-model.number="calculator.socialPoints" type="range" min="0" max="500" step="10" class="w-full accent-cyan-500" />
<div class="flex justify-between text-xs text-slate-600 mt-1">
<span>0</span><span>500</span>
</div>
</div>
</div>
<!-- Calculator Results -->
<div class="bg-slate-700/50 rounded-xl p-6 space-y-4">
<h3 class="text-sm font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.config.estimated_results') }}</h3>
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-slate-600/50">
<span class="text-sm text-slate-300">{{ t('gamification.config.daily_xp') }}</span>
<span class="text-lg font-bold text-amber-400">{{ formatNumber(calculatedDailyXp) }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-slate-600/50">
<span class="text-sm text-slate-300">{{ t('gamification.config.level_up_xp') }}</span>
<span class="text-lg font-bold text-emerald-400">{{ formatNumber(calculatedLevelUpXp) }}</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-slate-600/50">
<span class="text-sm text-slate-300">{{ t('gamification.config.social_credit') }}</span>
<span class="text-lg font-bold text-violet-400">{{ formatNumber(calculatedSocialCredit) }}</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="text-sm text-slate-300">{{ t('gamification.config.days_to_level_up') }}</span>
<span class="text-lg font-bold text-cyan-400">{{ calculatedDaysToLevelUp }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Raw JSON Editor (Collapsible) -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<button @click="showRawJson = !showRawJson" class="flex items-center justify-between w-full">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
{{ t('gamification.config.raw_json_editor') }}
</h2>
<svg class="w-5 h-5 text-slate-400 transition-transform" :class="showRawJson ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div v-if="showRawJson" class="mt-4">
<textarea
v-model="rawJson"
rows="20"
class="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-green-400 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50 resize-none"
spellcheck="false"
></textarea>
<div v-if="rawJsonError" class="mt-2 bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
<p class="text-sm text-rose-400 font-mono">{{ rawJsonError }}</p>
</div>
<button @click="applyRawJson" class="mt-3 px-4 py-2 bg-amber-600 hover:bg-amber-500 text-white rounded-lg text-sm font-medium transition">
{{ t('gamification.config.apply_json') }}
</button>
</div>
</div>
</template>
<!-- Form Error -->
<div v-if="formError" class="fixed bottom-6 right-6 z-50 bg-rose-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ formError }}
</div>
<!-- Success Toast -->
<div v-if="toast" class="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-up">
<svg class="w-4 h-4 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>
{{ toast }}
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface XpLogic {
baseXp: number
exponent: number
}
interface PenaltyThresholds {
level1: number
level2: number
level3: number
}
interface PenaltyMultipliers {
[key: string]: number
}
interface PenaltyLogic {
recoveryRate: number
thresholds: PenaltyThresholds
multipliers: PenaltyMultipliers
}
interface ConversionLogic {
socialToCreditRate: number
}
interface LevelRewards {
creditsPer10Levels: number
}
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
const formError = ref('')
const toast = ref('')
const lastSaved = ref('')
const showRawJson = ref(false)
const rawJson = ref('')
const rawJsonError = ref('')
const multiplierKeys = ['L0', 'L1', 'L2', 'L3']
const xpLogic = reactive<XpLogic>({ baseXp: 500, exponent: 1.5 })
const penaltyLogic = reactive<PenaltyLogic>({
recoveryRate: 0.5,
thresholds: { level1: 100, level2: 500, level3: 1000 },
multipliers: { L0: 1.0, L1: 0.5, L2: 0.1, L3: 0.0 },
})
const conversionLogic = reactive<ConversionLogic>({ socialToCreditRate: 100 })
const levelRewards = reactive<LevelRewards>({ creditsPer10Levels: 50 })
const calculator = reactive({ level: 5, activities: 3, socialPoints: 50 })
const calculatedDailyXp = computed(() => {
return Math.round(xpLogic.baseXp * Math.pow(calculator.activities, xpLogic.exponent))
})
const calculatedLevelUpXp = computed(() => {
return calculator.level * 1000
})
const calculatedSocialCredit = computed(() => {
return conversionLogic.socialToCreditRate * calculator.socialPoints
})
const calculatedDaysToLevelUp = computed(() => {
const daily = calculatedDailyXp.value
if (daily <= 0) return '∞'
return Math.ceil(calculatedLevelUpXp.value / daily) + ' ' + t('gamification.config.days')
})
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }
}
function showToast(msg: string) {
toast.value = msg
setTimeout(() => { toast.value = '' }, 3000)
}
function buildConfigObject(): Record<string, any> {
return {
xp_logic: {
base_xp: xpLogic.baseXp,
exponent: xpLogic.exponent,
},
penalty_logic: {
recovery_rate: penaltyLogic.recoveryRate,
thresholds: {
level_1: penaltyLogic.thresholds.level1,
level_2: penaltyLogic.thresholds.level2,
level_3: penaltyLogic.thresholds.level3,
},
multipliers: { ...penaltyLogic.multipliers },
},
conversion_logic: {
social_to_credit_rate: conversionLogic.socialToCreditRate,
},
level_rewards: {
credits_per_10_levels: levelRewards.creditsPer10Levels,
},
}
}
function applyConfigToForm(cfg: Record<string, any>) {
if (cfg.xp_logic) {
xpLogic.baseXp = cfg.xp_logic.base_xp ?? 500
xpLogic.exponent = cfg.xp_logic.exponent ?? 1.5
}
if (cfg.penalty_logic) {
penaltyLogic.recoveryRate = cfg.penalty_logic.recovery_rate ?? 0.5
if (cfg.penalty_logic.thresholds) {
penaltyLogic.thresholds.level1 = cfg.penalty_logic.thresholds.level_1 ?? 100
penaltyLogic.thresholds.level2 = cfg.penalty_logic.thresholds.level_2 ?? 500
penaltyLogic.thresholds.level3 = cfg.penalty_logic.thresholds.level_3 ?? 1000
}
if (cfg.penalty_logic.multipliers) {
for (const key of multiplierKeys) {
penaltyLogic.multipliers[key] = cfg.penalty_logic.multipliers[key] ?? 1.0
}
}
}
if (cfg.conversion_logic) {
conversionLogic.socialToCreditRate = cfg.conversion_logic.social_to_credit_rate ?? 100
}
if (cfg.level_rewards) {
levelRewards.creditsPer10Levels = cfg.level_rewards.credits_per_10_levels ?? 50
}
}
async function fetchConfig() {
loading.value = true
error.value = false
try {
const data = await $fetch<{ key: string; value: Record<string, any> }>('/api/v1/admin/gamification/master-config', { headers: getHeaders() })
if (data.value && Object.keys(data.value).length > 0) {
applyConfigToForm(data.value)
}
rawJson.value = JSON.stringify(buildConfigObject(), null, 2)
} catch (e) {
console.error('Failed to fetch master config:', e)
error.value = true
} finally {
loading.value = false
}
}
async function saveConfig() {
saving.value = true
formError.value = ''
try {
const config = buildConfigObject()
await $fetch('/api/v1/admin/gamification/master-config', {
method: 'PUT',
headers: getHeaders(),
body: { config },
})
lastSaved.value = new Date().toLocaleString('hu-HU')
rawJson.value = JSON.stringify(config, null, 2)
showToast(t('gamification.config.config_updated'))
} catch (e: any) {
formError.value = t('gamification.config.save_error')
console.error('Failed to save master config:', e)
setTimeout(() => { formError.value = '' }, 4000)
} finally {
saving.value = false
}
}
function applyRawJson() {
rawJsonError.value = ''
try {
const parsed = JSON.parse(rawJson.value)
applyConfigToForm(parsed)
showRawJson.value = false
showToast(t('gamification.config.json_applied'))
} catch (e: any) {
rawJsonError.value = t('gamification.config.invalid_json') + ' ' + (e.message || '')
}
}
function formatNumber(n: number): string {
return new Intl.NumberFormat('hu-HU').format(n)
}
onMounted(() => {
fetchConfig()
})
</script>
<style scoped>
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,222 @@
<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>

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>

View File

@@ -0,0 +1,392 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.ledger.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.ledger.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.ledger.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.ledger.load_error') }}</p>
<button @click="fetchLedger" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.ledger.retry') }}</button>
</div>
<template v-else>
<!-- Filters -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<!-- User ID Filter -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">{{ t('gamification.ledger.filter_user_id') }}</label>
<input
v-model.number="filters.user_id"
type="number"
min="1"
:placeholder="t('gamification.ledger.user_id_placeholder')"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@input="debouncedSearch"
/>
</div>
<!-- Date From -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">{{ t('gamification.ledger.filter_date_from') }}</label>
<input
v-model="filters.date_from"
type="date"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@change="debouncedSearch"
/>
</div>
<!-- Date To -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">{{ t('gamification.ledger.filter_date_to') }}</label>
<input
v-model="filters.date_to"
type="date"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@change="debouncedSearch"
/>
</div>
<!-- Reason Search -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">{{ t('gamification.ledger.filter_reason') }}</label>
<input
v-model="filters.reason_search"
type="text"
:placeholder="t('gamification.ledger.reason_placeholder')"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@input="debouncedSearch"
/>
</div>
<!-- Actions -->
<div class="flex items-end gap-2">
<button @click="fetchLedger" class="px-4 py-2 bg-amber-600 hover:bg-amber-500 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{{ t('gamification.ledger.search') }}
</button>
<button @click="resetFilters" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg text-sm font-medium transition">
{{ t('gamification.ledger.clear') }}
</button>
<button @click="exportCSV" class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition flex items-center gap-2 ml-auto" :title="t('gamification.ledger.csv_download')">
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ t('gamification.ledger.csv') }}
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="entries.length === 0" class="bg-slate-800 rounded-xl border border-slate-700 p-12 text-center">
<svg class="w-12 h-12 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-slate-400 text-sm">{{ t('gamification.ledger.no_results') }}</p>
<p class="text-slate-500 text-xs mt-1">{{ t('gamification.ledger.no_results_hint') }}</p>
</div>
<!-- Data 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.ledger.col_id') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.ledger.col_user') }}</th>
<th class="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.ledger.col_points') }}</th>
<th class="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.ledger.col_penalty') }}</th>
<th class="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.ledger.col_xp') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.ledger.col_reason') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.ledger.col_source') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.ledger.col_date') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="entry in entries" :key="entry.id" class="hover:bg-slate-700/30 transition">
<td class="px-4 py-3 text-sm text-slate-400 font-mono">{{ entry.id }}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="text-sm text-slate-300">{{ entry.user_name }}</span>
<span class="text-xs text-slate-500 font-mono">#{{ entry.user_id }}</span>
</div>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm font-semibold" :class="entry.points > 0 ? 'text-emerald-400' : entry.points < 0 ? 'text-rose-400' : 'text-slate-400'">
{{ entry.points > 0 ? '+' : '' }}{{ entry.points }}
</span>
</td>
<td class="px-4 py-3 text-right">
<span v-if="entry.penalty_change" class="text-sm font-semibold text-rose-400">
+{{ entry.penalty_change }}
</span>
<span v-else class="text-sm text-slate-500"></span>
</td>
<td class="px-4 py-3 text-right">
<span v-if="entry.xp" class="text-sm text-cyan-400 font-mono">{{ entry.xp }}</span>
<span v-else class="text-sm text-slate-500"></span>
</td>
<td class="px-4 py-3 max-w-xs">
<p class="text-sm text-slate-300 truncate" :title="entry.reason">{{ entry.reason }}</p>
</td>
<td class="px-4 py-3">
<div v-if="entry.source_type" class="flex items-center gap-1.5">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-700 text-slate-300">
{{ entry.source_type }}
</span>
<span v-if="entry.source_id" class="text-xs text-slate-500 font-mono">#{{ entry.source_id }}</span>
</div>
<span v-else class="text-sm text-slate-500"></span>
</td>
<td class="px-4 py-3 text-sm text-slate-400 whitespace-nowrap">
{{ formatDate(entry.created_at) }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between px-4 py-3 border-t border-slate-700">
<p class="text-sm text-slate-400">
{{ total }} {{ t('gamification.ledger.total_results') }} {{ entries.length }} {{ t('gamification.ledger.shown') }}
<span v-if="filters.user_id || filters.date_from || filters.date_to || filters.reason_search" class="text-slate-500">
({{ t('gamification.ledger.filtered') }})
</span>
</p>
<div class="flex items-center gap-2">
<button
@click="prevPage"
:disabled="offset === 0"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="offset === 0 ? 'text-slate-600' : 'text-slate-300 hover:bg-slate-700 hover:text-white'"
>
{{ t('gamification.ledger.prev') }}
</button>
<span class="text-sm text-slate-400 px-2">{{ currentPage }} / {{ totalPages }}</span>
<button
@click="nextPage"
:disabled="offset + limit >= total"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="offset + limit >= total ? 'text-slate-600' : 'text-slate-300 hover:bg-slate-700 hover:text-white'"
>
{{ t('gamification.ledger.next') }}
</button>
</div>
</div>
</div>
</template>
<!-- Success Toast -->
<div v-if="toast" class="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-up">
<svg class="w-4 h-4 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>
{{ toast }}
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface LedgerEntry {
id: number
user_id: number
user_name: string
points: number
penalty_change: number
reason: string
xp: number | null
source_type: string | null
source_id: number | null
created_at: string
}
const loading = ref(true)
const error = ref(false)
const entries = ref<LedgerEntry[]>([])
const total = ref(0)
const offset = ref(0)
const limit = ref(50)
const toast = ref('')
const filters = reactive({
user_id: null as number | null,
date_from: '',
date_to: '',
reason_search: '',
})
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }
}
function showToast(msg: string) {
toast.value = msg
setTimeout(() => { toast.value = '' }, 3000)
}
function debouncedSearch() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
offset.value = 0
fetchLedger()
}, 400)
}
function resetFilters() {
filters.user_id = null
filters.date_from = ''
filters.date_to = ''
filters.reason_search = ''
offset.value = 0
fetchLedger()
}
const currentPage = computed(() => Math.floor(offset.value / limit.value) + 1)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
function prevPage() {
if (offset.value > 0) {
offset.value = Math.max(0, offset.value - limit.value)
fetchLedger()
}
}
function nextPage() {
if (offset.value + limit.value < total.value) {
offset.value += limit.value
fetchLedger()
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('hu-HU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function buildQueryString(): string {
const params = new URLSearchParams()
params.set('limit', String(limit.value))
params.set('offset', String(offset.value))
if (filters.user_id) params.set('user_id', String(filters.user_id))
if (filters.date_from) params.set('date_from', filters.date_from)
if (filters.date_to) params.set('date_to', filters.date_to)
if (filters.reason_search) params.set('reason_search', filters.reason_search)
return params.toString()
}
async function fetchLedger() {
loading.value = true
error.value = false
try {
const qs = buildQueryString()
const data = await $fetch<LedgerEntry[]>(`/api/v1/admin/gamification/points-ledger?${qs}`, {
headers: getHeaders(),
})
entries.value = data
// Estimate total from response length — if less than limit, we're at the end
// For accurate total, we'd need a count endpoint, but this is a reasonable approach
if (data.length < limit.value) {
total.value = offset.value + data.length
} else {
total.value = offset.value + limit.value + 1 // approximate
}
} catch (e) {
console.error('Failed to fetch points ledger:', e)
error.value = true
} finally {
loading.value = false
}
}
function exportCSV() {
if (entries.value.length === 0) {
showToast(t('gamification.ledger.csv_no_data'))
return
}
const headers = ['ID', 'User ID', 'User Name', 'Points', 'Penalty Change', 'XP', 'Reason', 'Source Type', 'Source ID', 'Created At']
const rows = entries.value.map(e => [
e.id,
e.user_id,
`"${(e.user_name || '').replace(/"/g, '""')}"`,
e.points,
e.penalty_change || 0,
e.xp || 0,
`"${(e.reason || '').replace(/"/g, '""')}"`,
e.source_type || '',
e.source_id || '',
e.created_at || '',
])
const csvContent = [
headers.join(','),
...rows.map(row => row.join(',')),
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', `points_ledger_${new Date().toISOString().slice(0, 10)}.csv`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showToast(t('gamification.ledger.csv_started'))
}
onMounted(() => {
fetchLedger()
})
</script>
<style scoped>
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,296 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.levels.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.levels.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.levels.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.levels.load_error') }}</p>
<button @click="fetchLevels" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.levels.retry') }}</button>
</div>
<template v-else>
<!-- Create Button -->
<div class="mb-6 flex items-center justify-between">
<div></div>
<button @click="openCreateModal" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<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="M12 4v16m8-8H4" />
</svg>
{{ t('gamification.levels.create') }}
</button>
</div>
<!-- Levels Table -->
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden mb-8">
<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.levels.col_level') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.levels.col_rank') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.levels.col_min_points') }}</th>
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.levels.col_type') }}</th>
<th class="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.levels.col_actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="level in levels" :key="level.id" class="hover:bg-slate-700/30 transition">
<td class="px-4 py-3">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-500/10 text-indigo-400 text-sm font-bold">
{{ level.level_number }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-sm font-medium text-white">{{ level.rank_name }}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ level.min_points.toLocaleString() }}</td>
<td class="px-4 py-3">
<span v-if="level.is_penalty" 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">
{{ t('gamification.levels.penalty') }}
</span>
<span v-else class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
{{ t('gamification.levels.normal') }}
</span>
</td>
<td class="px-4 py-3 text-right">
<button @click="openEditModal(level)" 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">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{{ t('gamification.levels.edit') }}
</button>
</td>
</tr>
<tr v-if="levels.length === 0">
<td colspan="5" class="px-4 py-12 text-center text-slate-500 text-sm">
{{ t('gamification.levels.no_levels') }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Level Tree Diagram -->
<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.levels.tree_diagram') }}</h2>
<div v-if="levels.length === 0" class="text-sm text-slate-500 text-center py-8">
{{ t('gamification.levels.no_tree') }}
</div>
<div v-else class="flex flex-wrap gap-3">
<div
v-for="level in sortedLevels"
:key="level.id"
class="flex items-center gap-2 px-4 py-2 rounded-lg border transition"
:class="level.is_penalty ? 'bg-rose-500/5 border-rose-500/20' : 'bg-emerald-500/5 border-emerald-500/20'"
>
<span class="text-sm font-bold" :class="level.is_penalty ? 'text-rose-400' : 'text-emerald-400'">
Lv.{{ level.level_number }}
</span>
<span class="text-xs text-slate-400">{{ level.rank_name }}</span>
<span v-if="!level.is_penalty" class="text-xs text-slate-500">({{ level.min_points.toLocaleString() }} pts)</span>
</div>
</div>
</div>
</template>
<!-- Edit/Create Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="closeModal">
<div class="bg-slate-800 rounded-xl border border-slate-700 w-full max-w-md mx-4 p-6">
<h3 class="text-lg font-semibold text-white mb-6">
{{ editingLevel ? t('gamification.levels.edit_title') : t('gamification.levels.create_title') }}
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.levels.form_level_number') }}</label>
<input v-model.number="form.level_number" type="number" min="1" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50" />
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.levels.form_rank_name') }}</label>
<input v-model="form.rank_name" type="text" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50" />
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.levels.form_min_points') }}</label>
<input v-model.number="form.min_points" type="number" min="0" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50" />
</div>
<div class="flex items-center gap-2">
<input v-model="form.is_penalty" type="checkbox" id="is_penalty" class="rounded bg-slate-700 border-slate-600 text-indigo-600 focus:ring-indigo-500" />
<label for="is_penalty" class="text-sm text-slate-300">{{ t('gamification.levels.form_is_penalty') }}</label>
</div>
</div>
<div v-if="formError" class="mt-4 bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
<p class="text-sm text-rose-400">{{ formError }}</p>
</div>
<div class="flex items-center justify-end gap-3 mt-6">
<button @click="closeModal" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm font-medium rounded-lg transition">
{{ t('gamification.levels.cancel') }}
</button>
<button @click="saveLevel" :disabled="saving" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white text-sm font-medium rounded-lg transition">
{{ saving ? t('gamification.levels.saving') : t('gamification.levels.save') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface LevelConfig {
id: number
level_number: number
rank_name: string
min_points: number
is_penalty: boolean
}
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
const levels = ref<LevelConfig[]>([])
const showModal = ref(false)
const editingLevel = ref<LevelConfig | null>(null)
const formError = ref('')
const form = reactive({
level_number: 1,
rank_name: '',
min_points: 0,
is_penalty: false,
})
const sortedLevels = computed(() => {
return [...levels.value].sort((a, b) => a.level_number - b.level_number)
})
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }
}
function openCreateModal() {
editingLevel.value = null
form.level_number = levels.value.length + 1
form.rank_name = ''
form.min_points = 0
form.is_penalty = false
formError.value = ''
showModal.value = true
}
function openEditModal(level: LevelConfig) {
editingLevel.value = level
form.level_number = level.level_number
form.rank_name = level.rank_name
form.min_points = level.min_points
form.is_penalty = level.is_penalty
formError.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingLevel.value = null
}
async function fetchLevels() {
loading.value = true
error.value = false
try {
const data = await $fetch<LevelConfig[]>('/api/v1/admin/gamification/level-configs', { headers: getHeaders() })
levels.value = data || []
} catch (e) {
console.error('Failed to fetch level configs:', e)
error.value = true
} finally {
loading.value = false
}
}
async function saveLevel() {
saving.value = true
formError.value = ''
try {
const body = {
level_number: form.level_number,
rank_name: form.rank_name,
min_points: form.min_points,
is_penalty: form.is_penalty,
}
if (editingLevel.value) {
await $fetch(`/api/v1/admin/gamification/level-configs/${editingLevel.value.id}`, {
method: 'PUT',
headers: getHeaders(),
body,
})
showToast(t('gamification.levels.updated'))
} else {
await $fetch('/api/v1/admin/gamification/level-configs', {
method: 'POST',
headers: getHeaders(),
body,
})
showToast(t('gamification.levels.created'))
}
closeModal()
await fetchLevels()
} catch (e: any) {
if (e?.data?.detail?.includes('already exists')) {
formError.value = t('gamification.levels.duplicate_error')
} else {
formError.value = t('gamification.levels.save_error')
}
} finally {
saving.value = false
}
}
function showToast(msg: string) {
// Simple toast - could be enhanced
const toast = document.createElement('div')
toast.className = 'fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm animate-slide-up'
toast.textContent = msg
document.body.appendChild(toast)
setTimeout(() => toast.remove(), 3000)
}
onMounted(() => {
fetchLevels()
})
</script>
<style scoped>
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,285 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.params.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.params.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.params.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.params.load_error') }}</p>
<button @click="fetchParams" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.params.retry') }}</button>
</div>
<template v-else>
<!-- Params Table -->
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.params.col_key') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.params.col_value') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.params.col_category') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.params.col_scope') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.params.col_scope_id') }}</th>
<th class="text-right px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.params.col_actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="param in params" :key="param.key" class="hover:bg-slate-700/30 transition">
<td class="px-6 py-4">
<code class="text-sm font-mono text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded">{{ param.key }}</code>
</td>
<td class="px-6 py-4">
<span class="text-sm text-slate-300 max-w-xs truncate block font-mono" :title="formatValue(param.value)">
{{ truncateValue(formatValue(param.value), 60) }}
</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/10 text-slate-400">
{{ param.category || '—' }}
</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium" :class="scopeBadgeClass(param.scope_level)">
{{ param.scope_level }}
</span>
</td>
<td class="px-6 py-4 text-sm text-slate-400">{{ param.scope_id || '—' }}</td>
<td class="px-6 py-4 text-right">
<button @click="openEditModal(param)" class="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition" :title="t('gamification.params.edit')">
<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 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</td>
</tr>
<tr v-if="params.length === 0">
<td colspan="6" class="px-6 py-12 text-center text-sm text-slate-500">
{{ t('gamification.params.empty') }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeModal">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-lg mx-4 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-2">{{ t('gamification.params.edit_title') }}</h2>
<p class="text-sm text-slate-400 mb-6">
<code class="text-amber-300 font-mono text-xs">{{ editingParam?.key }}</code>
</p>
<form @submit.prevent="saveParam" class="space-y-4">
<!-- Value (JSON or text) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.params.value_label') }}</label>
<textarea
v-model="editValue"
rows="6"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white font-mono text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 resize-none"
spellcheck="false"
:placeholder="t('gamification.params.value_placeholder')"
></textarea>
<p v-if="isJsonValue" class="mt-1 text-xs text-emerald-400">{{ t('gamification.params.json_detected') }}</p>
<p v-else class="mt-1 text-xs text-slate-500">{{ t('gamification.params.text_value') }}</p>
</div>
<!-- Error Message -->
<div v-if="formError" class="bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
<p class="text-sm text-rose-400">{{ formError }}</p>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="closeModal" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">{{ t('gamification.params.cancel') }}</button>
<button type="submit" :disabled="saving" class="px-4 py-2 bg-amber-600 hover:bg-amber-500 disabled:bg-amber-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="saving" 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>
{{ saving ? t('gamification.params.saving') : t('gamification.params.save') }}
</button>
</div>
</form>
</div>
</div>
<!-- Success Toast -->
<div v-if="toast" class="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-up">
<svg class="w-4 h-4 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>
{{ toast }}
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface SystemParam {
key: string
value: any
category: string | null
scope_level: string
scope_id: number | null
}
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
const params = ref<SystemParam[]>([])
const showModal = ref(false)
const editingParam = ref<SystemParam | null>(null)
const editValue = ref('')
const formError = ref('')
const toast = ref('')
const isJsonValue = computed(() => {
if (!editValue.value) return false
const trimmed = editValue.value.trim()
return (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
})
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }
}
function showToast(msg: string) {
toast.value = msg
setTimeout(() => { toast.value = '' }, 3000)
}
function formatValue(value: any): string {
if (value === null || value === undefined) return '—'
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
return String(value)
}
function truncateValue(val: string, maxLen: number): string {
if (val.length <= maxLen) return val
return val.substring(0, maxLen) + '...'
}
function scopeBadgeClass(scope: string): string {
switch (scope?.toLowerCase()) {
case 'global': return 'bg-emerald-500/10 text-emerald-400'
case 'country': return 'bg-blue-500/10 text-blue-400'
case 'region': return 'bg-violet-500/10 text-violet-400'
case 'user': return 'bg-amber-500/10 text-amber-400'
default: return 'bg-slate-500/10 text-slate-400'
}
}
function openEditModal(param: SystemParam) {
editingParam.value = param
editValue.value = formatValue(param.value)
formError.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingParam.value = null
editValue.value = ''
formError.value = ''
}
async function fetchParams() {
loading.value = true
error.value = false
try {
const data = await $fetch<SystemParam[]>('/api/v1/admin/gamification/system-params', { headers: getHeaders() })
params.value = data
} catch (e) {
console.error('Failed to fetch system params:', e)
error.value = true
} finally {
loading.value = false
}
}
async function saveParam() {
if (!editingParam.value) return
saving.value = true
formError.value = ''
let parsedValue: any
try {
// Try to parse as JSON first; if fails, use as raw string
if (isJsonValue.value) {
parsedValue = JSON.parse(editValue.value)
} else {
// Try number conversion
const num = Number(editValue.value)
parsedValue = isNaN(num) ? editValue.value : num
}
} catch (e: any) {
formError.value = t('gamification.params.invalid_json') + ' ' + (e.message || '')
saving.value = false
return
}
try {
await $fetch(`/api/v1/admin/gamification/system-params/${editingParam.value.key}`, {
method: 'PUT',
headers: getHeaders(),
body: { value: parsedValue },
})
showToast(t('gamification.params.updated', { key: editingParam.value.key }))
closeModal()
await fetchParams()
} catch (e: any) {
formError.value = t('gamification.params.save_error')
console.error('Failed to save system param:', e)
} finally {
saving.value = false
}
}
onMounted(() => {
fetchParams()
})
</script>
<style scoped>
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,364 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('gamification.point_rules.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.point_rules.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.point_rules.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.point_rules.load_error') }}</p>
<button @click="fetchRules" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.point_rules.retry') }}</button>
</div>
<template v-else>
<!-- Create Button -->
<div class="mb-6 flex justify-end">
<button @click="openCreateModal" class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<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="M12 4v16m8-8H4" />
</svg>
{{ t('gamification.point_rules.create_btn') }}
</button>
</div>
<!-- Rules Table -->
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.point_rules.col_id') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.point_rules.col_action_key') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.point_rules.col_points') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.point_rules.col_description') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.point_rules.col_status') }}</th>
<th class="text-right px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.point_rules.col_actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="rule in rules" :key="rule.id" class="hover:bg-slate-700/30 transition">
<td class="px-6 py-4 text-sm text-slate-400">{{ rule.id }}</td>
<td class="px-6 py-4">
<code class="text-sm font-mono text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded">{{ rule.action_key }}</code>
</td>
<td class="px-6 py-4">
<span class="text-sm font-semibold" :class="rule.points >= 0 ? 'text-emerald-400' : 'text-rose-400'">
{{ rule.points >= 0 ? '+' : '' }}{{ rule.points }}
</span>
</td>
<td class="px-6 py-4 text-sm text-slate-300 max-w-xs truncate">{{ rule.description || '—' }}</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium" :class="rule.is_active ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-500/10 text-slate-400'">
{{ rule.is_active ? t('gamification.point_rules.active') : t('gamification.point_rules.inactive') }}
</span>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button @click="openEditModal(rule)" class="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition" :title="t('gamification.point_rules.edit')">
<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 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="confirmDelete(rule)" class="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 transition" :title="t('gamification.point_rules.delete_title')">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="rules.length === 0">
<td colspan="6" class="px-6 py-12 text-center text-sm text-slate-500">
{{ t('gamification.point_rules.empty') }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- Create/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeModal">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-lg mx-4 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-6">{{ editingRule ? t('gamification.point_rules.edit_title') : t('gamification.point_rules.create_title') }}</h2>
<form @submit.prevent="saveRule" class="space-y-4">
<!-- Action Key -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.point_rules.action_key_label') }}</label>
<input
v-model="form.action_key"
type="text"
required
maxlength="100"
:placeholder="t('gamification.point_rules.action_key_placeholder')"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
/>
</div>
<!-- Points -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.point_rules.points_label') }}</label>
<input
v-model.number="form.points"
type="number"
required
min="-10000"
max="10000"
placeholder="0"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
/>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.point_rules.description_label') }}</label>
<textarea
v-model="form.description"
rows="3"
:placeholder="t('gamification.point_rules.description_placeholder')"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm resize-none"
></textarea>
</div>
<!-- Is Active -->
<div class="flex items-center gap-3">
<input
v-model="form.is_active"
type="checkbox"
id="is_active"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 text-amber-500 focus:ring-amber-500/50"
/>
<label for="is_active" class="text-sm text-slate-300">{{ t('gamification.point_rules.active_label') }}</label>
</div>
<!-- Error Message -->
<div v-if="formError" class="bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
<p class="text-sm text-rose-400">{{ formError }}</p>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="closeModal" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">{{ t('gamification.point_rules.cancel') }}</button>
<button type="submit" :disabled="saving" class="px-4 py-2 bg-amber-600 hover:bg-amber-500 disabled:bg-amber-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="saving" 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>
{{ saving ? t('gamification.point_rules.saving') : t('gamification.point_rules.save') }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteModal = false">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-md mx-4 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-2">{{ t('gamification.point_rules.delete_confirm_title') }}</h2>
<p class="text-sm text-slate-400 mb-6">
{{ t('gamification.point_rules.delete_confirm_body', { action_key: deletingRule?.action_key }) }}
</p>
<div class="flex justify-end gap-3">
<button @click="showDeleteModal = false" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">{{ t('gamification.point_rules.cancel') }}</button>
<button @click="deleteRule" :disabled="saving" class="px-4 py-2 bg-rose-600 hover:bg-rose-500 disabled:bg-rose-600/50 text-white rounded-lg text-sm font-medium transition">
{{ saving ? t('gamification.point_rules.deleting') : t('gamification.point_rules.delete') }}
</button>
</div>
</div>
</div>
<!-- Success Toast -->
<div v-if="toast" class="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-up">
<svg class="w-4 h-4 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>
{{ toast }}
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface PointRule {
id: number
action_key: string
points: number
description: string | null
is_active: boolean
}
const loading = ref(true)
const error = ref(false)
const rules = ref<PointRule[]>([])
const showModal = ref(false)
const showDeleteModal = ref(false)
const editingRule = ref<PointRule | null>(null)
const deletingRule = ref<PointRule | null>(null)
const saving = ref(false)
const formError = ref('')
const toast = ref('')
const form = reactive({
action_key: '',
points: 0,
description: '',
is_active: true,
})
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }
}
function showToast(msg: string) {
toast.value = msg
setTimeout(() => { toast.value = '' }, 3000)
}
function resetForm() {
form.action_key = ''
form.points = 0
form.description = ''
form.is_active = true
formError.value = ''
}
function openCreateModal() {
editingRule.value = null
resetForm()
showModal.value = true
}
function openEditModal(rule: PointRule) {
editingRule.value = rule
form.action_key = rule.action_key
form.points = rule.points
form.description = rule.description || ''
form.is_active = rule.is_active
formError.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingRule.value = null
resetForm()
}
function confirmDelete(rule: PointRule) {
deletingRule.value = rule
showDeleteModal.value = true
}
async function fetchRules() {
loading.value = true
error.value = false
try {
const data = await $fetch<PointRule[]>('/api/v1/admin/gamification/point-rules', { headers: getHeaders() })
rules.value = data
} catch (e) {
console.error('Failed to fetch point rules:', e)
error.value = true
} finally {
loading.value = false
}
}
async function saveRule() {
saving.value = true
formError.value = ''
try {
const payload = {
action_key: form.action_key,
points: form.points,
description: form.description || null,
is_active: form.is_active,
}
if (editingRule.value) {
// Update existing rule
await $fetch(`/api/v1/admin/gamification/point-rules/${editingRule.value.id}`, {
method: 'PUT',
headers: getHeaders(),
body: payload,
})
showToast(t('gamification.point_rules.updated'))
} else {
// Create new rule
await $fetch('/api/v1/admin/gamification/point-rules', {
method: 'POST',
headers: getHeaders(),
body: payload,
})
showToast(t('gamification.point_rules.created'))
}
closeModal()
await fetchRules()
} catch (e: any) {
if (e?.response?.status === 409) {
formError.value = t('gamification.point_rules.duplicate_error')
} else {
formError.value = t('gamification.point_rules.save_error')
}
console.error('Failed to save point rule:', e)
} finally {
saving.value = false
}
}
async function deleteRule() {
if (!deletingRule.value) return
saving.value = true
try {
await $fetch(`/api/v1/admin/gamification/point-rules/${deletingRule.value.id}`, {
method: 'DELETE',
headers: getHeaders(),
})
showToast(t('gamification.point_rules.deleted'))
showDeleteModal.value = false
deletingRule.value = null
await fetchRules()
} catch (e) {
console.error('Failed to delete point rule:', e)
} finally {
saving.value = false
}
}
onMounted(() => {
fetchRules()
})
</script>
<style scoped>
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,407 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white">{{ t('gamification.seasons.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('gamification.seasons.subtitle') }}</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition"
@click="openCreateModal"
>
<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="M12 4v16m8-8H4" />
</svg>
{{ t('gamification.seasons.create_btn') }}
</button>
</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.seasons.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.seasons.load_error') }}</p>
<button class="mt-2 text-sm text-rose-300 hover:text-rose-200 underline" @click="fetchSeasons">{{ t('gamification.seasons.retry') }}</button>
</div>
<!-- Seasons List -->
<template v-else>
<!-- Empty State -->
<div v-if="seasons.length === 0" class="bg-slate-800 rounded-xl border border-slate-700 p-12 text-center">
<svg class="w-12 h-12 mx-auto text-slate-600 mb-4" 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>
<p class="text-slate-400">{{ t('gamification.seasons.empty') }}</p>
<button
class="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition"
@click="openCreateModal"
>
{{ t('gamification.seasons.create_first') }}
</button>
</div>
<!-- Season Cards -->
<div v-else class="space-y-4">
<div
v-for="season in seasons"
:key="season.id"
class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-slate-600 transition"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-lg font-semibold text-white">{{ season.name }}</h3>
<span
class="px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="season.is_active
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30'
: 'bg-slate-700 text-slate-400 border border-slate-600'"
>
{{ season.is_active ? t('gamification.seasons.active') : t('gamification.seasons.inactive') }}
</span>
</div>
<div class="flex items-center gap-6 text-sm text-slate-400">
<div class="flex items-center gap-2">
<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="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>
<span>{{ formatDate(season.start_date) }} {{ formatDate(season.end_date) }}</span>
</div>
<div class="flex items-center gap-2">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ t('gamification.seasons.created_at') }} {{ formatDateTime(season.created_at) }}</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 ml-4">
<button
v-if="!season.is_active"
class="p-2 rounded-lg text-emerald-400 hover:bg-emerald-500/10 transition"
:title="t('gamification.seasons.activate')"
@click="activateSeason(season)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
class="p-2 rounded-lg text-indigo-400 hover:bg-indigo-500/10 transition"
:title="t('gamification.seasons.edit')"
@click="openEditModal(season)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
<!-- Create/Edit Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div class="fixed inset-0 bg-black/60" @click="closeModal" />
<div class="relative bg-slate-800 rounded-xl border border-slate-700 w-full max-w-lg p-6 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-6">
{{ editingSeason ? t('gamification.seasons.edit_title') : t('gamification.seasons.create_title') }}
</h2>
<form @submit.prevent="saveSeason" class="space-y-4">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.seasons.name_label') }}</label>
<input
v-model="form.name"
type="text"
required
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:placeholder="t('gamification.seasons.name_placeholder')"
/>
</div>
<!-- Start Date -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.seasons.start_date_label') }}</label>
<input
v-model="form.start_date"
type="date"
required
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<!-- End Date -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('gamification.seasons.end_date_label') }}</label>
<input
v-model="form.end_date"
type="date"
required
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<!-- Active toggle (only for create) -->
<div v-if="!editingSeason" class="flex items-center gap-3">
<input
v-model="form.is_active"
type="checkbox"
id="is_active"
class="w-4 h-4 rounded border-slate-600 bg-slate-700 text-indigo-600 focus:ring-indigo-500"
/>
<label for="is_active" class="text-sm text-slate-300">{{ t('gamification.seasons.active_on_create') }}</label>
</div>
<!-- Error -->
<div v-if="formError" class="bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
<p class="text-sm text-rose-400">{{ formError }}</p>
</div>
<!-- Buttons -->
<div class="flex items-center justify-end gap-3 pt-2">
<button
type="button"
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
@click="closeModal"
>
{{ t('gamification.seasons.cancel') }}
</button>
<button
type="submit"
:disabled="saving"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white text-sm font-medium rounded-lg transition flex items-center gap-2"
>
<svg v-if="saving" 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>
{{ editingSeason ? t('gamification.seasons.save') : t('gamification.seasons.create') }}
</button>
</div>
</form>
</div>
</div>
<!-- Activate Confirmation Modal -->
<div
v-if="showActivateModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div class="fixed inset-0 bg-black/60" @click="showActivateModal = false" />
<div class="relative bg-slate-800 rounded-xl border border-slate-700 w-full max-w-md p-6 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-2">{{ t('gamification.seasons.activate_title') }}</h2>
<p class="text-slate-400 text-sm mb-6">
{{ t('gamification.seasons.activate_confirm', { name: activatingSeason?.name }) }}
</p>
<div class="flex items-center justify-end gap-3">
<button
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
@click="showActivateModal = false"
>
{{ t('gamification.seasons.cancel') }}
</button>
<button
:disabled="saving"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-600/50 text-white text-sm font-medium rounded-lg transition"
@click="confirmActivate"
>
{{ saving ? t('gamification.seasons.activating') : t('gamification.seasons.activate') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface Season {
id: number
name: string
start_date: string
end_date: string
is_active: boolean
created_at: string | null
}
interface SeasonForm {
name: string
start_date: string
end_date: string
is_active: boolean
}
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
const showModal = ref(false)
const showActivateModal = ref(false)
const seasons = ref<Season[]>([])
const editingSeason = ref<Season | null>(null)
const activatingSeason = ref<Season | null>(null)
const formError = ref('')
const form = reactive<SeasonForm>({
name: '',
start_date: '',
end_date: '',
is_active: false,
})
function getAuthHeaders() {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}` } : {}
}
function formatDate(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('hu-HU', { year: 'numeric', month: 'long', day: 'numeric' })
}
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('hu-HU', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function resetForm() {
form.name = ''
form.start_date = ''
form.end_date = ''
form.is_active = false
formError.value = ''
}
function openCreateModal() {
editingSeason.value = null
resetForm()
showModal.value = true
}
function openEditModal(season: Season) {
editingSeason.value = season
form.name = season.name
form.start_date = season.start_date
form.end_date = season.end_date
form.is_active = season.is_active
formError.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingSeason.value = null
resetForm()
}
function activateSeason(season: Season) {
activatingSeason.value = season
showActivateModal.value = true
}
async function fetchSeasons() {
loading.value = true
error.value = false
try {
const data = await $fetch('/api/v1/admin/gamification/seasons', {
headers: getAuthHeaders(),
})
seasons.value = data as Season[]
} catch (e) {
console.error('Failed to fetch seasons:', e)
error.value = true
} finally {
loading.value = false
}
}
async function saveSeason() {
saving.value = true
formError.value = ''
try {
if (editingSeason.value) {
// Update existing season
const payload: Record<string, any> = {}
if (form.name !== editingSeason.value.name) payload.name = form.name
if (form.start_date !== editingSeason.value.start_date) payload.start_date = form.start_date
if (form.end_date !== editingSeason.value.end_date) payload.end_date = form.end_date
if (Object.keys(payload).length > 0) {
await $fetch(`/api/v1/admin/gamification/seasons/${editingSeason.value.id}`, {
method: 'PUT',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: payload,
})
}
} else {
// Create new season
await $fetch('/api/v1/admin/gamification/seasons', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: {
name: form.name,
start_date: form.start_date,
end_date: form.end_date,
is_active: form.is_active,
},
})
}
closeModal()
await fetchSeasons()
} catch (e: any) {
console.error('Failed to save season:', e)
formError.value = e?.data?.detail || e?.message || t('gamification.seasons.save_error')
} finally {
saving.value = false
}
}
async function confirmActivate() {
if (!activatingSeason.value) return
saving.value = true
try {
await $fetch(`/api/v1/admin/gamification/seasons/${activatingSeason.value.id}/activate`, {
method: 'POST',
headers: getAuthHeaders(),
})
showActivateModal.value = false
activatingSeason.value = null
await fetchSeasons()
} catch (e: any) {
console.error('Failed to activate season:', e)
} finally {
saving.value = false
}
}
onMounted(() => {
fetchSeasons()
})
</script>

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>