felhasználói felületre pü

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

View File

@@ -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 ──

View File

@@ -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>

View File

@@ -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>

View File

@@ -1779,6 +1779,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved'): void
(e: 'deleted'): void
}>()
// ── Tab Configuration ──

View 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'"
>
&larr; {{ 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>

View 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>

View File

@@ -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>

View File

@@ -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()

View File

@@ -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',

View File

@@ -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',

View 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>

View File

@@ -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 }
}
]
})

View File

@@ -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 -->

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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() {