felhasználói felületre pü
This commit is contained in:
@@ -969,22 +969,130 @@ function resetForm() {
|
||||
}
|
||||
|
||||
// ── Edit Cost Hydration ──
|
||||
/**
|
||||
* P0 BUGFIX (2026-07-26): Complete rewrite of edit hydration to fix 5 bugs:
|
||||
*
|
||||
* BUG #1 (CRITICAL): Category hierarchy collapse — correctly splits category_id
|
||||
* into mainCategory (parent) and subCategory (leaf) using parent_category_id
|
||||
* from the enriched backend response.
|
||||
*
|
||||
* BUG #2 (HIGH): Vehicle not bound when not in store — explicitly sets
|
||||
* selectedVehicleId from cost.asset_id when props.vehicle is null.
|
||||
*
|
||||
* BUG #3 (HIGH): Provider not hydrated — constructs ProviderSearchResult from
|
||||
* cost.service_provider_id + cost.service_provider_name, or uses
|
||||
* cost.external_vendor_name as fallback.
|
||||
*
|
||||
* BUG #4 (MEDIUM): liters null-check — defensive guard on cost.data?.liters.
|
||||
*
|
||||
* BUG #5 (MEDIUM): onMainCategoryChange() clears subCategory during hydration —
|
||||
* sets mainCategory first (triggers @change → populates subcategories), then
|
||||
* sets subCategory to the correct leaf ID.
|
||||
*/
|
||||
watch(() => props.editCost, (cost) => {
|
||||
if (!cost) return
|
||||
// Hydrate form from existing expense data
|
||||
|
||||
// ── Basic fields (unchanged) ──
|
||||
form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
|
||||
form.mainCategory = String(cost.category_id || '')
|
||||
form.subCategory = String(cost.sub_category_id || '')
|
||||
form.netAmount = cost.data?.net_amount || cost.amount_net || 0
|
||||
form.vatRate = cost.data?.vat_rate || 27
|
||||
form.grossAmount = cost.amount_gross || 0
|
||||
form.netAmount = cost.data?.net_amount ?? cost.amount_net ?? 0
|
||||
form.vatRate = cost.data?.vat_rate ?? 27
|
||||
form.grossAmount = cost.amount_gross ?? 0
|
||||
form.currency = cost.currency || 'HUF'
|
||||
form.invoiceNumber = cost.data?.invoice_number || ''
|
||||
form.invoiceDate = cost.data?.invoice_date || ''
|
||||
form.paymentDeadline = cost.data?.payment_deadline || ''
|
||||
form.paymentMethod = cost.data?.payment_method || 'TRANSFER'
|
||||
form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null
|
||||
form.liters = cost.data?.liters || null
|
||||
form.invoiceNumber = cost.data?.invoice_number ?? ''
|
||||
form.invoiceDate = cost.data?.invoice_date ?? ''
|
||||
form.paymentDeadline = cost.data?.payment_deadline ?? ''
|
||||
form.paymentMethod = cost.data?.payment_method ?? 'TRANSFER'
|
||||
|
||||
// ── BUG #4 FIX: Defensive null-check on data.liters ──
|
||||
const costData = cost.data ?? {}
|
||||
form.liters = (costData.liters != null && costData.liters !== '') ? costData.liters : null
|
||||
|
||||
// ── Mileage: use enriched mileage_at_cost, fallback to data.mileage_at_cost ──
|
||||
form.mileageAtCost = (cost.mileage_at_cost != null && cost.mileage_at_cost > 0)
|
||||
? cost.mileage_at_cost
|
||||
: (costData.mileage_at_cost != null ? costData.mileage_at_cost : null)
|
||||
|
||||
// ── BUG #1 FIX: Category hierarchy split ──
|
||||
// The enriched GET /expenses/by-id/{id} response includes parent_category_id.
|
||||
// If parent_category_id is set → it's a subcategory: parent goes to main, leaf to sub.
|
||||
// If parent_category_id is null → category_id IS the main category already.
|
||||
// Handle the 'data' response wrapper pattern (list endpoints) vs enriched format.
|
||||
const categoryId = cost.category_id
|
||||
const parentCategoryId = cost.parent_category_id
|
||||
|
||||
if (parentCategoryId != null) {
|
||||
// This is a genuine subcategory — set main from parent, sub from category_id
|
||||
form.mainCategory = String(parentCategoryId)
|
||||
// Setting mainCategory triggers @change → onMainCategoryChange() synchronously,
|
||||
// which populates subCategories.value and clears form.subCategory.
|
||||
// Now re-populate subcategories if they weren't populated (safety net)
|
||||
if (subCategories.value.length === 0) {
|
||||
onMainCategoryChange()
|
||||
}
|
||||
form.subCategory = String(categoryId)
|
||||
} else if (categoryId != null) {
|
||||
// No parent — category_id IS the main category
|
||||
form.mainCategory = String(categoryId)
|
||||
form.subCategory = ''
|
||||
// Still call onMainCategoryChange to populate any subcategories if this
|
||||
// parent has children (the backend might not have returned parent_category_id)
|
||||
if (subCategories.value.length === 0) {
|
||||
onMainCategoryChange()
|
||||
}
|
||||
}
|
||||
|
||||
// ── BUG #2 FIX: Vehicle binding when not passed via prop ──
|
||||
if (!props.vehicle && cost.asset_id) {
|
||||
selectedVehicleId.value = cost.asset_id
|
||||
}
|
||||
|
||||
// ── BUG #3 FIX: Provider hydration from enriched response ──
|
||||
// Priority: service_provider_id > vendor_organization_id > external_vendor_name
|
||||
const spId = cost.service_provider_id
|
||||
const spName = cost.service_provider_name
|
||||
const vendorName = cost.vendor_name
|
||||
const extVendorName = cost.external_vendor_name
|
||||
|
||||
if (spId != null && spName) {
|
||||
// Service provider (crowd_added / staged_data) — primary vendor ref
|
||||
selectedProvider.value = {
|
||||
id: spId,
|
||||
name: spName,
|
||||
category: cost.category_name || null,
|
||||
specialization: [],
|
||||
city: null,
|
||||
address: null,
|
||||
source: 'crowd_added',
|
||||
is_verified: false,
|
||||
rating: null,
|
||||
}
|
||||
} else if (cost.vendor_organization_id != null && vendorName) {
|
||||
// B2B vendor organization
|
||||
selectedProvider.value = {
|
||||
id: cost.vendor_organization_id,
|
||||
name: vendorName,
|
||||
category: cost.category_name || null,
|
||||
specialization: [],
|
||||
city: null,
|
||||
address: null,
|
||||
source: 'verified_org',
|
||||
is_verified: true,
|
||||
rating: null,
|
||||
}
|
||||
} else if (extVendorName) {
|
||||
// Free-text vendor name — set as a minimal provider object
|
||||
selectedProvider.value = {
|
||||
id: 0,
|
||||
name: extVendorName,
|
||||
category: cost.category_name || null,
|
||||
specialization: [],
|
||||
city: null,
|
||||
address: null,
|
||||
source: 'crowd_added',
|
||||
is_verified: false,
|
||||
rating: null,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── File Methods ──
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Score card -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
|
||||
<p class="text-3xl font-extrabold text-emerald-600">{{ userScore }}</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
|
||||
</div>
|
||||
<!-- Achievement badges -->
|
||||
@@ -43,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -52,11 +52,13 @@ const emit = defineEmits<{
|
||||
'open-card': [cardId: string]
|
||||
}>()
|
||||
|
||||
// ── Placeholder badges ──
|
||||
const badges = ref([
|
||||
{ icon: '🌱', label: 'Eco Driver' },
|
||||
{ icon: '📏', label: '10k km Club' },
|
||||
{ icon: '🔧', label: 'Service Pro' },
|
||||
{ icon: '⭐', label: 'Top Rater' },
|
||||
])
|
||||
// ── Reactive user score (placeholder 0 — wire to gamification store when available) ──
|
||||
const userScore = computed(() => 0)
|
||||
|
||||
// ── Reactive badges (empty array — wire to gamification API when available) ──
|
||||
interface BadgeItem {
|
||||
icon: string
|
||||
label: string
|
||||
}
|
||||
const badges = ref<BadgeItem[]>([])
|
||||
</script>
|
||||
|
||||
@@ -1,63 +1,154 @@
|
||||
<template>
|
||||
<!--
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FinanceOverviewCard (ProfileTrustCard.vue — refactored)
|
||||
──
|
||||
PURPOSE: 5th dashboard card — "Pénzügy" (Finance) overview.
|
||||
Shows wallet summary + 3 action buttons routing to
|
||||
/finance/* sub-routes. Entire card body routes to /finance.
|
||||
──
|
||||
DESIGN:
|
||||
• Header: 💰 Pénzügy (Finance)
|
||||
• Body: Click anywhere → /finance
|
||||
Shows quick wallet balance overview + 4 stat tiles
|
||||
• Footer: 3 action buttons (Pénztárcák / Csomagok / Tranzakciók)
|
||||
──
|
||||
THOUGHT PROCESS:
|
||||
- User name, email, trust score REMOVED as per spec.
|
||||
- All modal trigger logic REMOVED — replaced with Vue Router navigation.
|
||||
- Wallet balance fetched on mount via financeStore.
|
||||
- Uses BaseCard with hoverable for the card-lift effect.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
<BaseCard
|
||||
hoverable
|
||||
@click="$emit('open-card', 'stats')"
|
||||
@click="router.push('/finance')"
|
||||
>
|
||||
<!-- ═══ HEADER: Profile title ═══ -->
|
||||
<!-- ═══ HEADER: Finance title ═══ -->
|
||||
<template #header>
|
||||
<span class="text-white font-bold text-sm tracking-wide">🛡️ {{ t('common.profileSettings') }}</span>
|
||||
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('menu.finance') }}</span>
|
||||
</template>
|
||||
|
||||
<!-- ═══ BODY: User info + Trust Score + Stats ═══ -->
|
||||
<!-- ═══ BODY: Quick finance overview (clickable → /finance) ═══ -->
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- User info -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
|
||||
<!-- Balance summary row -->
|
||||
<div class="rounded-xl border border-slate-200 bg-gradient-to-br from-amber-50 to-white p-3">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
|
||||
{{ t('finance.totalBalance') }}
|
||||
</p>
|
||||
<p class="text-2xl font-extrabold text-amber-600">
|
||||
{{ formatCredits(financeStore.totalCredits) }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">
|
||||
🪙 {{ t('finance.rewards') }}: {{ formatCredits(financeStore.serviceCoins) }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Trust Score -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
|
||||
<span class="text-sm font-bold text-emerald-600">A+</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick stats -->
|
||||
|
||||
<!-- Quick stat tiles (2×2 grid) -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<!-- Pénztárcák (Wallets) -->
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
|
||||
<p class="text-lg font-bold text-slate-800">{{ financeStore.totalCredits }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('finance.wallet') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tranzakciók (Transactions count) -->
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
|
||||
<p class="text-lg font-bold text-slate-800">{{ transactionCount }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('finance.transactionHistory') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Csomagok (Active Packages) -->
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ subscriptionPlanName }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('dashboard.viewProfile') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Voucher / Bónusz -->
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ formatCredits(financeStore.voucherBalance) }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('finance.voucher') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ FOOTER: CTA button ═══ -->
|
||||
<!-- ═══ FOOTER: 3 action buttons ═══ -->
|
||||
<template #footer>
|
||||
<button class="w-full py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||
{{ t('common.profileSettings') }} →
|
||||
</button>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<!-- Pénztárcák (Wallets) -->
|
||||
<button
|
||||
class="w-full py-2.5 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-xs transition-colors shadow-md"
|
||||
@click.stop="router.push('/finance/wallets')"
|
||||
>
|
||||
💰 {{ t('finance.wallet') }}
|
||||
</button>
|
||||
|
||||
<!-- Csomagok (Packages) -->
|
||||
<button
|
||||
class="w-full py-2.5 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-xs transition-colors shadow-md"
|
||||
@click.stop="router.push('/finance/packages')"
|
||||
>
|
||||
📦 {{ t('subscription.title') }}
|
||||
</button>
|
||||
|
||||
<!-- Tranzakciók (Transactions) -->
|
||||
<button
|
||||
class="w-full py-2.5 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-xs transition-colors shadow-md"
|
||||
@click.stop="router.push('/finance/transactions')"
|
||||
>
|
||||
📋 {{ t('finance.transactionHistory') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* ── Imports ──────────────────────────────────────────────────────────
|
||||
* PURPOSE: Standard Vue 3 Composition API + Pinia stores for
|
||||
* auth, vehicle, and finance data.
|
||||
* THOUGHT: Removed computed trustScore* props and the onMounted
|
||||
* auth check — no longer needed. Wallet balance still
|
||||
* fetched on mount to display quick overview.
|
||||
* Added authStore for subscription plan name display.
|
||||
*/
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFinanceStore } from '../../stores/finance'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import BaseCard from '../ui/BaseCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const financeStore = useFinanceStore()
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-card': [cardId: string]
|
||||
}>()
|
||||
// ── Reactive computed values for stat tiles ──
|
||||
|
||||
/** Transaction count — placeholder 0, wire to transaction store when available */
|
||||
const transactionCount = computed(() => 0)
|
||||
|
||||
/** Active subscription plan name — live from authStore */
|
||||
const subscriptionPlanName = computed(() => {
|
||||
return authStore.user?.subscription_plan || '—'
|
||||
})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
/**
|
||||
* formatCredits — Format a number for credit display.
|
||||
* Uses locale grouping for integers, 2 decimals for floats.
|
||||
*/
|
||||
function formatCredits(value: number): string {
|
||||
if (Number.isInteger(value)) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
// ── Fetch wallet balance on mount ────────────────────────────────────
|
||||
onMounted(() => {
|
||||
financeStore.fetchWalletBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1779,6 +1779,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'saved'): void
|
||||
(e: 'deleted'): void
|
||||
}>()
|
||||
|
||||
// ── Tab Configuration ──
|
||||
|
||||
302
frontend_app/src/components/finance/TransactionHistory.vue
Normal file
302
frontend_app/src/components/finance/TransactionHistory.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<!-- ═══ Header ═══ -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
||||
<h2 class="text-lg font-bold text-slate-800">📋 {{ t('finance.transactionHistory') }}</h2>
|
||||
<!-- Filter chips -->
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.value"
|
||||
@click="activeFilter = f.value; currentPage = 1; fetchTransactions()"
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors"
|
||||
:class="activeFilter === f.value
|
||||
? 'bg-sf-accent text-white'
|
||||
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Loading ═══ -->
|
||||
<div
|
||||
v-if="txLoading"
|
||||
class="flex items-center justify-center py-16"
|
||||
>
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
|
||||
</div>
|
||||
|
||||
<!-- ═══ Error ═══ -->
|
||||
<div
|
||||
v-else-if="txError"
|
||||
class="flex flex-col items-center justify-center py-12 text-center px-6"
|
||||
>
|
||||
<svg class="w-12 h-12 text-red-400 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">{{ txError }}</p>
|
||||
<button
|
||||
@click="fetchTransactions"
|
||||
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Empty ═══ -->
|
||||
<div
|
||||
v-else-if="transactions.length === 0"
|
||||
class="flex flex-col items-center justify-center py-16 text-slate-400 px-6"
|
||||
>
|
||||
<svg class="w-12 h-12 mb-3" 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 List ═══ -->
|
||||
<div v-else class="divide-y divide-slate-100">
|
||||
<div
|
||||
v-for="tx in transactions"
|
||||
:key="tx.id"
|
||||
class="flex items-center justify-between px-6 py-4 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<!-- Left: Icon + Details -->
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
<!-- Direction Icon -->
|
||||
<div
|
||||
class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
|
||||
:class="tx.entry_type === 'CREDIT' ? 'bg-emerald-100' : 'bg-red-100'"
|
||||
>
|
||||
<span class="text-base">{{ 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 flex-wrap gap-1.5 mt-1.5">
|
||||
<!-- Wallet type badge -->
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
:class="walletTypeBadge(tx.wallet_type)"
|
||||
>
|
||||
{{ walletTypeLabel(tx.wallet_type) }}
|
||||
</span>
|
||||
<!-- Status badge -->
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
:class="statusBadge(tx.status)"
|
||||
>
|
||||
{{ statusLabel(tx.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: 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 px-6 py-3 bg-slate-50"
|
||||
>
|
||||
<button
|
||||
@click="prevPage"
|
||||
:disabled="currentPage <= 1"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="currentPage > 1
|
||||
? 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200'
|
||||
: 'bg-slate-50 text-slate-300 cursor-not-allowed border border-slate-100'"
|
||||
>
|
||||
← {{ t('common.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-white text-slate-600 hover:bg-slate-100 border border-slate-200'
|
||||
: 'bg-slate-50 text-slate-300 cursor-not-allowed border border-slate-100'"
|
||||
>
|
||||
{{ t('common.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TransactionHistory.vue
|
||||
*
|
||||
* Lists paginated transaction data from GET /billing/wallet/transactions.
|
||||
* Uses the financeStore and the existing i18n locale files to display
|
||||
* human-readable wallet types (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)
|
||||
* and statuses (SUCCESS, PENDING, FAILED, REFUNDED, REFUND).
|
||||
*
|
||||
* Filter chips allow scoping by wallet_type. Pagination buttons at the bottom.
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../../api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── State ──
|
||||
const transactions = ref<any[]>([])
|
||||
const txLoading = ref(false)
|
||||
const txError = ref<string | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const activeFilter = ref<string | null>(null)
|
||||
const pageSize = 20
|
||||
|
||||
// ── Filter definitions (use existing i18n keys from finance section) ──
|
||||
const filters = computed(() => [
|
||||
{ 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') },
|
||||
])
|
||||
|
||||
// ── Formatting 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 '\u2014' // em dash
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wallet type badge colours + human-readable labels ──
|
||||
// Uses the existing i18n finance keys for display.
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
function walletTypeLabel(type: string | null): string {
|
||||
switch (type) {
|
||||
case 'PURCHASED': return t('finance.purchased')
|
||||
case 'EARNED': return t('finance.earned')
|
||||
case 'SERVICE_COINS': return t('finance.bonus')
|
||||
case 'VOUCHER': return t('finance.voucher')
|
||||
default: return type || '\u2014'
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'SUCCESS': return 'bg-emerald-100 text-emerald-700'
|
||||
case 'PENDING': return 'bg-amber-100 text-amber-700'
|
||||
case 'FAILED': return 'bg-red-100 text-red-700'
|
||||
case 'REFUNDED':
|
||||
case 'REFUND': return 'bg-purple-100 text-purple-700'
|
||||
default: return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'SUCCESS': return t('common.statusActive') || 'Success'
|
||||
case 'PENDING': return 'Pending'
|
||||
case 'FAILED': return 'Failed'
|
||||
case 'REFUNDED':
|
||||
case 'REFUND': return 'Refunded'
|
||||
default: return status || '\u2014'
|
||||
}
|
||||
}
|
||||
|
||||
// ── API fetch ──
|
||||
|
||||
async function fetchTransactions() {
|
||||
txLoading.value = true
|
||||
txError.value = null
|
||||
try {
|
||||
const params: Record<string, 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) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to load transactions.'
|
||||
txError.value = message
|
||||
console.error('[TransactionHistory] fetch 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expose for parent to trigger refresh ──
|
||||
defineExpose({ fetchTransactions })
|
||||
|
||||
// ── Load on mount ──
|
||||
onMounted(() => {
|
||||
fetchTransactions()
|
||||
})
|
||||
</script>
|
||||
188
frontend_app/src/components/finance/WalletBalanceCard.vue
Normal file
188
frontend_app/src/components/finance/WalletBalanceCard.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<!-- ═══ Header ═══ -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
||||
<h2 class="text-lg font-bold text-slate-800">💰 {{ t('finance.walletBreakdown') }}</h2>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.live') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Loading ═══ -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-16"
|
||||
>
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
|
||||
</div>
|
||||
|
||||
<!-- ═══ Error ═══ -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex flex-col items-center justify-center py-12 text-center px-6"
|
||||
>
|
||||
<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('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Wallet Pockets Grid ═══ -->
|
||||
<div v-else class="p-6 space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- EARNED -->
|
||||
<div
|
||||
class="rounded-xl border p-4 transition-all duration-300"
|
||||
:class="highlight === 'earned'
|
||||
? 'border-emerald-400 bg-emerald-50 ring-2 ring-emerald-200 scale-[1.02]'
|
||||
: 'border-slate-200 bg-slate-50 hover:bg-slate-100'"
|
||||
>
|
||||
<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(balance.earned) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- PURCHASED -->
|
||||
<div
|
||||
class="rounded-xl border p-4 transition-all duration-300"
|
||||
:class="highlight === 'purchased'
|
||||
? 'border-blue-400 bg-blue-50 ring-2 ring-blue-200 scale-[1.02]'
|
||||
: 'border-slate-200 bg-slate-50 hover:bg-slate-100'"
|
||||
>
|
||||
<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-blue-600">{{ formatCredits(balance.purchased) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE_COINS (default highlight target) -->
|
||||
<div
|
||||
ref="serviceCoinsRef"
|
||||
class="rounded-xl border p-4 transition-all duration-300"
|
||||
:class="highlight === 'service-coins'
|
||||
? 'border-amber-400 bg-amber-50 ring-2 ring-amber-200 scale-[1.02]'
|
||||
: 'border-slate-200 bg-slate-50 hover:bg-slate-100'"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
🪙 {{ t('finance.rewards') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.bonus') }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-extrabold text-amber-600">{{ formatCredits(balance.service_coins) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- VOUCHER -->
|
||||
<div
|
||||
class="rounded-xl border p-4 transition-all duration-300"
|
||||
:class="highlight === 'voucher'
|
||||
? 'border-purple-400 bg-purple-50 ring-2 ring-purple-200 scale-[1.02]'
|
||||
: 'border-slate-200 bg-slate-50 hover:bg-slate-100'"
|
||||
>
|
||||
<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(balance.voucher) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Balance Bar -->
|
||||
<div class="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(balance.total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* WalletBalanceCard.vue
|
||||
*
|
||||
* Displays the 4 wallet pockets (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)
|
||||
* fetched from GET /billing/wallet/balance via the financeStore.
|
||||
*
|
||||
* Props:
|
||||
* highlight - optional string matching a wallet type (e.g. "service-coins")
|
||||
* to visually emphasize a specific pocket on page load.
|
||||
*/
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFinanceStore } from '../../stores/finance'
|
||||
import type { WalletBalance } from '../../stores/finance'
|
||||
|
||||
const { t } = useI18n()
|
||||
const financeStore = useFinanceStore()
|
||||
|
||||
const props = defineProps<{
|
||||
highlight?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
loaded: []
|
||||
}>()
|
||||
|
||||
// ── Template refs ──
|
||||
const serviceCoinsRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// ── Computed from store ──
|
||||
const balance = computed<WalletBalance>(() => {
|
||||
if (financeStore.balance) {
|
||||
return financeStore.balance as WalletBalance
|
||||
}
|
||||
// Fallback shape while loading
|
||||
return { earned: 0, purchased: 0, service_coins: 0, voucher: 0, total: 0 }
|
||||
})
|
||||
|
||||
const isLoading = computed(() => financeStore.isLoading)
|
||||
const error = computed(() => financeStore.error)
|
||||
|
||||
// ── Helpers ──
|
||||
function formatCredits(value: number): string {
|
||||
if (Number.isInteger(value)) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
financeStore.fetchWalletBalance()
|
||||
}
|
||||
|
||||
// ── Highlight auto-scroll ──
|
||||
watch(() => props.highlight, (h) => {
|
||||
if (h === 'service-coins' && serviceCoinsRef.value) {
|
||||
nextTick(() => {
|
||||
serviceCoinsRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Fetch on mount & emit loaded ──
|
||||
onMounted(async () => {
|
||||
await financeStore.fetchWalletBalance()
|
||||
// Auto-scroll if highlight is service-coins
|
||||
if (props.highlight === 'service-coins' && serviceCoinsRef.value) {
|
||||
nextTick(() => {
|
||||
serviceCoinsRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
}
|
||||
emit('loaded')
|
||||
})
|
||||
</script>
|
||||
@@ -18,22 +18,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
/**
|
||||
* Compute the target route for the logo link.
|
||||
* - If the user is on an /organization/:id page, navigate to the org root.
|
||||
* - Otherwise, navigate to /dashboard (private garage).
|
||||
* Always navigate to /dashboard when clicking the logo.
|
||||
* This serves as a global "escape hatch" back to the private garage,
|
||||
* regardless of the current route (org, vehicles, etc.).
|
||||
*/
|
||||
const targetRoute = computed(() => {
|
||||
if (route.path.startsWith('/organization/')) {
|
||||
const orgId = route.params.id
|
||||
return orgId ? `/organization/${orgId}` : route.path
|
||||
}
|
||||
return '/dashboard'
|
||||
})
|
||||
const targetRoute = computed(() => '/dashboard')
|
||||
</script>
|
||||
|
||||
@@ -901,6 +901,7 @@ import VehiclePlateBadge from './VehiclePlateBadge.vue'
|
||||
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
|
||||
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
|
||||
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
|
||||
import TechDataTab from '../vehicles/tabs/TechDataTab.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -81,6 +81,10 @@ export default {
|
||||
garageLimit: 'Garage Limit',
|
||||
managePlan: 'Manage Plan',
|
||||
indefinite: 'Indefinite',
|
||||
vehicles: 'vehicles',
|
||||
unlimited: 'unlimited',
|
||||
timeRemaining: 'Time Remaining',
|
||||
days: 'days',
|
||||
},
|
||||
landing: {
|
||||
openGarage: 'Open Garage',
|
||||
|
||||
@@ -81,6 +81,10 @@ export default {
|
||||
garageLimit: 'Garázs Limit',
|
||||
managePlan: 'Csomag Kezelése',
|
||||
indefinite: 'Határozatlan',
|
||||
vehicles: 'jármű',
|
||||
unlimited: 'korlátlan',
|
||||
timeRemaining: 'Hátralévő idő',
|
||||
days: 'nap',
|
||||
},
|
||||
landing: {
|
||||
openGarage: 'Garázs Nyitása',
|
||||
|
||||
89
frontend_app/src/layouts/FinanceLayout.vue
Normal file
89
frontend_app/src/layouts/FinanceLayout.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<!--
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FinanceLayout — Layout wrapper for all /finance/* routes
|
||||
──
|
||||
PURPOSE: Provides the exact same TopBar (BaseHeader) as PrivateLayout,
|
||||
with the garage-bg.png background + dark overlay, so finance
|
||||
pages match the main dashboard's visual framework exactly.
|
||||
──
|
||||
DESIGN:
|
||||
• Sticky BaseHeader (glassmorphism, dark)
|
||||
- #left: HeaderLogo → routes to /dashboard
|
||||
- #center: Teleport target (#header-teleport-target) — child views
|
||||
inject their page title here via Teleport
|
||||
- #right: LanguageSwitcher + HeaderProfile (full user dropdown)
|
||||
• Full-bleed garage-bg.png background + dark overlay
|
||||
• <router-view /> for child route content
|
||||
──
|
||||
THOUGHT PROCESS:
|
||||
- Mirrors PrivateLayout.vue exactly, but standalone (not nested).
|
||||
- No hamburger menu — finance pages use their own navigation.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
<div class="finance-layout min-h-screen text-white">
|
||||
<!-- ── Garage Background Image (full-bleed, fixed) ── -->
|
||||
<div class="fixed inset-0 z-0">
|
||||
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed" />
|
||||
<!-- Dark overlay for readability -->
|
||||
<div class="absolute inset-0 bg-black/30" />
|
||||
</div>
|
||||
|
||||
<!-- ── Modular header — matches PrivateLayout exactly ── -->
|
||||
<BaseHeader>
|
||||
<template #left>
|
||||
<HeaderLogo />
|
||||
</template>
|
||||
|
||||
<template #center>
|
||||
<span class="text-white font-bold text-sm tracking-wide">{{ financeTitle }}</span>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<LanguageSwitcher />
|
||||
<HeaderProfile />
|
||||
</template>
|
||||
</BaseHeader>
|
||||
|
||||
<!-- ── Main content area — child routes render here ── -->
|
||||
<main class="relative z-10">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* ── Imports ──────────────────────────────────────────────────────────
|
||||
* PURPOSE: Reuses the exact same header Lego components as PrivateLayout
|
||||
* to ensure pixel-perfect visual consistency.
|
||||
* THOUGHT: No hamburger menu here — finance pages are focused views.
|
||||
* HeaderLogo auto-links to /dashboard (see HeaderLogo.vue).
|
||||
* The back button has been moved to each child view's local
|
||||
* FleetView-style content header for proper alignment.
|
||||
* The center slot dynamically shows the current page title
|
||||
* based on the active route path.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseHeader from '../components/layout/BaseHeader.vue'
|
||||
import HeaderLogo from '../components/header/HeaderLogo.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Dynamic page title for the finance section's top navbar center slot.
|
||||
* Matches the active route path to determine which title to display.
|
||||
*/
|
||||
const financeTitle = computed(() => {
|
||||
if (route.path === '/finance') return '💰 ' + t('menu.finance')
|
||||
if (route.path.startsWith('/finance/wallets')) return '💰 ' + t('finance.wallet')
|
||||
if (route.path.startsWith('/finance/packages')) return '📦 ' + t('subscription.title')
|
||||
if (route.path.startsWith('/finance/transactions')) return '📋 ' + t('finance.transactionHistory')
|
||||
return '💰 ' + t('menu.finance')
|
||||
})
|
||||
</script>
|
||||
@@ -84,6 +84,18 @@ const router = createRouter({
|
||||
component: () => import('../views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/profile/stats',
|
||||
name: 'profile-stats',
|
||||
component: () => import('../views/ProfileStatsView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/profile/settings',
|
||||
name: 'profile-settings',
|
||||
component: () => import('../views/ProfileSettingsView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/verify',
|
||||
name: 'verify',
|
||||
@@ -139,6 +151,51 @@ const router = createRouter({
|
||||
component: () => import('../views/admin/AdminServicesView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// ── Finance routes ─────────────────────────────────────────────────
|
||||
// Parent layout provides the standard TopBar (Logo, Profile, Lang)
|
||||
// and the garage-bg.png background. Child routes render their
|
||||
// content in <router-view /> inside FinanceLayout.
|
||||
path: '/finance',
|
||||
component: () => import('../layouts/FinanceLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
// /finance → Main overview with 4 BaseCard tiles
|
||||
path: '',
|
||||
name: 'finance',
|
||||
component: () => import('../views/FinanceMainView.vue')
|
||||
},
|
||||
{
|
||||
// /finance/wallets → Wallet balance breakdown (4 pockets)
|
||||
path: 'wallets',
|
||||
name: 'finance-wallets',
|
||||
component: () => import('../views/FinanceWalletsView.vue')
|
||||
},
|
||||
{
|
||||
// /finance/packages → Active subscription / packages
|
||||
path: 'packages',
|
||||
name: 'finance-packages',
|
||||
component: () => import('../views/FinancePackagesView.vue')
|
||||
},
|
||||
{
|
||||
// /finance/transactions → Paginated transaction history
|
||||
path: 'transactions',
|
||||
name: 'finance-transactions',
|
||||
component: () => import('../views/FinanceTransactionsView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// Legacy Wallet route (kept for backward compatibility)
|
||||
// Standalone page with its own white background — NOT nested
|
||||
// under FinanceLayout because it has a completely different
|
||||
// visual style (white bg, custom back button).
|
||||
path: '/finance/wallet',
|
||||
name: 'wallet',
|
||||
component: () => import('../views/WalletView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -68,10 +68,8 @@
|
||||
@open-card="openCard"
|
||||
/>
|
||||
|
||||
<!-- Card 5: 🛡️ My Profile & Trust (Profil & Trust Score) -->
|
||||
<ProfileTrustCard
|
||||
@open-card="openCard"
|
||||
/>
|
||||
<!-- Card 5: 💰 Pénzügy (Finance Overview) -->
|
||||
<ProfileTrustCard />
|
||||
</div>
|
||||
|
||||
<!-- ── Subscription Status & Ad Placement Row ── -->
|
||||
|
||||
266
frontend_app/src/views/FinanceMainView.vue
Normal file
266
frontend_app/src/views/FinanceMainView.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<!--
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FinanceMainView — /finance overview (rendered inside FinanceLayout)
|
||||
──
|
||||
PURPOSE: Main landing page for the finance section. Shows 4 separate
|
||||
cards using the DashboardView bottom-aligned pattern.
|
||||
──
|
||||
CARDS:
|
||||
1. 💰 Pénztárca → /finance/wallets
|
||||
- Live data: FinancialCard.vue pattern (totalCredits, 4 pockets)
|
||||
2. 📦 Csomagok → /finance/packages
|
||||
- Live data: authStore.subscription_plan
|
||||
3. 📋 Tranzakciók → /finance/transactions
|
||||
- Static placeholder
|
||||
4. 🧾 Számlák → (disabled, placeholder)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
|
||||
<!-- ═══ MAIN CONTENT CONTAINER (hybrid: FleetView width + Dashboard bottom-alignment) ═══ -->
|
||||
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
|
||||
<!-- ── Back button only (title is in top navbar via FinanceLayout) ── -->
|
||||
<div class="mb-8 flex items-center justify-end">
|
||||
<button
|
||||
@click="router.push('/dashboard')"
|
||||
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
{{ t('garage.backToDashboard') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 4-card grid — FleetView-style -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
|
||||
>
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
Card 1: 💰 Pénztárca (Wallets)
|
||||
Data binding copied from FinancialCard.vue (totalCredits,
|
||||
purchasedCredits, serviceCoins, earnedCredits, voucherBalance)
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
>
|
||||
<!-- Invisible overlay for click capture (MyVehiclesCard.vue pattern) -->
|
||||
<div
|
||||
class="absolute inset-0 z-10 cursor-pointer"
|
||||
@click="goTo('/finance/wallets')"
|
||||
/>
|
||||
<!-- Header bar -->
|
||||
<div class="h-12 bg-gradient-to-r from-amber-600 to-amber-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('finance.wallet') }}</span>
|
||||
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-white/20 px-2 py-0.5 text-xs font-medium text-white">
|
||||
{{ t('finance.live') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content: loading / error / balance (copied from FinancialCard.vue) -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="walletLoading"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="walletError"
|
||||
class="flex-1 flex flex-col items-center justify-center text-center px-4"
|
||||
>
|
||||
<svg class="w-10 h-10 text-amber-500 mb-2" 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-xs text-slate-500">{{ walletError }}</p>
|
||||
<button
|
||||
@click.stop="financeStore.fetchWalletBalance()"
|
||||
class="mt-2 text-xs text-sf-accent hover:underline"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Balance Content (FinancialCard.vue pattern) -->
|
||||
<div v-else class="flex-1 flex flex-col gap-3">
|
||||
<!-- Total Credits (big number) -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
|
||||
{{ t('finance.totalCredits') }}
|
||||
</p>
|
||||
<p class="text-3xl font-extrabold text-slate-800">
|
||||
{{ formatCredits(financeStore.totalCredits) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mini 2x2 breakdown (Purchased + Bonus / Earned + Voucher) -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
|
||||
<p class="text-xs text-slate-500 font-medium uppercase tracking-wider">{{ t('finance.purchased') }}</p>
|
||||
<p class="text-lg font-bold text-slate-800 mt-0.5">{{ formatCredits(financeStore.purchasedCredits) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
|
||||
<p class="text-xs text-slate-500 font-medium uppercase tracking-wider">{{ t('finance.bonus') }}</p>
|
||||
<p class="text-lg font-bold text-emerald-600 mt-0.5">{{ formatCredits(financeStore.serviceCoins) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-slate-400 px-1">
|
||||
<span>{{ t('finance.earned') }}: <strong class="text-slate-600">{{ formatCredits(financeStore.earnedCredits) }}</strong></span>
|
||||
<span>{{ t('finance.voucher') }}: <strong class="text-slate-600">{{ formatCredits(financeStore.voucherBalance) }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
Card 2: 📦 Csomagok (Packages)
|
||||
Shows authStore.subscription_plan with active plan name.
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
>
|
||||
<!-- Invisible overlay for click capture -->
|
||||
<div
|
||||
class="absolute inset-0 z-10 cursor-pointer"
|
||||
@click="goTo('/finance/packages')"
|
||||
/>
|
||||
<!-- Header bar -->
|
||||
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Current plan card -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
|
||||
{{ t('subscription.active') }}
|
||||
</p>
|
||||
<p class="text-2xl font-extrabold text-sky-600">
|
||||
{{ subscriptionPlanName }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick info row -->
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
|
||||
<p class="text-xs text-slate-500">{{ t('subscription.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
Card 3: 📋 Tranzakciók (Transactions)
|
||||
Placeholder — shows transaction history link.
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
>
|
||||
<!-- Invisible overlay for click capture -->
|
||||
<div
|
||||
class="absolute inset-0 z-10 cursor-pointer"
|
||||
@click="goTo('/finance/transactions')"
|
||||
/>
|
||||
<!-- Header bar -->
|
||||
<div class="h-12 bg-gradient-to-r from-emerald-600 to-emerald-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">📋 {{ t('finance.transactionHistory') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 flex flex-col items-center justify-center text-center space-y-3">
|
||||
<span class="text-5xl">📋</span>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('finance.transactionHistory') }}</h3>
|
||||
<p class="text-sm text-slate-500">{{ t('finance.analyticsTab') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
Card 4: 🧾 Számlák (Billing / Invoices)
|
||||
Placeholder — "Hamarosan" state, disabled routing.
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out opacity-80"
|
||||
>
|
||||
<!-- Header bar -->
|
||||
<div class="h-12 bg-gradient-to-r from-violet-600 to-violet-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🧾 {{ t('costWizard.title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 flex flex-col items-center justify-center text-center space-y-3">
|
||||
<span class="text-5xl">🚧</span>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('costWizard.title') }}</h3>
|
||||
<p class="text-sm text-slate-500">{{ t('costWizard.step4Desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: disabled placeholder badge -->
|
||||
<div class="shrink-0 px-4 pb-4">
|
||||
<div class="w-full text-center text-xs text-slate-400">
|
||||
🚧 {{ t('common.backToGarage') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* ── Imports ──────────────────────────────────────────────────────────
|
||||
* PURPOSE: Standard Vue 3 Composition API.
|
||||
* financeStore — live wallet balance data (Card 1).
|
||||
* authStore — subscription plan name (Card 2).
|
||||
* THOUGHT: Card 1 data binding copied from FinancialCard.vue lines 44-73.
|
||||
* Wallet balance fetched on mount via financeStore.
|
||||
* No flip mechanic — cards use direct @click routing.
|
||||
*/
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFinanceStore } from '../stores/finance'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const financeStore = useFinanceStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/**
|
||||
* goTo — navigate to a finance sub-route using the router.
|
||||
* Used by the invisible overlay on each card to capture clicks
|
||||
* reliably (MyVehiclesCard.vue pattern at line 6-9).
|
||||
*/
|
||||
function goTo(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
// ── Card 1: Wallet Loading/Error states (FinancialCard.vue pattern) ──
|
||||
const walletLoading = computed(() => financeStore.isLoading)
|
||||
const walletError = computed(() => financeStore.error)
|
||||
|
||||
// ── Card 2: Subscription plan name (authStore pattern) ────────────────
|
||||
const subscriptionPlanName = computed(() => {
|
||||
return authStore.user?.subscription_plan || '—'
|
||||
})
|
||||
|
||||
// ── Helper: format credits ───────────────────────────────────────────
|
||||
function formatCredits(value: number): string {
|
||||
if (Number.isInteger(value)) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
// ── Fetch wallet balance on mount ────────────────────────────────────
|
||||
onMounted(() => {
|
||||
financeStore.fetchWalletBalance()
|
||||
})
|
||||
</script>
|
||||
58
frontend_app/src/views/FinancePackagesView.vue
Normal file
58
frontend_app/src/views/FinancePackagesView.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<!--
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FinancePackagesView — /finance/packages (rendered inside FinanceLayout)
|
||||
──
|
||||
PURPOSE: Shows the user's active subscription/packages info.
|
||||
──
|
||||
DESIGN:
|
||||
• Back button only (title is in top navbar via FinanceLayout)
|
||||
• Dashboard-style bottom-aligned content area
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
|
||||
<!-- ═══ MAIN CONTENT CONTAINER (hybrid: FleetView width + Dashboard bottom-alignment) ═══ -->
|
||||
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
|
||||
<!-- ── Back button only (title is in top navbar via FinanceLayout) ── -->
|
||||
<div class="mb-8 flex items-center justify-end">
|
||||
<button
|
||||
@click="router.push('/finance')"
|
||||
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Active package card -->
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">{{ t('subscription.active') }}</h2>
|
||||
<SubscriptionStatusWidget />
|
||||
</div>
|
||||
|
||||
<!-- CTA to browse all plans -->
|
||||
<div class="text-center">
|
||||
<button
|
||||
@click="router.push('/dashboard/subscription')"
|
||||
class="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('subscription.selectPlan') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* ── Imports ──────────────────────────────────────────────────────────
|
||||
* PURPOSE: SubscriptionStatusWidget handles its own data loading.
|
||||
* Router is used for the CTA navigation and back button.
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SubscriptionStatusWidget from '../components/dashboard/SubscriptionStatusWidget.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
49
frontend_app/src/views/FinanceTransactionsView.vue
Normal file
49
frontend_app/src/views/FinanceTransactionsView.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<!--
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FinanceTransactionsView — /finance/transactions (rendered inside FinanceLayout)
|
||||
──
|
||||
PURPOSE: Shows the full paginated transaction history.
|
||||
──
|
||||
DESIGN:
|
||||
• Back button only (title is in top navbar via FinanceLayout)
|
||||
• Dashboard-style bottom-aligned content area
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
|
||||
<!-- ═══ MAIN CONTENT CONTAINER (hybrid: FleetView width + Dashboard bottom-alignment) ═══ -->
|
||||
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
|
||||
<!-- ── Back button only (title is in top navbar via FinanceLayout) ── -->
|
||||
<div class="mb-8 flex items-center justify-end">
|
||||
<button
|
||||
@click="router.push('/finance')"
|
||||
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<TransactionHistory />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* ── Imports ──────────────────────────────────────────────────────────
|
||||
* PURPOSE: TransactionHistory handles all state internally (loading,
|
||||
* error, empty, pagination, filters).
|
||||
* THOUGHT: The white card wrapper ensures visual consistency with the
|
||||
* dashboard's styling when rendered on the garage-bg backdrop.
|
||||
* Added useRouter for the back button.
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TransactionHistory from '../components/finance/TransactionHistory.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
52
frontend_app/src/views/FinanceWalletsView.vue
Normal file
52
frontend_app/src/views/FinanceWalletsView.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<!--
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FinanceWalletsView — /finance/wallets (rendered inside FinanceLayout)
|
||||
──
|
||||
PURPOSE: Shows the full wallet balance breakdown (4 pockets) using
|
||||
the existing WalletBalanceCard component.
|
||||
──
|
||||
DESIGN:
|
||||
• Back button only (title is in top navbar via FinanceLayout)
|
||||
• Dashboard-style bottom-aligned content area
|
||||
• WalletBalanceCard rendered inside a white dashboard-style card
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
|
||||
<!-- ═══ MAIN CONTENT CONTAINER (hybrid: FleetView width + Dashboard bottom-alignment) ═══ -->
|
||||
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
|
||||
<!-- ── Back button only (title is in top navbar via FinanceLayout) ── -->
|
||||
<div class="mb-8 flex items-center justify-end">
|
||||
<button
|
||||
@click="router.push('/finance')"
|
||||
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Wallet Balance Overview (4 pockets) -->
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<WalletBalanceCard />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* ── Imports ──────────────────────────────────────────────────────────
|
||||
* PURPOSE: Reuses WalletBalanceCard — handles data fetching internally.
|
||||
* THOUGHT: The rounded-2xl border bg-white shadow-sm wrapper ensures
|
||||
* the card matches the dashboard's visual style even without
|
||||
* the layout's glassmorphism.
|
||||
* Added useRouter for the back button.
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WalletBalanceCard from '../components/finance/WalletBalanceCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
70
frontend_app/src/views/ProfileSettingsView.vue
Normal file
70
frontend_app/src/views/ProfileSettingsView.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen text-white">
|
||||
<!-- Background Image -->
|
||||
<div class="fixed inset-0 z-0">
|
||||
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<BaseHeader>
|
||||
<template #left>
|
||||
<HeaderLogo />
|
||||
</template>
|
||||
<template #center>
|
||||
<span class="text-sm font-semibold text-white/70 tracking-wide">
|
||||
{{ t('dashboard.settings') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #right>
|
||||
<HeaderCompanySwitcher />
|
||||
<HeaderProfile />
|
||||
</template>
|
||||
</BaseHeader>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 pt-20">
|
||||
<div class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="rounded-2xl border border-white/10 bg-black/40 p-6 backdrop-blur-xl shadow-xl shadow-black/20">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-6 h-6 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<h2 class="text-xl font-bold text-white">{{ t('dashboard.settings') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for future settings content -->
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-6 text-center">
|
||||
<p class="text-white/50 text-sm">{{ t('profile.settingsPlaceholder') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Back button -->
|
||||
<div class="mt-8 text-center">
|
||||
<button
|
||||
@click="router.push('/profile')"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-white/[0.12] bg-white/[0.04] px-6 py-3 text-white/70 transition-all duration-200 hover:border-[#00E5A0]/40 hover:text-white hover:bg-white/[0.08] backdrop-blur-sm cursor-pointer"
|
||||
>
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12" />
|
||||
<polyline points="12 19 5 12 12 5" />
|
||||
</svg>
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseHeader from '../components/layout/BaseHeader.vue'
|
||||
import HeaderLogo from '../components/header/HeaderLogo.vue'
|
||||
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
140
frontend_app/src/views/ProfileStatsView.vue
Normal file
140
frontend_app/src/views/ProfileStatsView.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen text-white">
|
||||
<!-- Background Image -->
|
||||
<div class="fixed inset-0 z-0">
|
||||
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<BaseHeader>
|
||||
<template #left>
|
||||
<HeaderLogo />
|
||||
</template>
|
||||
<template #center>
|
||||
<span class="text-sm font-semibold text-white/70 tracking-wide">
|
||||
{{ t('dashboard.trustScore') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #right>
|
||||
<HeaderCompanySwitcher />
|
||||
<HeaderProfile />
|
||||
</template>
|
||||
</BaseHeader>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 pt-20">
|
||||
<div class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="rounded-2xl border border-white/10 bg-black/40 p-6 backdrop-blur-xl shadow-xl shadow-black/20">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-6 h-6 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h2 class="text-xl font-bold text-white">{{ t('dashboard.trustScore') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Trust Score Gauge -->
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-lg font-bold text-white">{{ trustScoreLabel }}</span>
|
||||
<span class="text-4xl font-extrabold" :class="trustScoreColor">{{ trustScorePercent }}%</span>
|
||||
</div>
|
||||
<div class="h-3 overflow-hidden rounded-full bg-slate-700">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500"
|
||||
:style="{ width: trustScorePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wider mb-1">{{ t('profile.completion') }}</p>
|
||||
<p class="text-2xl font-bold text-[#00E5A0]">{{ profileCompletion }}%</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wider mb-1">{{ t('header.myCompanies') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ organizationsCount }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wider mb-1">{{ t('dashboard.totalVehicles') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ vehiclesCount }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wider mb-1">{{ t('finance.rewards') }}</p>
|
||||
<p class="text-2xl font-bold text-amber-500">{{ serviceCoins }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back button -->
|
||||
<div class="mt-8 text-center">
|
||||
<button
|
||||
@click="router.push('/dashboard')"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-white/[0.12] bg-white/[0.04] px-6 py-3 text-white/70 transition-all duration-200 hover:border-[#00E5A0]/40 hover:text-white hover:bg-white/[0.08] backdrop-blur-sm cursor-pointer"
|
||||
>
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12" />
|
||||
<polyline points="12 19 5 12 12 5" />
|
||||
</svg>
|
||||
{{ t('common.backToGarage') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useVehicleStore } from '../stores/vehicle'
|
||||
import { useFinanceStore } from '../stores/finance'
|
||||
import BaseHeader from '../components/layout/BaseHeader.vue'
|
||||
import HeaderLogo from '../components/header/HeaderLogo.vue'
|
||||
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
const financeStore = useFinanceStore()
|
||||
|
||||
const trustScorePercent = computed(() => 75)
|
||||
const trustScoreLabel = computed(() => {
|
||||
const pct = trustScorePercent.value
|
||||
if (pct >= 90) return 'A+'
|
||||
if (pct >= 75) return 'A'
|
||||
if (pct >= 60) return 'B'
|
||||
if (pct >= 40) return 'C'
|
||||
return 'D'
|
||||
})
|
||||
const trustScoreColor = computed(() => {
|
||||
const pct = trustScorePercent.value
|
||||
if (pct >= 75) return 'text-emerald-400'
|
||||
if (pct >= 50) return 'text-amber-400'
|
||||
return 'text-red-400'
|
||||
})
|
||||
|
||||
const profileCompletion = computed(() => {
|
||||
const p = authStore.user?.person
|
||||
if (!p) return 0
|
||||
let total = 0
|
||||
let filled = 0
|
||||
if (p.first_name) filled++; total++
|
||||
if (p.last_name) filled++; total++
|
||||
if (p.phone) filled++; total++
|
||||
return Math.round((filled / total) * 100)
|
||||
})
|
||||
|
||||
const organizationsCount = computed(() => authStore.myOrganizations.length)
|
||||
const vehiclesCount = computed(() => vehicleStore.vehicles.length)
|
||||
const serviceCoins = computed(() => {
|
||||
const coins = financeStore.serviceCoins
|
||||
if (Number.isInteger(coins)) return coins.toLocaleString()
|
||||
return coins.toFixed(2)
|
||||
})
|
||||
</script>
|
||||
136
frontend_app/src/views/WalletView.vue
Normal file
136
frontend_app/src/views/WalletView.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-100">
|
||||
<!-- ═══ Top Navigation Bar ═══ -->
|
||||
<div class="sticky top-0 z-30 bg-white border-b border-slate-200 shadow-sm">
|
||||
<div class="mx-auto max-w-5xl px-4 sm:px-6">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Back button -->
|
||||
<button
|
||||
@click="goBack"
|
||||
class="flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-slate-800 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-lg font-bold text-slate-800">💰 {{ t('finance.walletDetails') }}</h1>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Main Content ═══ -->
|
||||
<div class="mx-auto max-w-5xl px-4 sm:px-6 py-8 space-y-6">
|
||||
<!-- Card 1: Wallet Balance Overview (4 pockets) -->
|
||||
<WalletBalanceCard
|
||||
:highlight="highlightPocket"
|
||||
@loaded="onBalanceLoaded"
|
||||
/>
|
||||
|
||||
<!-- Card 2: Quick Actions -->
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm p-6">
|
||||
<div class="flex gap-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: Transaction History -->
|
||||
<TransactionHistory ref="historyRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* WalletView.vue
|
||||
*
|
||||
* Dedicated Wallet / Finance page matching the /finance/wallet route.
|
||||
* Reads the ?highlight= query parameter to visually emphasise
|
||||
* the Service Coins pocket (or any other pocket) on page load.
|
||||
*
|
||||
* Components:
|
||||
* - WalletBalanceCard — 4-pocket balance overview
|
||||
* - TransactionHistory — paginated transaction list
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import WalletBalanceCard from '../components/finance/WalletBalanceCard.vue'
|
||||
import TransactionHistory from '../components/finance/TransactionHistory.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// ── Child component refs ──
|
||||
const historyRef = ref<InstanceType<typeof TransactionHistory> | null>(null)
|
||||
|
||||
// ── Read highlight query param ──
|
||||
// Supported values: "service-coins", "earned", "purchased", "voucher"
|
||||
const highlightPocket = computed<string | undefined>(() => {
|
||||
const h = route.query.highlight
|
||||
if (typeof h === 'string' && ['service-coins', 'earned', 'purchased', 'voucher'].includes(h)) {
|
||||
return h
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
// ── Navigation ──
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function onBalanceLoaded() {
|
||||
// After balance data is loaded, optionally trigger history refresh
|
||||
if (historyRef.value) {
|
||||
historyRef.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 = '\uD83D\uDD14 Fizetési kapu hamarosan elérhet\u0151!'
|
||||
document.body.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-slide-out')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@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-slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
.animate-slide-out {
|
||||
animation: slideOut 0.3s ease-in forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -524,12 +524,34 @@ function closeCostWizard() {
|
||||
|
||||
// ── Cost Entry Wizard (Edit) ──
|
||||
|
||||
function openEditWizard(cost: any) {
|
||||
// Find the vehicle for this cost
|
||||
const vehicleId = cost.asset_id || cost.vehicle_id
|
||||
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
|
||||
editCostData.value = cost
|
||||
showEditWizard.value = true
|
||||
/**
|
||||
* Open the edit modal for a specific expense.
|
||||
* P0 BUGFIX (2026-07-26): Fetches the enriched expense from the new
|
||||
* GET /expenses/by-id/{id} endpoint instead of passing the raw list object.
|
||||
* This ensures category_name, parent_category_id, service_provider_name,
|
||||
* vendor_name, description, and mileage_at_cost are all resolved.
|
||||
*/
|
||||
async function openEditWizard(cost: any) {
|
||||
const expenseId = cost.id
|
||||
if (!expenseId) return
|
||||
|
||||
try {
|
||||
const res = await api.get(`/expenses/by-id/${expenseId}`)
|
||||
const enriched = res.data?.data || res.data
|
||||
|
||||
// Find the vehicle for this cost
|
||||
const vehicleId = enriched.asset_id || cost.asset_id
|
||||
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
|
||||
editCostData.value = enriched
|
||||
showEditWizard.value = true
|
||||
} catch (err: any) {
|
||||
console.error('[CostsView] Failed to fetch enriched expense:', err)
|
||||
// Fallback: use the raw list object if the endpoint fails
|
||||
const vehicleId = cost.asset_id || cost.vehicle_id
|
||||
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
|
||||
editCostData.value = cost
|
||||
showEditWizard.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditWizard() {
|
||||
|
||||
Reference in New Issue
Block a user