felhasználói felületre pü

This commit is contained in:
Roo
2026-07-27 08:39:18 +00:00
parent c9118bf52f
commit 6f28d3e70d
72 changed files with 6311 additions and 749 deletions

View File

@@ -0,0 +1,387 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('financial_ledger.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('financial_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('financial_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('financial_ledger.error') }}</p>
<button @click="fetchLedger" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">
{{ t('financial_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('financial_ledger.user_id') }}
</label>
<input
v-model.number="filters.user_id"
type="number"
min="1"
placeholder="User ID"
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>
<!-- Transaction Type -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.transaction_type') }}
</label>
<input
v-model="filters.transaction_type"
type="text"
placeholder="pl. ACCOUNT_SUSPENDED..."
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>
<!-- Entry Type -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.entry_type') }}
</label>
<select
v-model="filters.entry_type"
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="fetchLedger"
>
<option value="">{{ t('financial_ledger.all_types') }}</option>
<option value="DEBIT">{{ tEnum('entry_type', 'DEBIT') }}</option>
<option value="CREDIT">{{ tEnum('entry_type', 'CREDIT') }}</option>
</select>
</div>
<!-- Wallet Type -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.wallet_type') }}
</label>
<select
v-model="filters.wallet_type"
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="fetchLedger"
>
<option value="">{{ t('financial_ledger.all_wallets') }}</option>
<option value="EARNED">{{ tEnum('wallet_type', 'EARNED') }}</option>
<option value="PURCHASED">{{ tEnum('wallet_type', 'PURCHASED') }}</option>
<option value="SERVICE_COINS">{{ tEnum('wallet_type', 'SERVICE_COINS') }}</option>
<option value="VOUCHER">{{ tEnum('wallet_type', 'VOUCHER') }}</option>
</select>
</div>
<!-- Date From -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.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="fetchLedger"
/>
</div>
</div>
<!-- Second Row: Date To + Actions -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mt-4">
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.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="fetchLedger"
/>
</div>
<!-- Actions -->
<div class="flex items-end gap-2">
<button
@click="clearFilters"
class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 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="M6 18L18 6M6 6l12 12" />
</svg>
{{ t('financial_ledger.clear') }}
</button>
</div>
</div>
</div>
<!-- Table -->
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-slate-700/50">
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">ID</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.user') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.amount') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.transaction_type') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.entry_type') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.wallet_type') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.status') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.created_at') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="items.length === 0">
<td colspan="8" class="px-4 py-12 text-center text-slate-500">
<p>{{ t('financial_ledger.no_results') }}</p>
<p class="text-sm mt-1">{{ t('financial_ledger.no_results_hint') }}</p>
</td>
</tr>
<tr
v-for="entry in items"
:key="entry.id"
@click="selectedEntry = selectedEntry?.id === entry.id ? null : entry"
class="border-t border-slate-700 hover:bg-slate-700/30 transition cursor-pointer"
>
<td class="px-4 py-3 text-sm text-slate-300 font-mono">{{ entry.id }}</td>
<td class="px-4 py-3 text-sm">
<div class="text-white font-medium">{{ entry.user_name || '-' }}</div>
<div class="text-xs text-slate-500">{{ entry.user_email || '-' }}</div>
</td>
<td class="px-4 py-3 text-sm text-right">
<span
class="font-mono font-medium"
:class="entry.entry_type === 'CREDIT' ? 'text-emerald-400' : 'text-rose-400'"
>
{{ entry.entry_type === 'CREDIT' ? '+' : '-' }}{{ formatAmount(entry.amount) }}
</span>
</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ tEnum('transaction_type', entry.transaction_type) }}</td>
<td class="px-4 py-3">
<span
class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full"
:class="entry.entry_type === 'CREDIT' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'"
>
{{ tEnum('entry_type', entry.entry_type) }}
</span>
</td>
<td class="px-4 py-3">
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-indigo-500/10 text-indigo-400">
{{ tEnum('wallet_type', entry.wallet_type) }}
</span>
</td>
<td class="px-4 py-3">
<span
class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full"
:class="statusClass(entry.status)"
>
{{ tEnum('status', entry.status) }}
</span>
</td>
<td class="px-4 py-3 text-sm text-slate-400">{{ formatDate(entry.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Expanded Details -->
<div v-if="selectedEntry" class="border-t border-slate-700 bg-slate-750 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">{{ t('financial_ledger.details') }}</h3>
<pre class="text-xs text-slate-400 font-mono bg-slate-900/50 rounded-lg p-3 overflow-x-auto max-h-48 overflow-y-auto">{{ JSON.stringify(selectedEntry.details, null, 2) }}</pre>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 border-t border-slate-700 bg-slate-800/50">
<div class="text-sm text-slate-400">
{{ t('financial_ledger.page_info', { page: currentPage, total: totalPages, count: total }) }}
</div>
<div class="flex items-center gap-2">
<button
:disabled="currentPage <= 1"
:class="['px-3 py-1.5 text-sm rounded-lg transition', currentPage <= 1 ? 'bg-slate-700 text-slate-500 cursor-not-allowed' : 'bg-slate-700 hover:bg-slate-600 text-slate-300']"
@click="goToPage(currentPage - 1)"
>
{{ t('financial_ledger.prev') }}
</button>
<span class="text-sm text-slate-400 px-2">{{ currentPage }} / {{ totalPages }}</span>
<button
:disabled="currentPage >= totalPages"
:class="['px-3 py-1.5 text-sm rounded-lg transition', currentPage >= totalPages ? 'bg-slate-700 text-slate-500 cursor-not-allowed' : 'bg-slate-700 hover:bg-slate-600 text-slate-300']"
@click="goToPage(currentPage + 1)"
>
{{ t('financial_ledger.next') }}
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCookie } from '#app'
const { t, te } = useI18n()
/**
* Translate enum values from the backend (DB enums) to the current locale.
* Falls back to the raw value if no translation key exists.
*/
function tEnum(category: string, value: string | null | undefined): string {
if (!value) return '-'
const key = `enums.${category}.${value}`
if (te(key)) return t(key)
// Try lowercase fallback for uppercase values
const lowerKey = `enums.${category}.${value.toLowerCase()}`
if (te(lowerKey)) return t(lowerKey)
return value
}
interface LedgerEntry {
id: number
user_id: number | null
person_id: number | null
amount: number
currency: string | null
transaction_type: string | null
entry_type: string | null
wallet_type: string | null
status: string | null
details: Record<string, unknown> | null
created_at: string | null
user_email: string | null
user_name: string | null
}
interface LedgerResponse {
total: number
page: number
page_size: number
items: LedgerEntry[]
}
const loading = ref(true)
const error = ref(false)
const items = ref<LedgerEntry[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(50)
const selectedEntry = ref<LedgerEntry | null>(null)
const filters = ref({
user_id: null as number | null,
transaction_type: '',
entry_type: '',
wallet_type: '',
date_from: '',
date_to: '',
})
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
return tokenCookie.value
? { Authorization: `Bearer ${tokenCookie.value}` }
: {}
}
function formatAmount(val: number): string {
return val.toLocaleString('hu-HU', { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
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 statusClass(status: string | null): string {
const s = (status || '').toLowerCase()
if (s === 'completed' || s === 'success') return 'bg-emerald-500/10 text-emerald-400'
if (s === 'pending') return 'bg-amber-500/10 text-amber-400'
if (s === 'failed') return 'bg-rose-500/10 text-rose-400'
if (s === 'refunded' || s === 'refund') return 'bg-purple-500/10 text-purple-400'
if (s === 'cancelled') return 'bg-slate-500/10 text-slate-400'
return 'bg-slate-500/10 text-slate-400'
}
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function debouncedSearch() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
currentPage.value = 1
fetchLedger()
}, 400)
}
function clearFilters() {
filters.value = { user_id: null, transaction_type: '', entry_type: '', wallet_type: '', date_from: '', date_to: '' }
currentPage.value = 1
fetchLedger()
}
function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
fetchLedger()
}
async function fetchLedger() {
loading.value = true
error.value = false
selectedEntry.value = null
try {
const params: Record<string, string | number> = {
page: currentPage.value,
page_size: pageSize.value,
}
if (filters.value.user_id) params.user_id = String(filters.value.user_id)
if (filters.value.transaction_type) params.transaction_type = filters.value.transaction_type
if (filters.value.entry_type) params.entry_type = filters.value.entry_type
if (filters.value.wallet_type) params.wallet_type = filters.value.wallet_type
if (filters.value.date_from) params.date_from = filters.value.date_from
if (filters.value.date_to) params.date_to = filters.value.date_to
const data: LedgerResponse = await $fetch('/api/v1/admin/finance/ledger', {
headers: getHeaders(),
params,
})
items.value = data.items
total.value = data.total
} catch (err) {
console.error('Failed to fetch financial ledger:', err)
error.value = true
} finally {
loading.value = false
}
}
onMounted(() => {
fetchLedger()
})
</script>