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