gemification_admin bekötve
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user