Files
service-finder/frontend/src/components/dashboard/WalletDetailsModal.vue

476 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
@click.self="$emit('close')"
>
<div class="bg-white w-11/12 max-w-3xl max-h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
<!-- Close button -->
<button
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
@click="$emit('close')"
>
</button>
<!-- Header -->
<div class="h-16 bg-slate-700 w-full shrink-0 flex items-center px-6">
<span class="text-white font-bold text-lg tracking-wide">💰 {{ t('finance.walletDetails') }}</span>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-6 text-slate-800">
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-20"
>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div>
<!-- Error State -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-12 text-center"
>
<svg class="w-12 h-12 text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-sm text-slate-500">{{ error }}</p>
<button
@click="retry"
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
>
{{ t('finance.retry') }}
</button>
</div>
<!-- Wallet Content -->
<div v-else class="space-y-6">
<!--
Section 1: 4-Pocket Overview
-->
<div>
<h3 class="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
{{ t('finance.walletBreakdown') }}
</h3>
<div class="grid grid-cols-2 gap-3">
<!-- Purchased -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
💳 {{ t('finance.purchased') }}
</span>
<span class="text-xs text-slate-400">{{ t('finance.topUpWallet') }}</span>
</div>
<p class="text-2xl font-extrabold text-slate-800">{{ formatCredits(purchasedCredits) }}</p>
</div>
<!-- Earned -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
🏆 {{ t('finance.earned') }}
</span>
<span class="text-xs text-slate-400">{{ t('finance.referralRewards') }}</span>
</div>
<p class="text-2xl font-extrabold text-emerald-600">{{ formatCredits(earnedCredits) }}</p>
</div>
<!-- Service Coins -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
🪙 {{ t('finance.bonus') }}
</span>
<span class="text-xs text-slate-400">{{ t('finance.rewards') }}</span>
</div>
<p class="text-2xl font-extrabold text-amber-600">{{ formatCredits(serviceCoins) }}</p>
</div>
<!-- Voucher -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
🎟 {{ t('finance.voucher') }}
</span>
<span class="text-xs text-slate-400">{{ t('finance.activeVouchers') }}</span>
</div>
<p class="text-2xl font-extrabold text-purple-600">{{ formatCredits(voucherBalance) }}</p>
</div>
</div>
<!-- Total Balance Bar -->
<div class="mt-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 p-4 text-white">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold opacity-90">{{ t('finance.totalBalance') }}</span>
<span class="text-2xl font-extrabold">{{ formatCredits(totalCredits) }}</span>
</div>
</div>
</div>
<!--
Section 2: Action Buttons
-->
<div class="flex gap-3">
<button
@click="handleTopUp"
class="flex-1 px-6 py-3 bg-gradient-to-r from-sf-accent to-emerald-500 hover:from-sf-accent/90 hover:to-emerald-600 text-white rounded-2xl font-medium text-sm transition-all shadow-md hover:shadow-lg active:scale-[0.98]"
>
{{ t('finance.topUp') }}
</button>
<button
disabled
class="flex-1 px-6 py-3 rounded-2xl font-medium text-sm bg-slate-200 text-slate-400 cursor-not-allowed opacity-50 shadow-md"
:title="t('finance.payoutComingSoon')"
>
💸 {{ t('finance.requestPayout') }}
</button>
</div>
<!--
Section 3: Transaction History
-->
<div>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-slate-500 uppercase tracking-wider">
📋 {{ t('finance.transactionHistory') }}
</h3>
<!-- Filter chips -->
<div class="flex gap-1.5">
<button
v-for="filter in availableFilters"
:key="filter.value"
@click="activeFilter = filter.value; fetchTransactions()"
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors"
:class="activeFilter === filter.value
? 'bg-sf-accent text-white'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'"
>
{{ filter.label }}
</button>
</div>
</div>
<!-- Transaction List -->
<div class="space-y-2">
<!-- Loading -->
<div
v-if="txLoading"
class="flex items-center justify-center py-8"
>
<div class="h-6 w-6 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div>
<!-- Empty -->
<div
v-else-if="transactions.length === 0"
class="flex flex-col items-center justify-center py-8 text-slate-400"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-sm">{{ t('finance.noTransactions') }}</p>
</div>
<!-- Transaction Items -->
<template v-else>
<div
v-for="tx in transactions"
:key="tx.id"
class="flex items-center justify-between rounded-xl border border-slate-100 bg-white p-3.5 hover:bg-slate-50 transition-colors"
>
<div class="flex items-start gap-3 min-w-0">
<!-- Icon -->
<div
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
:class="tx.entry_type === 'CREDIT' ? 'bg-emerald-100' : 'bg-red-100'"
>
<span class="text-sm">{{ tx.entry_type === 'CREDIT' ? '⬆' : '⬇' }}</span>
</div>
<!-- Details -->
<div class="min-w-0">
<p class="text-sm font-medium text-slate-800 truncate">
{{ tx.description || tx.transaction_type || t('finance.transaction') }}
</p>
<p class="text-xs text-slate-400 mt-0.5">
{{ formatDate(tx.created_at) }}
</p>
<div class="flex gap-2 mt-1">
<span
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
:class="walletTypeBadge(tx.wallet_type)"
>
{{ tx.wallet_type || '—' }}
</span>
<span
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
:class="tx.status === 'SUCCESS' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'"
>
{{ tx.status || '—' }}
</span>
</div>
</div>
</div>
<!-- Amount -->
<div class="shrink-0 text-right ml-4">
<p
class="text-sm font-bold"
:class="tx.entry_type === 'CREDIT' ? 'text-emerald-600' : 'text-red-600'"
>
{{ tx.entry_type === 'CREDIT' ? '+' : '-' }}{{ formatCredits(tx.amount) }}
</p>
<p class="text-[10px] text-slate-400 mt-0.5 font-mono">
#{{ String(tx.id).slice(0, 8) }}
</p>
</div>
</div>
<!-- Pagination -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between pt-2"
>
<button
@click="prevPage"
:disabled="currentPage <= 1"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="currentPage > 1
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
>
{{ t('finance.prev') }}
</button>
<span class="text-xs text-slate-400">
{{ currentPage }} / {{ totalPages }}
</span>
<button
@click="nextPage"
:disabled="currentPage >= totalPages"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="currentPage < totalPages
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
>
{{ t('finance.next') }}
</button>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFinanceStore } from '../../stores/finance'
import api from '../../api/axios'
const { t } = useI18n()
const financeStore = useFinanceStore()
// ── Props ──
const props = defineProps<{
isOpen: boolean
}>()
const emit = defineEmits<{
close: []
}>()
// ── Wallet balance from store ──
const isLoading = computed(() => financeStore.isLoading)
const error = computed(() => financeStore.error)
const totalCredits = computed(() => financeStore.totalCredits)
const purchasedCredits = computed(() => financeStore.purchasedCredits)
const serviceCoins = computed(() => financeStore.serviceCoins)
const earnedCredits = computed(() => financeStore.earnedCredits)
const voucherBalance = computed(() => financeStore.voucherBalance)
// ── Transaction state ──
const transactions = ref<any[]>([])
const txLoading = ref(false)
const currentPage = ref(1)
const totalPages = ref(1)
const totalCount = ref(0)
const activeFilter = ref<string | null>(null)
const availableFilters = [
{ value: null, label: t('finance.all') },
{ value: 'PURCHASED', label: t('finance.purchased') },
{ value: 'EARNED', label: t('finance.earned') },
{ value: 'SERVICE_COINS', label: t('finance.bonus') },
]
const pageSize = 20
// ── Helpers ──
function formatCredits(value: number): string {
if (Number.isInteger(value)) {
return value.toLocaleString()
}
return value.toFixed(2)
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function walletTypeBadge(type: string | null): string {
switch (type) {
case 'PURCHASED': return 'bg-blue-100 text-blue-700'
case 'EARNED': return 'bg-emerald-100 text-emerald-700'
case 'SERVICE_COINS': return 'bg-amber-100 text-amber-700'
case 'VOUCHER': return 'bg-purple-100 text-purple-700'
default: return 'bg-slate-100 text-slate-600'
}
}
// ── Actions ──
async function fetchTransactions() {
txLoading.value = true
try {
const params: any = {
page: currentPage.value,
page_size: pageSize,
}
if (activeFilter.value) {
params.wallet_type = activeFilter.value
}
const res = await api.get('/billing/wallet/transactions', { params })
const data = res.data
transactions.value = data.data || []
totalCount.value = data.pagination?.total_count || 0
totalPages.value = data.pagination?.total_pages || 1
} catch (err: any) {
console.error('[WalletDetailsModal] fetchTransactions error:', err)
transactions.value = []
} finally {
txLoading.value = false
}
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
fetchTransactions()
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
fetchTransactions()
}
}
function handleTopUp() {
const toast = document.createElement('div')
toast.className =
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
toast.textContent = '🔔 Fizetési kapu hamarosan elérhető!'
document.body.appendChild(toast)
setTimeout(() => {
toast.classList.add('animate-slide-out')
setTimeout(() => toast.remove(), 300)
}, 3000)
}
function handleWithdraw() {
if (earnedCredits.value <= 0) return
const toast = document.createElement('div')
toast.className =
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
toast.textContent = `💸 Kifizetési kérelem: ${formatCredits(earnedCredits.value)} egység — hamarosan elérhető!`
document.body.appendChild(toast)
setTimeout(() => {
toast.classList.add('animate-slide-out')
setTimeout(() => toast.remove(), 300)
}, 3000)
}
function retry() {
financeStore.fetchWalletBalance()
fetchTransactions()
}
// ── Lifecycle ──
watch(() => props.isOpen, (open) => {
if (open) {
financeStore.fetchWalletBalance()
currentPage.value = 1
fetchTransactions()
}
})
onMounted(() => {
if (props.isOpen) {
financeStore.fetchWalletBalance()
fetchTransactions()
}
})
</script>
<style scoped>
@keyframes flipIn {
from {
transform: perspective(1200px) rotateY(-90deg);
opacity: 0;
}
to {
transform: perspective(1200px) rotateY(0deg);
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.animate-flip-in {
animation: flipIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
}
.animate-slide-in {
animation: slideIn 0.3s ease-out forwards;
}
.animate-slide-out {
animation: slideOut 0.3s ease-in forwards;
}
</style>