gemification_admin bekötve
This commit is contained in:
365
frontend_admin/pages/gamification/badges.vue
Normal file
365
frontend_admin/pages/gamification/badges.vue
Normal 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>
|
||||
496
frontend_admin/pages/gamification/competitions.vue
Normal file
496
frontend_admin/pages/gamification/competitions.vue
Normal 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>
|
||||
545
frontend_admin/pages/gamification/config.vue
Normal file
545
frontend_admin/pages/gamification/config.vue
Normal 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>
|
||||
222
frontend_admin/pages/gamification/index.vue
Normal file
222
frontend_admin/pages/gamification/index.vue
Normal 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>
|
||||
400
frontend_admin/pages/gamification/leaderboard.vue
Normal file
400
frontend_admin/pages/gamification/leaderboard.vue
Normal 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>
|
||||
392
frontend_admin/pages/gamification/ledger.vue
Normal file
392
frontend_admin/pages/gamification/ledger.vue
Normal 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>
|
||||
296
frontend_admin/pages/gamification/levels.vue
Normal file
296
frontend_admin/pages/gamification/levels.vue
Normal 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>
|
||||
285
frontend_admin/pages/gamification/parameters.vue
Normal file
285
frontend_admin/pages/gamification/parameters.vue
Normal 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>
|
||||
364
frontend_admin/pages/gamification/point-rules.vue
Normal file
364
frontend_admin/pages/gamification/point-rules.vue
Normal 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>
|
||||
407
frontend_admin/pages/gamification/seasons.vue
Normal file
407
frontend_admin/pages/gamification/seasons.vue
Normal 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>
|
||||
392
frontend_admin/pages/gamification/users/[id].vue
Normal file
392
frontend_admin/pages/gamification/users/[id].vue
Normal file
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Back Button & Header -->
|
||||
<div class="mb-8">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-white transition mb-4"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ t('gamification.user_detail.back') }}
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-white">{{ t('gamification.user_detail.title') }}</h1>
|
||||
<p class="text-slate-400 mt-1">{{ t('gamification.user_detail.user_id') }} #{{ $route.params.id }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<div class="text-slate-400 flex items-center gap-3">
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>{{ t('gamification.user_detail.loading') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
|
||||
<p class="text-rose-400">{{ t('gamification.user_detail.load_error') }}</p>
|
||||
<button @click="fetchData" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.user_detail.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Not Found -->
|
||||
<div v-else-if="!stats" class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<p class="text-slate-400 text-center py-12">{{ t('gamification.user_detail.not_found') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_total_xp') }}</h3>
|
||||
<p class="text-2xl font-bold text-white">{{ formatNumber(stats.total_xp) }}</p>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_level') }}</h3>
|
||||
<p class="text-2xl font-bold text-indigo-400">{{ stats.current_level }}</p>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_total_points') }}</h3>
|
||||
<p class="text-2xl font-bold text-emerald-400">{{ formatNumber(stats.total_points) }}</p>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_social_points') }}</h3>
|
||||
<p class="text-2xl font-bold text-cyan-400">{{ formatNumber(stats.social_points) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Penalty & Activity Stats -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_penalty_points') }}</h3>
|
||||
<p class="text-2xl font-bold" :class="stats.penalty_points > 0 ? 'text-rose-400' : 'text-slate-400'">
|
||||
{{ stats.penalty_points > 0 ? `-${stats.penalty_points}` : '0' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_restriction') }}</h3>
|
||||
<p class="text-2xl font-bold" :class="stats.restriction_level > 0 ? 'text-amber-400' : 'text-slate-400'">
|
||||
{{ stats.restriction_level > 0 ? restrictionLabel(stats.restriction_level) : t('gamification.user_detail.none') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_penalty_quota') }}</h3>
|
||||
<p class="text-2xl font-bold text-slate-300">{{ stats.penalty_quota_remaining }}</p>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_banned') }}</h3>
|
||||
<p class="text-2xl font-bold" :class="stats.banned_until ? 'text-rose-400' : 'text-slate-400'">
|
||||
{{ stats.banned_until ? formatDate(stats.banned_until) : t('gamification.user_detail.no') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Stats -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_services') }}</h3>
|
||||
<p class="text-2xl font-bold text-white">{{ stats.services_submitted }}</p>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_discovered') }}</h3>
|
||||
<p class="text-2xl font-bold text-white">{{ stats.places_discovered }}</p>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_validated') }}</h3>
|
||||
<p class="text-2xl font-bold text-white">{{ stats.places_validated }}</p>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<h3 class="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{{ t('gamification.user_detail.stat_providers') }}</h3>
|
||||
<p class="text-2xl font-bold text-white">{{ stats.providers_added_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions: Penalty & Reward -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Penalty Form -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
{{ t('gamification.user_detail.penalty_title') }}
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.penalty_points_label') }}</label>
|
||||
<input v-model.number="penaltyForm.amount" type="number" min="1" max="10000" placeholder="1–10000" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-rose-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.penalty_reason_label') }}</label>
|
||||
<textarea v-model="penaltyForm.reason" rows="2" :placeholder="t('gamification.user_detail.penalty_reason_placeholder')" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-rose-500 focus:border-transparent resize-none"></textarea>
|
||||
</div>
|
||||
<button @click="applyPenalty" :disabled="penaltySubmitting || !penaltyForm.amount || !penaltyForm.reason" class="w-full px-4 py-2.5 bg-rose-600 hover:bg-rose-500 disabled:bg-rose-600/50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition flex items-center justify-center gap-2">
|
||||
<svg v-if="penaltySubmitting" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ penaltySubmitting ? t('gamification.user_detail.penalty_submitting') : t('gamification.user_detail.penalty_submit') }}
|
||||
</button>
|
||||
<p v-if="penaltyError" class="text-xs text-rose-400">{{ penaltyError }}</p>
|
||||
<p v-if="penaltySuccess" class="text-xs text-emerald-400">{{ penaltySuccess }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reward Form -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ t('gamification.user_detail.reward_title') }}
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.reward_xp_label') }}</label>
|
||||
<input v-model.number="rewardForm.xp_amount" type="number" min="0" max="100000" placeholder="0–100000" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-emerald-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.reward_social_label') }}</label>
|
||||
<input v-model.number="rewardForm.social_amount" type="number" min="0" max="100000" placeholder="0–100000" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-emerald-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.user_detail.reward_reason_label') }}</label>
|
||||
<textarea v-model="rewardForm.reason" rows="2" :placeholder="t('gamification.user_detail.reward_reason_placeholder')" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-emerald-500 focus:border-transparent resize-none"></textarea>
|
||||
</div>
|
||||
<button @click="applyReward" :disabled="rewardSubmitting || !rewardForm.xp_amount || !rewardForm.reason" class="w-full px-4 py-2.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-600/50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition flex items-center justify-center gap-2">
|
||||
<svg v-if="rewardSubmitting" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ rewardSubmitting ? t('gamification.user_detail.reward_submitting') : t('gamification.user_detail.reward_submit') }}
|
||||
</button>
|
||||
<p v-if="rewardError" class="text-xs text-rose-400">{{ rewardError }}</p>
|
||||
<p v-if="rewardSuccess" class="text-xs text-emerald-400">{{ rewardSuccess }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Points Ledger -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('gamification.user_detail.ledger_title') }}</h2>
|
||||
<span class="text-xs text-slate-500">{{ t('gamification.user_detail.ledger_count', { count: ledger.length }) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ledgerLoading" class="flex items-center justify-center py-8">
|
||||
<div class="text-slate-400 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>{{ t('gamification.user_detail.ledger_loading') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="ledger.length === 0" class="text-sm text-slate-500 text-center py-8">
|
||||
{{ t('gamification.user_detail.ledger_empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-700">
|
||||
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_date') }}</th>
|
||||
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_points') }}</th>
|
||||
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_penalty') }}</th>
|
||||
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_xp') }}</th>
|
||||
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_reason') }}</th>
|
||||
<th class="text-left px-3 py-2 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.user_detail.ledger_col_source') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-700/50">
|
||||
<tr v-for="entry in ledger" :key="entry.id" class="hover:bg-slate-700/30 transition">
|
||||
<td class="px-3 py-2.5 text-xs text-slate-400 whitespace-nowrap">{{ formatDateTime(entry.created_at) }}</td>
|
||||
<td class="px-3 py-2.5 text-sm font-medium" :class="entry.points >= 0 ? 'text-emerald-400' : 'text-rose-400'">
|
||||
{{ entry.points >= 0 ? '+' : '' }}{{ entry.points }}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-sm" :class="entry.penalty_change ? 'text-rose-400' : 'text-slate-500'">
|
||||
{{ entry.penalty_change ? `-${entry.penalty_change}` : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-sm text-slate-300">{{ entry.xp ?? '—' }}</td>
|
||||
<td class="px-3 py-2.5 text-sm text-slate-300 max-w-xs truncate">{{ entry.reason }}</td>
|
||||
<td class="px-3 py-2.5 text-xs text-slate-500">
|
||||
<span v-if="entry.source_type" class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-slate-700 text-slate-400">
|
||||
{{ entry.source_type }}{{ entry.source_id ? ` #${entry.source_id}` : '' }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface UserStats {
|
||||
user_id: number
|
||||
total_xp: number
|
||||
total_points: number
|
||||
social_points: number
|
||||
current_level: number
|
||||
penalty_points: number
|
||||
restriction_level: number
|
||||
penalty_quota_remaining: number
|
||||
places_discovered: number
|
||||
places_validated: number
|
||||
providers_added_count: number
|
||||
services_submitted: number
|
||||
banned_until: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface LedgerEntry {
|
||||
id: number
|
||||
user_id: number
|
||||
user_name: string
|
||||
points: number
|
||||
penalty_change: number | null
|
||||
reason: string
|
||||
xp: number | null
|
||||
source_type: string | null
|
||||
source_id: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const { formatNumber, formatDate, formatDateTime } = useFormatter()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const userId = computed(() => Number(route.params.id))
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const stats = ref<UserStats | null>(null)
|
||||
|
||||
const ledger = ref<LedgerEntry[]>([])
|
||||
const ledgerLoading = ref(false)
|
||||
|
||||
// Penalty form
|
||||
const penaltyForm = ref({ amount: 0, reason: '' })
|
||||
const penaltySubmitting = ref(false)
|
||||
const penaltyError = ref('')
|
||||
const penaltySuccess = ref('')
|
||||
|
||||
// Reward form
|
||||
const rewardForm = ref({ xp_amount: 0, social_amount: 0, reason: '' })
|
||||
const rewardSubmitting = ref(false)
|
||||
const rewardError = ref('')
|
||||
const rewardSuccess = ref('')
|
||||
|
||||
function restrictionLabel(level: number): string {
|
||||
switch (level) {
|
||||
case 1: return t('gamification.user_detail.restriction_mild')
|
||||
case 2: return t('gamification.user_detail.restriction_moderate')
|
||||
case 3: return t('gamification.user_detail.restriction_severe')
|
||||
default: return `${level}. ${t('gamification.user_detail.restriction_level')}`
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/gamification/users')
|
||||
}
|
||||
|
||||
function getHeaders() {
|
||||
const tokenCookie = useCookie('access_token')
|
||||
const token = tokenCookie.value
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const headers = getHeaders()
|
||||
const data = await $fetch<UserStats>(`/api/v1/admin/gamification/user-stats/${userId.value}`, { headers })
|
||||
stats.value = data
|
||||
await fetchLedger()
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load user stats:', e)
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLedger() {
|
||||
ledgerLoading.value = true
|
||||
try {
|
||||
const headers = getHeaders()
|
||||
const data = await $fetch<LedgerEntry[]>(`/api/v1/admin/gamification/points-ledger?user_id=${userId.value}&limit=50`, { headers })
|
||||
ledger.value = data
|
||||
} catch (e) {
|
||||
console.error('Failed to load ledger:', e)
|
||||
ledger.value = []
|
||||
} finally {
|
||||
ledgerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPenalty() {
|
||||
if (!penaltyForm.value.amount || !penaltyForm.value.reason) return
|
||||
penaltySubmitting.value = true
|
||||
penaltyError.value = ''
|
||||
penaltySuccess.value = ''
|
||||
try {
|
||||
const headers = getHeaders()
|
||||
await $fetch(`/api/v1/admin/gamification/user-stats/${userId.value}/penalty`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: {
|
||||
amount: penaltyForm.value.amount,
|
||||
reason: penaltyForm.value.reason,
|
||||
},
|
||||
})
|
||||
penaltySuccess.value = t('gamification.user_detail.penalty_success', { amount: penaltyForm.value.amount })
|
||||
penaltyForm.value = { amount: 0, reason: '' }
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
penaltyError.value = e?.data?.detail || e?.message || t('gamification.user_detail.penalty_error')
|
||||
} finally {
|
||||
penaltySubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyReward() {
|
||||
if (!rewardForm.value.xp_amount || !rewardForm.value.reason) return
|
||||
rewardSubmitting.value = true
|
||||
rewardError.value = ''
|
||||
rewardSuccess.value = ''
|
||||
try {
|
||||
const headers = getHeaders()
|
||||
await $fetch(`/api/v1/admin/gamification/user-stats/${userId.value}/reward`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: {
|
||||
xp_amount: rewardForm.value.xp_amount,
|
||||
social_amount: rewardForm.value.social_amount || 0,
|
||||
reason: rewardForm.value.reason,
|
||||
},
|
||||
})
|
||||
rewardSuccess.value = t('gamification.user_detail.reward_success', { xp: rewardForm.value.xp_amount, social: rewardForm.value.social_amount || 0 })
|
||||
rewardForm.value = { xp_amount: 0, social_amount: 0, reason: '' }
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
rewardError.value = e?.data?.detail || e?.message || t('gamification.user_detail.reward_error')
|
||||
} finally {
|
||||
rewardSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
252
frontend_admin/pages/gamification/users/index.vue
Normal file
252
frontend_admin/pages/gamification/users/index.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">{{ t('gamification.users.title') }}</h1>
|
||||
<p class="text-slate-400 mt-1">{{ t('gamification.users.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Level Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_level') }}</label>
|
||||
<select v-model="filters.level" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent">
|
||||
<option value="">{{ t('gamification.users.all_levels') }}</option>
|
||||
<option v-for="lvl in 20" :key="lvl" :value="lvl">{{ lvl }}. {{ t('gamification.users.level_suffix') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Min XP -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_min_xp') }}</label>
|
||||
<input v-model.number="filters.min_xp" type="number" min="0" placeholder="0" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- Max XP -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_max_xp') }}</label>
|
||||
<input v-model.number="filters.max_xp" type="number" min="0" placeholder="100000" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- Has Penalty -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">{{ t('gamification.users.filter_penalty') }}</label>
|
||||
<select v-model="filters.has_penalty" class="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent">
|
||||
<option value="">{{ t('gamification.users.penalty_any') }}</option>
|
||||
<option :value="true">{{ t('gamification.users.penalty_only') }}</option>
|
||||
<option :value="false">{{ t('gamification.users.penalty_none') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-3 mt-4 pt-4 border-t border-slate-700/50">
|
||||
<button @click="applyFilters" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition">
|
||||
{{ t('gamification.users.filter_apply') }}
|
||||
</button>
|
||||
<button @click="resetFilters" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm font-medium rounded-lg transition">
|
||||
{{ t('gamification.users.filter_reset') }}
|
||||
</button>
|
||||
<span class="text-xs text-slate-500 ml-auto">{{ t('gamification.users.total_count', { count: totalCount }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<div class="text-slate-400 flex items-center gap-3">
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>{{ t('gamification.users.loading') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
|
||||
<p class="text-rose-400">{{ t('gamification.users.load_error') }}</p>
|
||||
<button @click="fetchUsers" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('gamification.users.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="users.length === 0" class="bg-slate-800 rounded-xl border border-slate-700 p-6">
|
||||
<p class="text-slate-400 text-center py-12">{{ t('gamification.users.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div v-else class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-700">
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_user_id') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_xp') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_level') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_points') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_penalty') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_restriction') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_services') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_discoveries') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_updated') }}</th>
|
||||
<th class="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('gamification.users.col_actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-700/50">
|
||||
<tr v-for="user in users" :key="user.user_id" class="hover:bg-slate-700/30 transition">
|
||||
<td class="px-4 py-3 text-sm text-white font-mono">#{{ user.user_id }}</td>
|
||||
<td class="px-4 py-3 text-sm text-white">{{ formatNumber(user.total_xp) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
|
||||
{{ user.current_level }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-300">{{ formatNumber(user.total_points) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span v-if="user.penalty_points > 0" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||
-{{ user.penalty_points }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-slate-500">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span v-if="user.restriction_level > 0" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
{{ restrictionLabel(user.restriction_level) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-slate-500">{{ t('gamification.users.none') }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-300">{{ user.services_submitted }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-300">{{ user.places_discovered }}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-500">{{ user.updated_at ? formatDate(user.updated_at) : '—' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<NuxtLink :to="`/gamification/users/${user.user_id}`" class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600/10 hover:bg-indigo-600/20 text-indigo-400 text-xs font-medium rounded-lg transition">
|
||||
{{ t('gamification.users.details') }}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-slate-700">
|
||||
<button @click="prevPage" :disabled="offset === 0" class="px-3 py-1.5 text-sm rounded-lg transition" :class="offset === 0 ? 'text-slate-600 cursor-not-allowed' : 'text-slate-300 hover:bg-slate-700'">
|
||||
{{ t('gamification.users.prev') }}
|
||||
</button>
|
||||
<span class="text-sm text-slate-500">{{ offset + 1 }}–{{ Math.min(offset + limit, totalCount) }} / {{ totalCount }}</span>
|
||||
<button @click="nextPage" :disabled="offset + limit >= totalCount" class="px-3 py-1.5 text-sm rounded-lg transition" :class="offset + limit >= totalCount ? 'text-slate-600 cursor-not-allowed' : 'text-slate-300 hover:bg-slate-700'">
|
||||
{{ t('gamification.users.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface UserStats {
|
||||
user_id: number
|
||||
total_xp: number
|
||||
total_points: number
|
||||
social_points: number
|
||||
current_level: number
|
||||
penalty_points: number
|
||||
restriction_level: number
|
||||
penalty_quota_remaining: number
|
||||
places_discovered: number
|
||||
places_validated: number
|
||||
providers_added_count: number
|
||||
services_submitted: number
|
||||
banned_until: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
const { formatNumber, formatDate } = useFormatter()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const users = ref<UserStats[]>([])
|
||||
const totalCount = ref(0)
|
||||
const offset = ref(0)
|
||||
const limit = ref(50)
|
||||
|
||||
const filters = ref({
|
||||
level: '' as number | string,
|
||||
min_xp: null as number | null,
|
||||
max_xp: null as number | null,
|
||||
has_penalty: '' as boolean | string,
|
||||
})
|
||||
|
||||
function restrictionLabel(level: number): string {
|
||||
switch (level) {
|
||||
case 1: return t('gamification.users.restriction_mild')
|
||||
case 2: return t('gamification.users.restriction_moderate')
|
||||
case 3: return t('gamification.users.restriction_severe')
|
||||
default: return `${level}. ${t('gamification.users.restriction_level')}`
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryParams(): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(limit.value))
|
||||
params.set('offset', String(offset.value))
|
||||
if (filters.value.level !== '') params.set('level', String(filters.value.level))
|
||||
if (filters.value.min_xp !== null) params.set('min_xp', String(filters.value.min_xp))
|
||||
if (filters.value.max_xp !== null) params.set('max_xp', String(filters.value.max_xp))
|
||||
if (filters.value.has_penalty !== '') params.set('has_penalty', String(filters.value.has_penalty))
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const tokenCookie = useCookie('access_token')
|
||||
const token = tokenCookie.value
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
||||
|
||||
const data = await $fetch<UserStats[]>(`/api/v1/admin/gamification/user-stats?${buildQueryParams()}`, { headers })
|
||||
users.value = data
|
||||
totalCount.value = data.length < limit.value ? offset.value + data.length : offset.value + limit.value + 1
|
||||
} catch (e) {
|
||||
console.error('Failed to load user stats:', e)
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
offset.value = 0
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = { level: '', min_xp: null, max_xp: null, has_penalty: '' }
|
||||
offset.value = 0
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (offset.value > 0) {
|
||||
offset.value = Math.max(0, offset.value - limit.value)
|
||||
fetchUsers()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
offset.value += limit.value
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user