297 lines
12 KiB
Vue
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>
|