Files
2026-06-30 08:56:55 +00:00

297 lines
12 KiB
Vue

<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>