felhasználói felületre pü
This commit is contained in:
387
frontend_admin/pages/finance/ledger.vue
Normal file
387
frontend_admin/pages/finance/ledger.vue
Normal 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>
|
||||
Reference in New Issue
Block a user