388 lines
16 KiB
Vue
388 lines
16 KiB
Vue
<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>
|