szolgáltatók beálltásai, szerkesztése , létrehozása

This commit is contained in:
Roo
2026-06-17 11:52:25 +00:00
parent 213ba3b0f1
commit bf3a971ff1
56 changed files with 14421 additions and 1512 deletions

View File

@@ -0,0 +1,294 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
@click.self="$emit('close')"
>
<div class="bg-white w-11/12 max-w-4xl max-h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
<!-- Close button -->
<button
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
@click="$emit('close')"
>
</button>
<!-- Header -->
<div class="h-16 bg-slate-700 w-full shrink-0 flex items-center px-6">
<span class="text-white font-bold text-lg tracking-wide">📊 {{ t('finance.costManager') }}</span>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-6 text-slate-800">
<!-- Description -->
<p class="text-sm text-slate-500 mb-4">{{ t('finance.costManagerDescription') }}</p>
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-20"
>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div>
<!-- Error State -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-12 text-center"
>
<svg class="w-12 h-12 text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-sm text-slate-500">{{ error }}</p>
<button
@click="fetchCosts"
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
>
{{ t('finance.retry') }}
</button>
</div>
<!-- Cost Table -->
<div v-else>
<!-- Table Header -->
<div class="hidden md:grid grid-cols-12 gap-3 px-4 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider bg-slate-50 rounded-xl mb-2">
<div class="col-span-2">{{ t('finance.costDate') }}</div>
<div class="col-span-2">{{ t('finance.costType') }}</div>
<div class="col-span-2">{{ t('finance.costAmount') }}</div>
<div class="col-span-3">{{ t('finance.costVehicle') }}</div>
<div class="col-span-3">{{ t('finance.costDescription') }}</div>
</div>
<!-- Empty State -->
<div
v-if="costs.length === 0"
class="flex flex-col items-center justify-center py-16 text-slate-400"
>
<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.noCosts') }}</p>
</div>
<!-- Cost Rows -->
<div v-else class="space-y-2">
<div
v-for="cost in costs"
:key="cost.id"
class="grid grid-cols-1 md:grid-cols-12 gap-2 md:gap-3 px-4 py-3 rounded-xl border border-slate-100 bg-white hover:bg-slate-50 transition-colors items-center"
>
<!-- Date -->
<div class="col-span-2 text-sm text-slate-700">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costDate') }}:</span>
{{ formatDate(cost.created_at || cost.date) }}
</div>
<!-- Type -->
<div class="col-span-2">
<span
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="costTypeBadge(cost.cost_type)"
>
{{ cost.cost_type || '—' }}
</span>
</div>
<!-- Amount -->
<div class="col-span-2 text-sm font-bold text-slate-800">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costAmount') }}:</span>
{{ formatAmount(cost.amount, cost.currency) }}
</div>
<!-- Vehicle -->
<div class="col-span-3 text-sm text-slate-600 truncate">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costVehicle') }}:</span>
{{ cost.vehicle_label || cost.license_plate || '—' }}
</div>
<!-- Description -->
<div class="col-span-3 text-sm text-slate-500 truncate">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costDescription') }}:</span>
{{ cost.description || '—' }}
</div>
</div>
</div>
<!-- Pagination -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between pt-4 mt-4 border-t border-slate-100"
>
<button
@click="prevPage"
:disabled="currentPage <= 1"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="currentPage > 1
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
>
{{ t('finance.prev') }}
</button>
<span class="text-xs text-slate-400">
{{ currentPage }} / {{ totalPages }}
</span>
<button
@click="nextPage"
:disabled="currentPage >= totalPages"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="currentPage < totalPages
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
>
{{ t('finance.next') }}
</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '../../api/axios'
const { t } = useI18n()
// ── Props ──
const props = defineProps<{
isOpen: boolean
}>()
const emit = defineEmits<{
close: []
}>()
// ── State ──
const costs = ref<any[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const currentPage = ref(1)
const totalPages = ref(1)
const pageSize = 20
// ── Helpers ──
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function formatAmount(amount: number | null, currency?: string): string {
if (amount == null) return '—'
const formatted = Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})
return currency ? `${formatted} ${currency}` : `${formatted} Ft`
}
function costTypeBadge(type: string | null): string {
switch (type) {
case 'fuel':
case 'FUEL':
return 'bg-orange-100 text-orange-700'
case 'service':
case 'SERVICE':
return 'bg-blue-100 text-blue-700'
case 'insurance':
case 'INSURANCE':
return 'bg-purple-100 text-purple-700'
case 'tax':
case 'TAX':
return 'bg-red-100 text-red-700'
case 'parking':
case 'PARKING':
return 'bg-amber-100 text-amber-700'
case 'toll':
case 'TOLL':
return 'bg-yellow-100 text-yellow-700'
default:
return 'bg-slate-100 text-slate-600'
}
}
// ── Data Fetching ──
async function fetchCosts() {
isLoading.value = true
error.value = null
try {
const res = await api.get('/expenses', {
params: {
page: currentPage.value,
page_size: pageSize,
},
})
const data = res.data
costs.value = data.data || data.items || data.results || []
totalPages.value = data.pagination?.total_pages || data.total_pages || 1
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Nem sikerült betölteni a költségeket.'
error.value = message
console.error('[CostManagerModal] fetchCosts error:', err)
} finally {
isLoading.value = false
}
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
fetchCosts()
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
fetchCosts()
}
}
// ── Lifecycle ──
watch(() => props.isOpen, (open) => {
if (open) {
currentPage.value = 1
fetchCosts()
}
})
onMounted(() => {
if (props.isOpen) {
fetchCosts()
}
})
</script>
<style scoped>
@keyframes flipIn {
from {
transform: perspective(1200px) rotateY(-90deg);
opacity: 0;
}
to {
transform: perspective(1200px) rotateY(0deg);
opacity: 1;
}
}
.animate-flip-in {
animation: flipIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
}
</style>

View File

@@ -149,6 +149,7 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useCostStore } from '../../stores/cost'
import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle'
import { getLocalDateString } from '../../services/dateUtils'
const props = defineProps<{
isOpen: boolean
@@ -166,7 +167,7 @@ const costStore = useCostStore()
// ── Form State ──
const form = reactive({
date: new Date().toISOString().slice(0, 10),
date: getLocalDateString(),
amount: 0,
mainCategory: '',
subCategory: '',
@@ -281,7 +282,7 @@ async function handleSubmit() {
if (result) {
// Reset form
form.date = new Date().toISOString().slice(0, 10)
form.date = getLocalDateString()
form.amount = 0
form.mainCategory = ''
form.subCategory = ''
@@ -293,6 +294,18 @@ async function handleSubmit() {
}
}
/**
* Reset the form date to today's local date every time the modal opens.
* This ensures the date-picker always shows the correct local date,
* even if the component is kept alive between opens.
*/
watch(() => props.isOpen, (open) => {
if (open) {
form.date = getLocalDateString()
errorMessage.value = null
}
})
onMounted(() => {
fetchCategories()
})

View File

@@ -0,0 +1,180 @@
<template>
<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 cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="$emit('open-details')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-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
v-if="!isLoading && !error"
class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700"
>
{{ t('finance.live') }}
</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<!-- Loading State -->
<div
v-if="isLoading"
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="error"
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">{{ error }}</p>
<button
@click="retry"
class="mt-2 text-xs text-sf-accent hover:underline"
>
{{ t('finance.retry') }}
</button>
</div>
<!-- Balance Content -->
<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(totalCredits) }}
</p>
</div>
<!-- Breakdown: Purchased + Service Coins -->
<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(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(serviceCoins) }}</p>
</div>
</div>
<!-- Earned + Voucher mini row -->
<div class="flex items-center justify-between text-xs text-slate-400 px-1">
<span>{{ t('finance.earned') }}: <strong class="text-slate-600">{{ formatCredits(earnedCredits) }}</strong></span>
<span>{{ t('finance.voucher') }}: <strong class="text-slate-600">{{ formatCredits(voucherBalance) }}</strong></span>
</div>
</div>
</div>
<!-- Bottom CTA: Top Up button -->
<button
@click="handleTopUp"
class="mt-auto mb-4 mx-auto px-8 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>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFinanceStore } from '../../stores/finance'
const { t } = useI18n()
const financeStore = useFinanceStore()
// ── Emits ──
const emit = defineEmits<{
'open-details': []
}>()
// ── Reactive state from store ──
const isLoading = computed(() => financeStore.isLoading)
const error = computed(() => financeStore.error)
const totalCredits = computed(() => financeStore.totalCredits)
const purchasedCredits = computed(() => financeStore.purchasedCredits)
const serviceCoins = computed(() => financeStore.serviceCoins)
const earnedCredits = computed(() => financeStore.earnedCredits)
const voucherBalance = computed(() => financeStore.voucherBalance)
// ── Helpers ──
/**
* Format credit amount for display.
* Shows 2 decimal places if fractional, otherwise integer.
*/
function formatCredits(value: number): string {
if (Number.isInteger(value)) {
return value.toLocaleString()
}
return value.toFixed(2)
}
/**
* Handle the Top Up button click.
* Currently shows a toast notification — payment gateway coming soon.
*/
function handleTopUp() {
// Show a toast-like notification
const toast = document.createElement('div')
toast.className =
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
toast.textContent = '🔔 Fizetési kapu hamarosan elérhető!'
document.body.appendChild(toast)
// Auto-remove after 3 seconds
setTimeout(() => {
toast.classList.add('animate-slide-out')
setTimeout(() => toast.remove(), 300)
}, 3000)
}
/**
* Retry fetching the wallet balance.
*/
function retry() {
financeStore.fetchWalletBalance()
}
// ── Lifecycle ──
onMounted(() => {
financeStore.fetchWalletBalance()
})
</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

@@ -110,10 +110,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useCostStore } from '../../stores/cost'
import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle'
import { getLocalDateString } from '../../services/dateUtils'
const props = defineProps<{
isOpen: boolean
@@ -128,7 +129,7 @@ const emit = defineEmits<{
const costStore = useCostStore()
const form = reactive({
date: new Date().toISOString().slice(0, 10),
date: getLocalDateString(),
amount: 0,
quantity: 0,
odometer: 0,
@@ -152,6 +153,18 @@ async function resolveFuelCategory() {
}
}
/**
* Reset the form date to today's local date every time the modal opens.
* This ensures the date-picker always shows the correct local date,
* even if the component is kept alive between opens.
*/
watch(() => props.isOpen, (open) => {
if (open) {
form.date = getLocalDateString()
errorMessage.value = null
}
})
onMounted(() => {
resolveFuelCategory()
})
@@ -192,7 +205,7 @@ async function handleSubmit() {
if (result) {
// Reset form
form.date = new Date().toISOString().slice(0, 10)
form.date = getLocalDateString()
form.amount = 0
form.quantity = 0
form.odometer = 0

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,6 @@
<template>
<header
class="sticky top-0 z-50 w-full border-b border-white/[0.06] bg-[#04151F]/80 backdrop-blur-2xl"
class="sticky top-0 z-40 w-full border-b border-white/[0.06] bg-[#04151F]/80 backdrop-blur-2xl"
>
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<!-- Left Slot -->

View File

@@ -0,0 +1,299 @@
<template>
<div class="relative w-full">
<!-- Search Input -->
<div
v-if="!selectedProvider"
class="relative"
>
<input
ref="inputRef"
v-model="searchQuery"
type="text"
autocomplete="off"
:placeholder="t('provider.autocompletePlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 pl-10 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
@input="onSearchInput"
@focus="onSearchFocus"
@blur="onSearchBlur"
@keydown.down.prevent="highlightIndex < filteredResults.length - 1 && highlightIndex++"
@keydown.up.prevent="highlightIndex > 0 && highlightIndex--"
@keydown.enter.prevent="selectResult(filteredResults[highlightIndex])"
@keydown.escape="showDropdown = false"
/>
<!-- Search Icon -->
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<!-- Loading Spinner -->
<svg
v-if="isSearching"
class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-sf-accent"
fill="none" viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<!-- Selected Provider Card -->
<div
v-if="selectedProvider"
class="flex items-start gap-3 rounded-xl border border-sf-accent/30 bg-sf-accent/5 p-3"
>
<!-- Avatar Placeholder -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent font-bold text-sm">
{{ selectedProvider.name.charAt(0).toUpperCase() }}
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-800 truncate">{{ selectedProvider.name }}</p>
<p v-if="selectedProvider.category" class="text-xs text-slate-500">{{ selectedProvider.category }}</p>
<p v-if="selectedProvider.city || selectedProvider.address" class="text-xs text-slate-400 truncate">
{{ selectedProvider.city }}{{ selectedProvider.city && selectedProvider.address ? ', ' : '' }}{{ selectedProvider.address }}
</p>
</div>
<!-- Remove Button -->
<button
@click="clearSelection"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
:aria-label="t('provider.autocompleteClear')"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Dropdown Results -->
<div
v-if="showDropdown && filteredResults.length > 0"
class="absolute z-50 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-64 overflow-y-auto"
>
<button
v-for="(result, idx) in filteredResults"
:key="`${result.source}-${result.id}`"
@mousedown.prevent="selectResult(result)"
class="w-full flex items-start gap-3 px-4 py-3 text-left transition-colors border-b border-slate-100 last:border-b-0"
:class="idx === highlightIndex ? 'bg-sf-accent/10' : 'hover:bg-slate-50'"
>
<!-- Source Badge -->
<div
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="sourceBadgeClass(result.source)"
>
{{ result.name.charAt(0).toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-slate-800 truncate">{{ result.name }}</p>
<p class="text-xs text-slate-500">
<span v-if="result.category">{{ result.category }}</span>
<span v-if="result.city" class="ml-1">· {{ result.city }}</span>
</p>
<p class="text-xs text-slate-400">
<span
class="inline-block rounded-full px-1.5 py-0.5 text-[10px] font-medium"
:class="sourceLabelClass(result.source)"
>
{{ sourceLabel(result.source) }}
</span>
<span v-if="result.is_verified" class="ml-1 text-emerald-600">{{ t('provider.autocompleteVerified') }}</span>
</p>
</div>
</button>
<!-- "Add New Provider" Footer -->
<div class="border-t border-slate-200 px-3 py-2">
<button
@mousedown.prevent="$emit('open-quick-add')"
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:shadow-md cursor-pointer"
>
<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="M12 4v16m8-8H4" />
</svg>
{{ t('provider.autocompleteAddNew') }}
</button>
</div>
</div>
<!-- Empty State (no results) -->
<div
v-if="showDropdown && filteredResults.length === 0 && searchQuery.length >= 2 && !isSearching"
class="absolute z-50 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg p-4"
>
<p class="text-sm text-slate-500 text-center mb-3">{{ t('provider.autocompleteNoResults') }}</p>
<button
@mousedown.prevent="$emit('open-quick-add')"
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:shadow-md cursor-pointer"
>
<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="M12 4v16m8-8H4" />
</svg>
{{ t('provider.autocompleteAddNew') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/axios'
const { t } = useI18n()
// ── Types ──
export interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
city: string | null
address: string | null
source: 'verified_org' | 'staged_data' | 'crowd_added'
is_verified: boolean
rating: number | null
}
// ── Props ──
const props = defineProps<{
modelValue?: ProviderSearchResult | null
}>()
// ── Emits ──
const emit = defineEmits<{
(e: 'update:modelValue', value: ProviderSearchResult | null): void
(e: 'select', value: ProviderSearchResult): void
(e: 'clear'): void
(e: 'open-quick-add'): void
}>()
// ── State ──
const inputRef = ref<HTMLInputElement | null>(null)
const searchQuery = ref('')
const selectedProvider = ref<ProviderSearchResult | null>(props.modelValue || null)
const results = ref<ProviderSearchResult[]>([])
const isSearching = ref(false)
const showDropdown = ref(false)
const highlightIndex = ref(0)
// ── Debounce Timer ──
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// ── Computed ──
const filteredResults = computed(() => {
return results.value
})
// ── Source Badge / Label Helpers ──
function sourceBadgeClass(source: string): string {
switch (source) {
case 'verified_org': return 'bg-emerald-100 text-emerald-700'
case 'staged_data': return 'bg-amber-100 text-amber-700'
case 'crowd_added': return 'bg-blue-100 text-blue-700'
default: return 'bg-slate-100 text-slate-700'
}
}
function sourceLabelClass(source: string): string {
switch (source) {
case 'verified_org': return 'bg-emerald-50 text-emerald-600'
case 'staged_data': return 'bg-amber-50 text-amber-600'
case 'crowd_added': return 'bg-blue-50 text-blue-600'
default: return 'bg-slate-50 text-slate-600'
}
}
function sourceLabel(source: string): string {
switch (source) {
case 'verified_org': return t('provider.autocompleteSourceVerified')
case 'staged_data': return t('provider.autocompleteSourceRobot')
case 'crowd_added': return t('provider.autocompleteSourceCommunity')
default: return source
}
}
// ── Search API ──
async function performSearch(query: string) {
if (query.length < 2) {
results.value = []
showDropdown.value = false
return
}
isSearching.value = true
try {
const response = await api.get('providers/search', {
params: { q: query, limit: 10 },
})
results.value = response.data.results || []
highlightIndex.value = 0
showDropdown.value = true
} catch (error) {
console.error('[ProviderAutocomplete] Search error:', error)
results.value = []
showDropdown.value = false
} finally {
isSearching.value = false
}
}
// ── Event Handlers ──
function onSearchInput() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
performSearch(searchQuery.value)
}, 300)
}
function onSearchFocus() {
if (results.value.length > 0) {
showDropdown.value = true
}
}
function onSearchBlur() {
setTimeout(() => {
showDropdown.value = false
}, 200)
}
function selectResult(result: ProviderSearchResult) {
selectedProvider.value = result
searchQuery.value = ''
results.value = []
showDropdown.value = false
emit('update:modelValue', result)
emit('select', result)
}
function clearSelection() {
selectedProvider.value = null
searchQuery.value = ''
results.value = []
showDropdown.value = false
emit('update:modelValue', null)
emit('clear')
}
// ── Watch modelValue from parent ──
watch(() => props.modelValue, (val) => {
selectedProvider.value = val || null
})
</script>
<style scoped>
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 0.8s linear infinite;
}
</style>

View File

@@ -0,0 +1,277 @@
<template>
<Teleport to="body">
<div
v-if="isOpen && provider"
class="fixed inset-0 z-[10001] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="emit('close')"
>
<div
class="relative w-full max-w-lg mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-fade-in-up"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-800">{{ t('serviceFinder.detailTitle') }}</h3>
</div>
</div>
<button
@click="emit('close')"
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
:aria-label="t('serviceFinder.close')"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="p-6 space-y-5">
<!-- Provider Name -->
<div>
<h2 class="text-2xl font-extrabold text-slate-800">{{ provider.name }}</h2>
</div>
<!-- Category -->
<div v-if="provider.category" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailCategory') }}</p>
<p class="text-sm font-medium text-slate-800">{{ provider.category }}</p>
</div>
</div>
<!-- Address (P1 CRITICAL ALIGN: atomizált címmezők intelligens összefűzése) -->
<div v-if="hasAddress" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailAddress') }}</p>
<p class="text-sm font-medium text-slate-800">
{{ formattedAddress }}
</p>
</div>
</div>
<!-- Source -->
<div class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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="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>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSource') }}</p>
<p class="text-sm font-medium text-slate-800">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="sourceBadgeClass"
>
{{ sourceLabel }}
</span>
</p>
</div>
</div>
<!-- Specialization -->
<div v-if="provider.specialization && provider.specialization.length > 0" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSpecialization') }}</p>
<div class="flex flex-wrap gap-1.5 mt-1">
<span
v-for="(tag, idx) in provider.specialization"
:key="idx"
class="inline-flex items-center rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
</span>
</div>
</div>
</div>
<!-- Rating -->
<div v-if="provider.rating" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
<div class="flex items-center gap-1 mt-0.5">
<svg
v-for="star in 5"
:key="star"
class="h-4 w-4"
:class="star <= Math.round(provider.rating!) ? 'text-amber-400' : 'text-slate-200'"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span class="ml-1 text-sm font-medium text-slate-700">{{ provider.rating.toFixed(1) }}</span>
</div>
</div>
</div>
<!-- No rating fallback -->
<div v-if="!provider.rating" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
<p class="text-sm text-slate-400 italic">{{ t('serviceFinder.detailNoRating') }}</p>
</div>
</div>
<!-- Divider -->
<hr class="border-slate-200" />
<!-- Edit / Complete Data Button -->
<button
@click="emit('edit-request', provider)"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3.5 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{{ t('serviceFinder.editRequest') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// ── Types matching backend ProviderSearchResult ──
interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
city: string | null
address: string | null
address_zip: string | null
address_street_name: string | null
address_street_type: string | null
address_house_number: string | null
contact_phone: string | null
contact_email: string | null
website: string | null
tags: string[]
source: 'verified_org' | 'staged_data' | 'crowd_added'
is_verified: boolean
rating: number | null
}
// ── Props ──
const props = defineProps<{
isOpen: boolean
provider: ProviderSearchResult | null
}>()
// ── Emits ──
const emit = defineEmits<{
(e: 'close'): void
(e: 'edit-request', provider: ProviderSearchResult): void
}>()
// ── Computed helpers ──
const sourceBadgeClass = computed(() => {
switch (props.provider?.source) {
case 'verified_org': return 'bg-emerald-100 text-emerald-700'
case 'crowd_added': return 'bg-amber-100 text-amber-700'
case 'staged_data': return 'bg-slate-100 text-slate-600'
default: return 'bg-slate-100 text-slate-600'
}
})
const sourceLabel = computed(() => {
switch (props.provider?.source) {
case 'verified_org': return t('serviceFinder.sourceVerified')
case 'crowd_added': return t('serviceFinder.sourceCommunity')
case 'staged_data': return t('serviceFinder.sourceRobot')
default: return props.provider?.source || ''
}
})
/**
* P1 CRITICAL ALIGN: Atomizált címmezők intelligens összefűzése.
* A provider.address_street_name + ' ' + provider.address_street_type + ' ' + provider.address_house_number
* Ha a city is meg van adva, akkor "city, street_name street_type house_number" formátumban jelenik meg.
*/
const hasAddress = computed(() => {
const p = props.provider
if (!p) return false
return !!(p.city || p.address_street_name || p.address_street_type || p.address_house_number)
})
const formattedAddress = computed(() => {
const p = props.provider
if (!p) return ''
const parts: string[] = []
// ZIP + City
const locationParts: string[] = []
if (p.address_zip) locationParts.push(p.address_zip)
if (p.city) locationParts.push(p.city)
if (locationParts.length > 0) parts.push(locationParts.join(' '))
// Atomizált cím: street_name + street_type + house_number
const streetParts: string[] = []
if (p.address_street_name) streetParts.push(p.address_street_name)
if (p.address_street_type) streetParts.push(p.address_street_type)
if (p.address_house_number) streetParts.push(p.address_house_number)
if (streetParts.length > 0) {
parts.push(streetParts.join(' '))
}
return parts.join(', ')
})
</script>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
</style>

View File

@@ -0,0 +1,413 @@
<template>
<Teleport to="body">
<div
v-if="isOpen && provider"
class="fixed inset-0 z-[10002] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="emit('close')"
>
<div
class="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.editTitle') }}</h3>
<p class="text-xs text-slate-500">{{ provider.name }}</p>
</div>
</div>
<button
@click="emit('close')"
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
:aria-label="t('serviceFinder.close')"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Form Body -->
<div class="p-6 space-y-5">
<!-- Name -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
</label>
<input
v-model="form.name"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.namePlaceholder')"
/>
</div>
<!-- Atomizált címmezők (P1 CRITICAL ALIGN) -->
<!-- Street Name -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.streetNameLabel') }}
</label>
<input
v-model="form.address_street_name"
type="text"
maxlength="150"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.streetNamePlaceholder')"
/>
</div>
<!-- Street Type + House Number row -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.streetTypeLabel') }}
</label>
<select
v-model="form.address_street_type"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="">{{ t('provider.streetTypePlaceholder') }}</option>
<option value="utca">utca</option>
<option value="út">út</option>
<option value="tér">tér</option>
<option value="körút">körút</option>
<option value="sugárút">sugárút</option>
<option value="sétány">sétány</option>
<option value="köz">köz</option>
<option value="sor">sor</option>
<option value="liget">liget</option>
<option value="park">park</option>
<option value="rakpart">rakpart</option>
<option value="dűlő">dűlő</option>
<option value="hegy">hegy</option>
<option value="lakópark">lakópark</option>
<option value="üdülőtelep">üdülőtelep</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.houseNumberLabel') }}
</label>
<input
v-model="form.address_house_number"
type="text"
maxlength="20"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.houseNumberPlaceholder')"
/>
</div>
</div>
<!-- City + ZIP row -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.zipLabel') }}
</label>
<input
v-model="form.zip"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.zipPlaceholder')"
/>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.cityLabel') }}
</label>
<input
v-model="form.city"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.cityPlaceholder')"
/>
</div>
</div>
<!-- Phone -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.phoneLabel') }}
</label>
<input
v-model="form.phone"
type="tel"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.phonePlaceholder')"
/>
</div>
<!-- Email -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.emailLabel') }}
</label>
<input
v-model="form.email"
type="email"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.emailPlaceholder')"
/>
</div>
<!-- Website -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.websiteLabel') }}
</label>
<input
v-model="form.website"
type="url"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.websitePlaceholder')"
/>
</div>
<!-- Tags (comma-separated) -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.tagsLabel') }}
</label>
<input
v-model="form.tagsInput"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.tagsPlaceholder')"
/>
<p v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="(tag, idx) in form.tags"
:key="idx"
class="inline-flex items-center gap-1 rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
<button
@click="removeTag(idx)"
class="ml-0.5 text-sf-accent/60 hover:text-red-500 cursor-pointer"
>&times;</button>
</span>
</p>
</div>
<!-- Divider -->
<hr class="border-slate-200" />
<!-- Info text -->
<p class="text-xs text-slate-400 italic">
{{ t('provider.editInfo') }}
</p>
<!-- Action buttons -->
<div class="flex gap-3">
<button
@click="emit('close')"
class="flex-1 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:bg-slate-50 cursor-pointer"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleSave"
:disabled="isSaving || !form.name.trim()"
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 cursor-pointer"
>
<svg v-if="isSaving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else 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="M5 13l4 4L19 7" />
</svg>
{{ isSaving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
/**
* ProviderEditModal.vue — Teljes Szerkesztő Ablak szolgáltató adatokhoz.
*
* P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
* - A régi single 'street' mező helyett 3 külön mező:
* address_street_name, address_street_type, address_house_number
* - A payload pontosan az új, atomizált kulcsokkal megy a backend felé.
*
* @emits close — modal bezárása
* @emits saved — sikeres mentés után, payload: { id: number }
*
* @see backend/app/api/v1/endpoints/providers.py — PUT /providers/{id}
* @see frontend/src/components/provider/ProviderDetailModal.vue — hívó
*/
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/axios'
const { t } = useI18n()
// ── Types ──
interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
city: string | null
address: string | null
address_zip: string | null
address_street_name: string | null
address_street_type: string | null
address_house_number: string | null
contact_phone: string | null
contact_email: string | null
website: string | null
tags: string[]
source: 'verified_org' | 'staged_data' | 'crowd_added'
is_verified: boolean
rating: number | null
}
// ── Props ──
const props = defineProps<{
isOpen: boolean
provider: ProviderSearchResult | null
}>()
// ── Emits ──
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved', payload: { id: number }): void
}>()
// ── Form State ──
const isSaving = ref(false)
interface FormState {
name: string
city: string
zip: string
address_street_name: string
address_street_type: string
address_house_number: string
phone: string
email: string
website: string
tagsInput: string
tags: string[]
}
const form = reactive<FormState>({
name: '',
city: '',
zip: '',
address_street_name: '',
address_street_type: '',
address_house_number: '',
phone: '',
email: '',
website: '',
tagsInput: '',
tags: [],
})
// ── Watch provider changes → populate form ──
watch(
() => props.provider,
(provider) => {
if (provider) {
form.name = provider.name || ''
form.city = provider.city || ''
form.zip = provider.address_zip || ''
// P1 CRITICAL ALIGN: Atomizált címmezők kitöltése
form.address_street_name = provider.address_street_name || ''
form.address_street_type = provider.address_street_type || ''
form.address_house_number = provider.address_house_number || ''
form.phone = provider.contact_phone || ''
form.email = provider.contact_email || ''
form.website = provider.website || ''
form.tagsInput = ''
form.tags = provider.tags && provider.tags.length > 0
? [...provider.tags]
: provider.specialization
? [...provider.specialization]
: []
}
},
{ immediate: true }
)
// ── Tag management ──
watch(
() => form.tagsInput,
(val) => {
if (val.endsWith(',') || val.endsWith(';')) {
const newTag = val.slice(0, -1).trim()
if (newTag && !form.tags.includes(newTag)) {
form.tags.push(newTag)
}
form.tagsInput = ''
}
}
)
function removeTag(index: number) {
form.tags.splice(index, 1)
}
// ── Save handler (async — calls backend PUT /providers/{id}) ──
async function handleSave() {
if (!form.name.trim() || !props.provider) return
isSaving.value = true
try {
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
const body = {
name: form.name.trim(),
city: form.city.trim() || null,
address_zip: form.zip.trim() || null,
address_street_name: form.address_street_name.trim() || null,
address_street_type: form.address_street_type.trim() || null,
address_house_number: form.address_house_number.trim() || null,
contact_phone: form.phone.trim() || null,
contact_email: form.email.trim() || null,
website: form.website.trim() || null,
tags: form.tags.length > 0 ? [...form.tags] : null,
}
await api.put(`providers/${props.provider.id}`, body)
// Emit success so parent can refresh search results
emit('saved', { id: props.provider.id })
emit('close')
} catch (err: any) {
console.error('[ProviderEditModal] Save error:', err)
// Keep modal open on error so user can retry
} finally {
isSaving.value = false
}
}
</script>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
</style>

View File

@@ -0,0 +1,579 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[10001] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="handleClose"
>
<div
class="relative w-full max-w-lg mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-fade-in-up"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.quickAddTitle') }}</h3>
<p class="text-xs text-slate-500">{{ t('provider.quickAddSubtitle') }}</p>
</div>
</div>
<button
@click="handleClose"
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
:aria-label="t('provider.closeButton')"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Form Body -->
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
<!-- Provider Name -->
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
</label>
<input
v-model="form.name"
type="text"
required
minlength="2"
maxlength="200"
:placeholder="t('provider.namePlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<!-- Category Dropdown -->
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.categoryLabel') }} <span class="text-red-500">*</span>
</label>
<select
v-model="form.category_id"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="" disabled>{{ t('provider.categoryPlaceholder') }}</option>
<option
v-for="cat in categories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name_hu || cat.name_en || cat.key }}
</option>
</select>
</div>
<!-- ZIP Code -->
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.zipLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.zipOptional') }})</span>
</label>
<input
v-model="form.address_zip"
type="text"
maxlength="20"
:placeholder="t('provider.zipPlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<!-- City -->
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.cityLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.cityOptional') }})</span>
</label>
<input
v-model="form.city"
type="text"
maxlength="100"
:placeholder="t('provider.cityPlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<!-- Atomizált címmezők (P1 CRITICAL ALIGN) -->
<!-- Street Name -->
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.streetNameLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.streetNameOptional') }})</span>
</label>
<input
v-model="form.address_street_name"
type="text"
maxlength="150"
:placeholder="t('provider.streetNamePlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<!-- Street Type + House Number row -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.streetTypeLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.streetTypeOptional') }})</span>
</label>
<select
v-model="form.address_street_type"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="">{{ t('provider.streetTypePlaceholder') }}</option>
<option value="utca">utca</option>
<option value="út">út</option>
<option value="tér">tér</option>
<option value="körút">körút</option>
<option value="sugárút">sugárút</option>
<option value="sétány">sétány</option>
<option value="köz">köz</option>
<option value="sor">sor</option>
<option value="liget">liget</option>
<option value="park">park</option>
<option value="rakpart">rakpart</option>
<option value="dűlő">dűlő</option>
<option value="hegy">hegy</option>
<option value="lakópark">lakópark</option>
<option value="üdülőtelep">üdülőtelep</option>
</select>
</div>
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.houseNumberLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.houseNumberOptional') }})</span>
</label>
<input
v-model="form.address_house_number"
type="text"
maxlength="20"
:placeholder="t('provider.houseNumberPlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
<!-- Phone -->
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.phoneLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.phoneOptional') }})</span>
</label>
<input
v-model="form.phone"
type="tel"
maxlength="50"
:placeholder="t('provider.phonePlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<!-- Email -->
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.emailLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.emailOptional') }})</span>
</label>
<input
v-model="form.email"
type="email"
maxlength="255"
:placeholder="t('provider.emailPlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<!-- Website -->
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.websiteLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.websiteOptional') }})</span>
</label>
<input
v-model="form.website"
type="url"
maxlength="255"
:placeholder="t('provider.websitePlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<!-- Tags (comma-separated) -->
<div>
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.tagsLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.tagsOptional') }})</span>
</label>
<input
v-model="form.tagsInput"
type="text"
:placeholder="t('provider.tagsPlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
<p v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="(tag, idx) in form.tags"
:key="idx"
class="inline-flex items-center gap-1 rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
<button
@click="removeTag(idx)"
type="button"
class="ml-0.5 text-sf-accent/60 hover:text-red-500 cursor-pointer"
>&times;</button>
</span>
</p>
</div>
<!-- Soft-Deduplication Warning -->
<div
v-if="duplicateWarning"
class="rounded-lg bg-amber-50 border border-amber-300 px-4 py-3 text-sm text-amber-800"
>
<div class="flex items-start gap-2">
<svg class="h-5 w-5 mt-0.5 shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<span>{{ duplicateWarning }}</span>
</div>
</div>
<!-- Error Message -->
<div
v-if="errorMessage"
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700"
>
{{ errorMessage }}
</div>
<!-- Success Toast (gamification) -->
<div
v-if="successMessage"
class="rounded-xl bg-gradient-to-r from-emerald-50 to-emerald-100 border border-emerald-300 px-5 py-4 text-center animate-fade-in-up"
>
<div class="flex items-center justify-center gap-2 mb-1">
<span class="text-2xl">🎉</span>
<span class="text-lg font-bold text-emerald-800">{{ successMessage }}</span>
</div>
<p class="text-sm text-emerald-600 font-medium">{{ t('provider.successSubtext') }}</p>
</div>
<!-- Submit Button -->
<button
v-if="!successMessage"
type="submit"
:disabled="isSubmitting || !isFormValid"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<svg
v-if="isSubmitting"
class="h-4 w-4 animate-spin"
fill="none" viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else 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="M12 4v16m8-8H4" />
</svg>
{{ isSubmitting ? t('provider.submitting') : t('provider.submitButton') }}
</button>
<!-- Close button after success -->
<button
v-if="successMessage"
@click="handleCloseAfterSuccess"
class="w-full rounded-xl bg-sf-accent px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-accent/90 cursor-pointer"
>
{{ t('provider.closeButton') }}
</button>
</form>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/axios'
const { t } = useI18n()
// ── Types ──
interface ExpertiseCategory {
id: number
key: string
name_hu: string | null
name_en: string | null
category: string
}
interface QuickAddResponse {
id: number
name: string
status: string
message: string
earned_points: number
}
interface SearchResult {
id: number
name: string
city: string | null
address: string | null
source: string
is_verified: boolean
}
// ── Props ──
const props = defineProps<{
isOpen: boolean
}>()
// ── Emits ──
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved', provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }): void
}>()
// ── State ──
const categories = ref<ExpertiseCategory[]>([])
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const duplicateWarning = ref<string | null>(null)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const form = reactive({
name: '',
category_id: '' as string | number,
city: '',
address_zip: '',
address_street_name: '',
address_street_type: '',
address_house_number: '',
phone: '',
email: '',
website: '',
tagsInput: '',
tags: [] as string[],
})
// ── Computed ──
const isFormValid = computed(() => {
return form.name.trim().length >= 2 && !!form.category_id
})
// ── Soft-Deduplication: Debounced search on name/city/zip changes ──
async function checkDuplicates() {
const name = form.name.trim()
if (name.length < 3) {
duplicateWarning.value = null
return
}
try {
const params: Record<string, string | number> = { q: name, limit: 3 }
if (form.city.trim()) params.city = form.city.trim()
else if (form.address_zip.trim()) params.city = form.address_zip.trim()
const res = await api.get('providers/search', { params })
const data = res.data as { results: SearchResult[]; total: number }
if (data.results && data.results.length > 0) {
const names = data.results.map((r: SearchResult) => r.name)
if (names.length === 1) {
duplicateWarning.value = t('provider.duplicateWarningShort', { name: names[0] })
} else {
duplicateWarning.value = t('provider.duplicateWarning', {
matches: names.slice(0, 3).join(', '),
})
}
} else {
duplicateWarning.value = null
}
} catch {
// Silent fail — deduplication is non-blocking
duplicateWarning.value = null
}
}
function onFormFieldChange() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
checkDuplicates()
}, 500)
}
// ── Fetch Categories ──
async function fetchCategories() {
try {
const res = await api.get('providers/categories')
categories.value = res.data || []
} catch {
categories.value = [
{ id: 1, key: 'auto_szerelo', name_hu: 'Autószerelő', name_en: 'Car Mechanic', category: 'vehicle_service' },
{ id: 2, key: 'motor_szerelo', name_hu: 'Motorkerékpár szerelő', name_en: 'Motorcycle Mechanic', category: 'vehicle_service' },
{ id: 3, key: 'gumiszerviz', name_hu: 'Gumiszerviz', name_en: 'Tire Service', category: 'vehicle_service' },
{ id: 4, key: 'karosszerialakatos', name_hu: 'Karosszérialakatos', name_en: 'Body Shop', category: 'body_paint' },
{ id: 5, key: 'fényező', name_hu: 'Fényező', name_en: 'Painter', category: 'body_paint' },
{ id: 6, key: 'autómentő', name_hu: 'Autómentő / Motormentő', name_en: 'Tow Truck / Roadside', category: 'roadside' },
{ id: 7, key: 'benzinkút', name_hu: 'Benzinkút', name_en: 'Gas Station', category: 'fuel' },
{ id: 8, key: 'alkatrész_kereskedés', name_hu: 'Alkatrész kereskedés', name_en: 'Parts Store', category: 'parts' },
{ id: 9, key: 'vizsgaállomás', name_hu: 'Vizsgaállomás', name_en: 'Inspection Station', category: 'inspection' },
{ id: 10, key: 'autókozmetika', name_hu: 'Autókozmetika', name_en: 'Car Detailing', category: 'detailing' },
{ id: 11, key: 'egyéb', name_hu: 'Egyéb szolgáltató', name_en: 'Other Service', category: 'other' },
]
}
}
// ── Reset Form ──
function resetForm() {
form.name = ''
form.category_id = ''
form.city = ''
form.address_zip = ''
form.address_street_name = ''
form.address_street_type = ''
form.address_house_number = ''
form.phone = ''
form.email = ''
form.website = ''
form.tagsInput = ''
form.tags = []
errorMessage.value = null
successMessage.value = null
duplicateWarning.value = null
isSubmitting.value = false
}
// ── Submit ──
async function handleSubmit() {
if (!isFormValid.value) return
isSubmitting.value = true
errorMessage.value = null
successMessage.value = null
try {
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
const payload = {
name: form.name.trim(),
category_id: Number(form.category_id),
city: form.city.trim() || null,
address_zip: form.address_zip.trim() || null,
address_street_name: form.address_street_name.trim() || null,
address_street_type: form.address_street_type.trim() || null,
address_house_number: form.address_house_number.trim() || null,
contact_phone: form.phone.trim() || null,
contact_email: form.email.trim() || null,
website: form.website.trim() || null,
tags: form.tags.length > 0 ? [...form.tags] : null,
}
const response = await api.post<QuickAddResponse>('providers/quick-add', payload)
const data = response.data
// ── Success: Show Gamification Toast ──
successMessage.value = t('provider.successMessage', { points: data.earned_points })
// Build a display address from atomized fields
const addressParts = [
form.address_street_name,
form.address_street_type,
form.address_house_number,
].filter(Boolean)
const displayAddress = addressParts.length > 0 ? addressParts.join(' ') : null
// Emit the saved provider back to parent
emit('saved', {
id: data.id,
name: data.name,
category: form.category_id ? categories.value.find(c => c.id === Number(form.category_id))?.name_hu || null : null,
city: form.city || null,
address: displayAddress,
})
} catch (error: any) {
console.error('[ProviderQuickAddModal] Submit error:', error)
if (error.response?.data?.detail) {
errorMessage.value = error.response.data.detail
} else {
errorMessage.value = t('provider.errorMessage')
}
} finally {
isSubmitting.value = false
}
}
// ── Tag management ──
watch(
() => form.tagsInput,
(val) => {
if (val.endsWith(',') || val.endsWith(';')) {
const newTag = val.slice(0, -1).trim()
if (newTag && !form.tags.includes(newTag)) {
form.tags.push(newTag)
}
form.tagsInput = ''
}
}
)
function removeTag(index: number) {
form.tags.splice(index, 1)
}
// ── Close Handlers ──
function handleClose() {
if (isSubmitting.value) return
resetForm()
emit('close')
}
function handleCloseAfterSuccess() {
resetForm()
emit('close')
}
// ── Watch Modal Open ──
watch(() => props.isOpen, (open) => {
if (open) {
resetForm()
fetchCategories()
}
})
// ── Watch form fields for soft-deduplication ──
watch(() => form.name, () => { onFormFieldChange() })
watch(() => form.city, () => { onFormFieldChange() })
watch(() => form.address_zip, () => { onFormFieldChange() })
onMounted(() => {
fetchCategories()
})
</script>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 0.8s linear infinite;
}
</style>

View File

@@ -21,7 +21,7 @@
</div>
<div>
<div class="flex items-center gap-2">
<h3 class="text-lg font-bold text-slate-800">{{ vehicle?.brand }} {{ vehicle?.model }}</h3>
<h3 class="text-lg font-bold text-slate-800">{{ vehicleDisplayName }}</h3>
<!-- Primary Vehicle Star -->
<button
v-if="vehicle"
@@ -64,8 +64,8 @@
</div>
<!--
3-TAB NAVIGATION
-->
4-TAB NAVIGATION
-->
<div class="px-6">
<div class="flex border-b border-slate-200 gap-1">
<button
@@ -100,8 +100,8 @@
<!-- Body / Karosszéria -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Karosszéria</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.vehicle_class || vehicle?.body_type || '—' }}</p>
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">{{ t('vehicle.label_body_type') }}</p>
<p class="text-sm font-bold text-slate-800">{{ bodyTypeDisplay }}</p>
</div>
<!-- VIN / Alvázszám -->
@@ -118,14 +118,14 @@
<!-- Transmission -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Váltó</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.transmission_type || '—' }}</p>
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">{{ t('vehicle.label_transmission') }}</p>
<p class="text-sm font-bold text-slate-800">{{ transmissionDisplay }}</p>
</div>
<!-- Drive Type -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Hajtás</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.drive_type || '—' }}</p>
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">{{ t('vehicle.label_drive_type') }}</p>
<p class="text-sm font-bold text-slate-800">{{ driveTypeDisplay }}</p>
</div>
<!-- Fuel Type -->
@@ -137,7 +137,99 @@
<!-- Trim Level / Felszereltség -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Felszereltség</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.trim_level || '—' }}</p>
<p class="text-sm font-bold text-slate-800">{{ trimLevelDisplay }}</p>
</div>
</div>
<!-- EV / Plugin Hybrid section (from individual_equipment.ev_specs) -->
<div v-if="vehicle?.individual_equipment?.ev_specs" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<div class="flex items-center gap-2 mb-3">
<svg class="h-5 w-5 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-sm font-bold text-emerald-800">Elektromos Hajtás / Akkumulátor</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-if="vehicle.individual_equipment.ev_specs.battery_capacity_kwh">
<p class="text-xs text-slate-500">Akkumulátor</p>
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.battery_capacity_kwh }} kWh</p>
</div>
<div v-if="vehicle.individual_equipment.ev_specs.battery_soh">
<p class="text-xs text-slate-500">SoH</p>
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.battery_soh }}%</p>
</div>
<div v-if="vehicle.individual_equipment.ev_specs.range_wltp">
<p class="text-xs text-slate-500">Hatótáv (WLTP)</p>
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.range_wltp }} km</p>
</div>
<div v-if="vehicle.individual_equipment.ev_specs.range_highway">
<p class="text-xs text-slate-500">Hatótáv (Országút)</p>
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.range_highway }} km</p>
</div>
<div v-if="vehicle.individual_equipment.ev_specs.range_winter">
<p class="text-xs text-slate-500">Hatótáv (Téli)</p>
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.range_winter }} km</p>
</div>
<div v-if="vehicle.individual_equipment.ev_specs.ac_connector">
<p class="text-xs text-slate-500">AC töltés</p>
<p class="text-sm font-semibold text-slate-800">{{ getConnectorLabel(vehicle.individual_equipment.ev_specs.ac_connector) }}{{ vehicle.individual_equipment.ev_specs.ac_power_kw ? ` (${vehicle.individual_equipment.ev_specs.ac_power_kw} kW)` : '' }}</p>
</div>
<div v-if="vehicle.individual_equipment.ev_specs.dc_connector">
<p class="text-xs text-slate-500">DC töltés</p>
<p class="text-sm font-semibold text-slate-800">{{ getConnectorLabel(vehicle.individual_equipment.ev_specs.dc_connector) }}{{ vehicle.individual_equipment.ev_specs.dc_power_kw ? ` (${vehicle.individual_equipment.ev_specs.dc_power_kw} kW)` : '' }}</p>
</div>
<div v-if="vehicle.individual_equipment.ev_specs.fast_charge_support">
<p class="text-xs text-slate-500">Gyorstöltés</p>
<p class="text-sm font-semibold text-emerald-700"> Támogatott</p>
</div>
<div v-if="vehicle.individual_equipment.ev_specs.green_plate_eligible">
<p class="text-xs text-slate-500">Zöld rendszám</p>
<p class="text-sm font-semibold text-emerald-700"> Jogosult</p>
</div>
</div>
</div>
<!-- Features / Extrák badges (from individual_equipment.features) -->
<div v-if="vehicleFeatures?.length" class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">{{ t('vehicle.label_features') }}</p>
<div class="flex flex-wrap gap-1.5">
<span
v-for="feat in vehicleFeatures"
:key="feat"
class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 border border-emerald-200 shadow-sm"
>
{{ t('vehicle.features.' + feat, feat) }}
</span>
</div>
</div>
<!-- Dates section (from individual_equipment.dates) -->
<div v-if="vehicle?.individual_equipment?.dates" class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">{{ t('vehicle.label_dates') }}</p>
<div class="grid grid-cols-2 gap-3">
<div v-if="vehicle.individual_equipment.dates.mot_expiry">
<p class="text-xs text-slate-500">{{ t('vehicle.label_mot_expiry') }}</p>
<p class="text-sm font-semibold text-slate-800">{{ formatDate(vehicle.individual_equipment.dates.mot_expiry) }}</p>
</div>
<div v-if="vehicle.individual_equipment.dates.document_expiry">
<p class="text-xs text-slate-500">{{ t('vehicle.label_document_expiry') }}</p>
<p class="text-sm font-semibold text-slate-800">{{ formatDate(vehicle.individual_equipment.dates.document_expiry) }}</p>
</div>
</div>
</div>
<!-- Document numbers section (from individual_equipment.documents) -->
<div v-if="vehicle?.individual_equipment?.documents" class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">{{ t('vehicle.label_documents') }}</p>
<div class="grid grid-cols-2 gap-3">
<div v-if="vehicle.individual_equipment.documents.registration_cert_number">
<p class="text-xs text-slate-500">{{ t('vehicle.label_reg_cert_number') }}</p>
<p class="text-sm font-semibold text-slate-800 font-mono">{{ vehicle.individual_equipment.documents.registration_cert_number }}</p>
</div>
<div v-if="vehicle.individual_equipment.documents.title_cert_number">
<p class="text-xs text-slate-500">{{ t('vehicle.label_title_cert_number') }}</p>
<p class="text-sm font-semibold text-slate-800 font-mono">{{ vehicle.individual_equipment.documents.title_cert_number }}</p>
</div>
</div>
</div>
@@ -299,6 +391,20 @@
</div>
</template>
<!-- Statistics cards (Average Consumption & Cost per km) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Fogyasztás</p>
<p class="text-2xl font-extrabold text-slate-800">
{{ vehicle?.fuel_type === 'electric' ? '—' : (vehicle?.individual_equipment?.avg_consumption ? vehicle.individual_equipment.avg_consumption : '—') }} <span class="text-sm font-semibold text-slate-500">{{ vehicle?.fuel_type === 'electric' ? 'kWh/100km' : 'l/100km' }}</span>
</p>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Km Költség</p>
<p class="text-2xl font-extrabold text-slate-800">{{ avgCostPerKm }} <span class="text-sm font-semibold text-slate-500">Ft/km</span></p>
</div>
</div>
<!-- TCO per km hint (visible to all) -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500">
@@ -312,64 +418,63 @@
<!-- TAB 3: Figyelmeztetések & Időpontok -->
<div v-if="activeTab === 'alerts'" class="space-y-4">
<!-- Task 6: Statistics moved here from Tab 2 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Fogyasztás</p>
<p class="text-2xl font-extrabold text-slate-800">
{{ vehicle?.fuel_type === 'electric' ? '—' : '8.2' }} <span class="text-sm font-semibold text-slate-500">{{ vehicle?.fuel_type === 'electric' ? 'kWh/100km' : 'l/100km' }}</span>
</p>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Km Költség</p>
<p class="text-2xl font-extrabold text-slate-800">62 <span class="text-sm font-semibold text-slate-500">Ft/km</span></p>
</div>
</div>
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Idővonal Közelgő események</p>
<div class="space-y-3">
<!-- Alert 1: Insurance renewal -->
<div class="flex items-start gap-4 rounded-xl border border-amber-200 bg-amber-50/50 p-4">
<!-- Alert 1: Insurance renewal dynamic from individual_equipment JSONB -->
<div
v-if="vehicle?.individual_equipment?.insurance_due_date"
class="flex items-start gap-4 rounded-xl border border-amber-200 bg-amber-50/50 p-4"
>
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-600 text-base shrink-0">📋</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-slate-800">Kötelező biztosítás fordulónap</p>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-[10px] font-bold text-amber-700 uppercase">30 nap</span>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-[10px] font-bold text-amber-700 uppercase">{{ daysUntil(vehicle.individual_equipment.insurance_due_date) }} nap</span>
</div>
<p class="text-xs text-slate-500 mt-0.5">Lemondási határidő: 2026. 07. 12. Jelenlegi díj: 85,000 Ft/év</p>
<div class="flex gap-2 mt-2">
<p class="text-xs text-slate-500 mt-0.5">
Lemondási határidő: {{ formatDate(vehicle.individual_equipment.insurance_due_date) }}
<template v-if="vehicle.individual_equipment.insurance_premium">
Jelenlegi díj: {{ formatAmount(vehicle.individual_equipment.insurance_premium) }}/év
</template>
</p>
<div v-if="daysUntil(vehicle.individual_equipment.insurance_due_date) <= 30" class="flex gap-2 mt-2">
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-600"> Lemondási határidő</span>
</div>
</div>
</div>
<!-- Alert 2: MOT / Műszaki vizsga -->
<div class="flex items-start gap-4 rounded-xl border border-red-200 bg-red-50/50 p-4">
<!-- Alert 2: MOT / Műszaki vizsga dynamic from individual_equipment JSONB -->
<div
v-if="vehicle?.individual_equipment?.mot_due_date"
class="flex items-start gap-4 rounded-xl border border-red-200 bg-red-50/50 p-4"
>
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-red-100 text-red-600 text-base shrink-0">🔧</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-slate-800">Műszaki vizsga lejárata</p>
<span class="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-[10px] font-bold text-red-700 uppercase">14 nap</span>
<span class="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-[10px] font-bold text-red-700 uppercase">{{ daysUntil(vehicle.individual_equipment.mot_due_date) }} nap</span>
</div>
<p class="text-xs text-slate-500 mt-0.5">Lejárat: 2026. 06. 26. Hátralévő napok: 14</p>
<div class="flex gap-2 mt-2">
<p class="text-xs text-slate-500 mt-0.5">
Lejárat: {{ formatDate(vehicle.individual_equipment.mot_due_date) }} Hátralévő napok: {{ daysUntil(vehicle.individual_equipment.mot_due_date) }}
</p>
<div v-if="daysUntil(vehicle.individual_equipment.mot_due_date) <= 30" class="flex gap-2 mt-2">
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-600">🔴 Sürgős</span>
</div>
</div>
</div>
<!-- Alert 3: Next service -->
<!-- Alert 3: Next service dynamic from current_mileage -->
<div class="flex items-start gap-4 rounded-xl border border-blue-200 bg-blue-50/50 p-4">
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-blue-100 text-blue-600 text-base shrink-0"></span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-slate-800">Következő szerviz</p>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase">45 nap</span>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase">{{ nextServiceDays }} nap</span>
</div>
<p class="text-xs text-slate-500 mt-0.5">
{{ vehicle?.current_mileage
? `Jelenleg: ${vehicle.current_mileage.toLocaleString()} km — Következő: ${(vehicle.current_mileage + 15000).toLocaleString()} km`
? `Jelenleg: ${vehicle.current_mileage.toLocaleString()} km — Következő: ${nextServiceKm.toLocaleString()} km`
: '15,000 km vagy 1 év' }}
</p>
<div class="flex gap-2 mt-2">
@@ -378,19 +483,245 @@
</div>
</div>
<!-- Alert 4: Seasonal tire change -->
<div class="flex items-start gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<!-- Alert 4: Seasonal tire change dynamic from individual_equipment JSONB -->
<div
v-if="vehicle?.individual_equipment?.tire_change_date"
class="flex items-start gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4"
>
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-200 text-slate-500 text-base shrink-0">🛞</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-slate-800">Szezonális gumicsere</p>
<span class="inline-flex items-center rounded-full bg-slate-200 px-2.5 py-0.5 text-[10px] font-bold text-slate-600 uppercase">90 nap</span>
<span class="inline-flex items-center rounded-full bg-slate-200 px-2.5 py-0.5 text-[10px] font-bold text-slate-600 uppercase">{{ daysUntil(vehicle.individual_equipment.tire_change_date) }} nap</span>
</div>
<p class="text-xs text-slate-500 mt-0.5">Téli Nyári gumi: 2026. 09. 10. (ajánlott időszak)</p>
<p class="text-xs text-slate-500 mt-0.5">{{ vehicle.individual_equipment.tire_change_description || 'Téli → Nyári gumi: ' + formatDate(vehicle.individual_equipment.tire_change_date) + ' (ajánlott időszak)' }}</p>
</div>
</div>
<!-- Empty state when no alerts are configured -->
<div
v-if="!vehicle?.individual_equipment?.insurance_due_date && !vehicle?.individual_equipment?.mot_due_date && !vehicle?.individual_equipment?.tire_change_date"
class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center"
>
<p class="text-sm text-slate-500">Nincsenek beállított figyelmeztetések ehhez a járműhöz.</p>
<p class="text-xs text-slate-400 mt-1">A figyelmeztetések a jármű szerkesztésekor adhatók hozzá.</p>
</div>
</div>
</div>
<!-- TAB 4: Szervizkönyv (Service Book) -->
<div v-if="activeTab === 'servicebook'" class="space-y-5">
<!-- Loading State -->
<div
v-if="eventsLoading"
class="flex items-center justify-center py-12"
>
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div>
<template v-if="!eventsLoading">
<!-- Add Event Button (opens inline form) -->
<div class="flex justify-end">
<button
v-if="!showAddForm"
@click="showAddForm = true"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-4 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue cursor-pointer"
>
<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="M12 4v16m8-8H4" />
</svg>
{{ $t('servicebook.addEvent') }}
</button>
</div>
<!-- Inline Add Event Form -->
<div
v-if="showAddForm"
class="rounded-xl border border-sf-accent/30 bg-sf-accent/5 p-5 space-y-4"
>
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-slate-700">{{ $t('servicebook.addEvent') }}</p>
<button
@click="showAddForm = false"
class="text-slate-400 hover:text-red-500 cursor-pointer"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Event Date -->
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.eventDate') }}</label>
<input
v-model="newEvent.event_date"
type="date"
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent"
/>
</div>
<!-- Odometer -->
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.odometer') }}</label>
<input
v-model.number="newEvent.odometer_reading"
type="number"
min="0"
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent"
:placeholder="String(vehicle?.current_mileage || 0)"
/>
</div>
<!-- Event Type -->
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.eventType') }}</label>
<select
v-model="newEvent.event_type"
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent"
>
<option value="" disabled>{{ $t('servicebook.eventType') }}</option>
<option v-for="(label, key) in eventTypeOptions" :key="key" :value="key">
{{ label }}
</option>
</select>
</div>
<!-- Cost Amount (optional, no spinners) -->
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.costAmount') }}</label>
<div class="relative">
<input
v-model.number="newEvent.cost_amount"
type="number"
min="0"
step="0.01"
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="$t('servicebook.costPlaceholder')"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400">HUF</span>
</div>
</div>
<!-- Provider / Service Center (full width) -->
<div class="sm:col-span-2">
<label class="block text-xs font-semibold text-slate-500 mb-1">
Szolgáltató / Szerviz <span class="text-slate-400 text-xs">(opcionális)</span>
</label>
<ProviderAutocomplete
v-model="selectedProvider"
@select="onProviderSelect"
@clear="onProviderClear"
@open-quick-add="isQuickAddOpen = true"
/>
</div>
<!-- Description (full width) -->
<div class="sm:col-span-2">
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.description') }}</label>
<textarea
v-model="newEvent.description"
rows="3"
maxlength="2000"
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent resize-none"
:placeholder="$t('servicebook.descriptionPlaceholder')"
></textarea>
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-end gap-3 pt-2">
<button
@click="showAddForm = false"
class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-600 transition-all hover:bg-slate-100 cursor-pointer"
>
{{ $t('servicebook.cancel') }}
</button>
<button
@click="submitEvent"
:disabled="savingEvent || !newEvent.event_type"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<svg v-if="savingEvent" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ savingEvent ? $t('servicebook.saving') : t('common.save') }}
</button>
</div>
</div>
<!-- Timeline of Service Events -->
<div v-if="serviceEvents.length === 0" class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center">
<p class="text-sm text-slate-500">{{ $t('servicebook.noEvents') }}</p>
</div>
<div v-else class="relative">
<!-- Timeline vertical line -->
<div class="absolute left-5 top-0 bottom-0 w-0.5 bg-slate-200"></div>
<div class="space-y-4">
<div
v-for="event in serviceEvents"
:key="event.id"
class="relative pl-12"
>
<!-- Timeline dot -->
<div
class="absolute left-3.5 top-1.5 h-3.5 w-3.5 rounded-full border-2 shadow-sm"
:class="getEventDotColor(event.event_type)"
></div>
<!-- Event card -->
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wider"
:class="getEventBadgeColor(event.event_type)"
>
{{ getEventTypeLabel(event.event_type) }}
</span>
<span class="text-xs text-slate-400">
{{ formatDate(event.event_date) }}
</span>
<span v-if="event.odometer_reading" class="text-xs text-slate-400">
{{ $t('servicebook.atOdometer', { km: event.odometer_reading.toLocaleString() }) }}
</span>
</div>
<p v-if="event.description" class="text-sm text-slate-600 mt-2 whitespace-pre-wrap">
{{ event.description }}
</p>
<!-- Cost display on timeline -->
<div
v-if="event.cost_amount && event.cost_amount > 0"
class="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-emerald-50 border border-emerald-200 px-2.5 py-1"
>
<svg class="w-3.5 h-3.5 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xs font-semibold text-emerald-700">
{{ formatAmount(event.cost_amount) }}
</span>
<span v-if="event.currency" class="text-[10px] text-emerald-500 font-medium">{{ event.currency }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Provider Quick Add Modal (Teleported) -->
<ProviderQuickAddModal
:is-open="isQuickAddOpen"
@close="isQuickAddOpen = false"
@saved="onQuickAddSaved"
/>
</div>
<!--
@@ -422,10 +753,18 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getLocalDateString } from '../../services/dateUtils'
import type { VehicleData } from '../../types/vehicle'
import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle'
import api from '../../api/axios'
import VehiclePlateBadge from './VehiclePlateBadge.vue'
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
const { t } = useI18n()
const props = defineProps<{
isOpen: boolean
@@ -440,21 +779,64 @@ const emit = defineEmits<{
const authStore = useAuthStore()
// ── Computed display helpers ──
const vehicleDisplayName = computed(() => {
if (!props.vehicle) return ''
const brand = props.vehicle.brand || ''
const model = props.vehicle.model || ''
// Deduplicate: if model already starts with brand, only show model
if (model.toLowerCase().startsWith(brand.toLowerCase())) {
return model
}
return [brand, model].filter(Boolean).join(' ')
})
const bodyTypeDisplay = computed(() => {
if (!props.vehicle) return '—'
// Prefer body_type from the vehicle root, fall back to individual_equipment.car_specs.body_type
const bodyType = props.vehicle.body_type || props.vehicle.individual_equipment?.car_specs?.body_type
if (!bodyType) return '—'
return t('vehicle.bodyTypes.' + bodyType, bodyType)
})
const transmissionDisplay = computed(() => {
if (!props.vehicle?.transmission_type) return '—'
return t('vehicle.transmissionTypes.' + props.vehicle.transmission_type, props.vehicle.transmission_type)
})
const driveTypeDisplay = computed(() => {
if (!props.vehicle?.drive_type) return '—'
return t('vehicle.driveTypes.' + props.vehicle.drive_type, props.vehicle.drive_type)
})
const trimLevelDisplay = computed(() => {
if (!props.vehicle?.trim_level) return '—'
return props.vehicle.trim_level
})
const vehicleFeatures = computed(() => {
if (!props.vehicle?.individual_equipment?.features) return []
const features = props.vehicle.individual_equipment.features
return Array.isArray(features) ? features : []
})
// ── Tab state ──
const activeTab = ref<'basics' | 'finance' | 'alerts'>('basics')
const activeTab = ref<'basics' | 'finance' | 'alerts' | 'servicebook'>('basics')
const tabs = [
{ id: 'basics' as const, label: 'Alapadatok', icon: '📋' },
{ id: 'finance' as const, label: 'Pénzügyek (TCO)', icon: '💰' },
{ id: 'alerts' as const, label: 'Figyelmeztetések', icon: '🔔' },
{ id: 'servicebook' as const, label: 'Szervizkönyv', icon: '📖' },
]
// ── Task 2: Reset activeTab to 'basics' every time modal opens ──
// ── Reset state every time modal opens ──
watch(() => props.isOpen, (newVal) => {
if (newVal) {
activeTab.value = 'basics'
vehicleCosts.value = []
costsLoading.value = false
resetNewEventForm()
}
})
@@ -561,6 +943,104 @@ function formatAmount(amount: number): string {
return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: 'HUF', maximumFractionDigits: 0 }).format(amount)
}
// ── Connector label helper (for EV section) ──
function getConnectorLabel(connector: string): string {
const labels: Record<string, string> = {
type1: 'Type 1 (SAE J1772)',
type2: 'Type 2 (Mennekes)',
type3: 'Type 3 (Scame)',
gb_t: 'GB/T (Kína)',
ccs: 'CCS',
chademo: 'CHAdeMO',
tesla_supercharger: 'Tesla Supercharger',
gb_t_dc: 'GB/T DC (Kína)',
nacs: 'NACS',
}
return labels[connector] || connector
}
// ── Feature label helper (for features badges) ──
function getFeatureLabel(featureKey: string): string {
const labels: Record<string, string> = {
abs: 'ABS',
esp: 'ESP',
traction_control: 'Kipörgésgátló',
hill_start_assist: 'Hegyi start segéd',
hill_descent_control: 'Lejtmenet szabályzó',
cruise_control: 'Tempomat',
adaptive_cruise_control: 'Adaptív tempomat',
lane_assist: 'Sávtartó',
blind_spot: 'Holtpont figyelő',
parking_sensors_front: 'Első parkoló szenzorok',
parking_sensors_rear: 'Hátsó parkoló szenzorok',
rear_view_camera: 'Tolatókamera',
'360_camera': '360° kamera',
start_stop: 'Start-Stop',
keyless_go: 'Kulcs nélküli indítás',
auto_hold: 'Auto Hold',
airbag_driver: 'Vezető légzsák',
airbag_passenger: 'Utas légzsák',
airbag_side: 'Oldallégzsák',
airbag_curtain: 'Függönylégzsák',
isofix: 'Isofix',
tpms: 'Guminyomás monitor',
night_vision: 'Éjjellátó',
traffic_sign_recognition: 'Táblafelismerő',
driver_alert: 'Fáradtság figyelő',
leather_seats: 'Bőr ülések',
heated_seats_front: 'Első ülésfűtés',
heated_seats_rear: 'Hátsó ülésfűtés',
ventilated_seats: 'Szellőztetett ülések',
massage_seats: 'Masszázs ülések',
sport_seats: 'Sportülések',
electric_seat_adjust: 'Elektromos ülésállítás',
memory_seats: 'Memóriás ülések',
heated_steering_wheel: 'Fűtött kormány',
panoramic_roof: 'Panoráma tető',
sunroof: 'Napfénytető',
ambient_lighting: 'Hangulatvilágítás',
auto_dimming_mirror: 'Automata sötétedő tükör',
rear_blind: 'Hátsó roló',
trunk_cover: 'Csomagtér takaró',
alloy_wheels: 'Könnyűfém felni',
winter_tires: 'Téli gumi',
roof_rails: 'Tetősín',
tow_bar: 'Vontató',
tinted_windows: 'Színezett üvegek',
xenon_headlights: 'Xenon fényszóró',
led_headlights: 'LED fényszóró',
adaptive_headlights: 'Adaptív fényszóró',
fog_lights: 'Ködlámpa',
rain_sensor: 'Esőérzékelő',
light_sensor: 'Fényérzékelő',
electric_trunk: 'Elektromos csomagtér',
side_steps: 'Oldallépcső',
mudguards: 'Sárvédő',
navigation: 'Navigáció',
apple_carplay: 'Apple CarPlay',
android_auto: 'Android Auto',
bluetooth: 'Bluetooth',
usb_charging: 'USB töltő',
wireless_charging: 'Vezeték nélküli töltő',
head_up_display: 'Head-up Display',
digital_cockpit: 'Digitális műszerfal',
premium_sound: 'Prémium hangrendszer',
dab_radio: 'DAB rádió',
wifi_hotspot: 'WiFi hotspot',
voice_control: 'Hangvezérlés',
garage_kept: 'Garázsban tartott',
smoke_free: 'Nem dohányzó',
pet_free: 'Háziállat mentes',
service_book: 'Szervizkönyvvel',
original_mileage: 'Igazolt futásteljesítmény',
first_owner: 'Első tulajdonos',
fleet_vehicle: 'Flottás jármű',
imported: 'Importált',
accident_free: 'Balesetmentes',
}
return labels[featureKey] || featureKey
}
// ── Task 4: Trust Score computed helpers ──
const trustScorePercent = computed(() => {
const v = props.vehicle
@@ -623,7 +1103,36 @@ const engineDisplay = computed(() => {
const hpDisplay = computed(() => {
const kw = props.vehicle?.power_kw
if (!kw) return '—'
return `${Math.round(kw * 1.341)} LE`
return `${Math.round(kw * 1.35962)} LE`
})
// ── Alerts tab computed helpers (dynamic, no hardcoded values) ──
function daysUntil(dateStr: string): number {
if (!dateStr) return 999
const target = new Date(dateStr)
if (isNaN(target.getTime())) return 999
const now = new Date()
const diff = Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
return Math.max(0, diff)
}
const avgCostPerKm = computed(() => {
const mileage = props.vehicle?.current_mileage || props.vehicle?.mileage
if (!mileage || mileage === 0) return '—'
// Use individual_equipment.avg_cost_per_km if stored, otherwise derive from TCO
const stored = props.vehicle?.individual_equipment?.avg_cost_per_km
if (stored) return stored
return '—'
})
const nextServiceKm = computed(() => {
const mileage = props.vehicle?.current_mileage || 0
return mileage + 15000
})
const nextServiceDays = computed(() => {
// Default: 45 days if no specific data
return props.vehicle?.individual_equipment?.next_service_days || 45
})
// ── Financial computed helpers ──
@@ -719,6 +1228,256 @@ const costDistribution = computed<DistributionItem[]>(() => {
}))
.sort((a, b) => b.percent - a.percent)
})
// ═══════════════════════════════════════════════════════════════════
// SERVICE BOOK (Szervizkönyv) — AssetEvent management
// ═══════════════════════════════════════════════════════════════════
interface ServiceEvent {
id: string
asset_id: string
event_type: string
event_date: string
odometer_reading: number | null
description: string | null
cost_id: string | null
cost_amount: number | null
currency: string | null
created_at: string
}
const serviceEvents = ref<ServiceEvent[]>([])
const eventsLoading = ref(false)
const showAddForm = ref(false)
const savingEvent = ref(false)
/**
* Default new event form state.
*/
const newEvent = ref<{
event_date: string
odometer_reading: number | null
event_type: string
description: string
cost_amount: number | null
}>({
event_date: getLocalDateString(),
odometer_reading: null,
event_type: '',
description: '',
cost_amount: null,
})
// ── Provider Autocomplete State ──
const selectedProvider = ref<ProviderSearchResult | null>(null)
const isQuickAddOpen = ref(false)
/**
* Called when a provider is selected from the autocomplete dropdown.
* Stores the provider reference for the event submission.
*/
function onProviderSelect(provider: ProviderSearchResult) {
selectedProvider.value = provider
}
/**
* Called when the selected provider is cleared.
*/
function onProviderClear() {
selectedProvider.value = null
}
/**
* Called when the ProviderQuickAddModal emits 'saved' after a successful creation.
* Auto-selects the newly created provider and shows a gamification toast.
*/
function onQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
isQuickAddOpen.value = false
// Auto-select the newly created provider
selectedProvider.value = {
id: provider.id,
name: provider.name,
category: provider.category || null,
specialization: [],
city: provider.city || null,
address: provider.address || null,
source: 'crowd_added',
is_verified: false,
rating: null,
}
// Show gamification toast
alert(`🎉 Szereztél pontokat! A(z) "${provider.name}" sikeresen rögzítve és kiválasztva!`)
}
/**
* Event type options for the dropdown, using i18n labels.
*/
const eventTypeOptions = computed(() => {
const t = (window as any).__i18n?.global?.t || ((key: string) => key)
// We use the $t function from the template; for computed we access via useI18n
return {
SERVICE: 'Szerviz',
REPAIR: 'Javítás',
ACCIDENT: 'Baleset',
INSPECTION: 'Vizsga',
TIRE_CHANGE: 'Gumicsere',
MAINTENANCE: 'Karbantartás',
UPGRADE: 'Fejlesztés',
RECALL: 'Visszahívás',
}
})
/**
* Get the display label for an event type.
*/
function getEventTypeLabel(eventType: string): string {
const labels: Record<string, string> = {
SERVICE: 'Szerviz',
REPAIR: 'Javítás',
ACCIDENT: 'Baleset',
INSPECTION: 'Vizsga',
TIRE_CHANGE: 'Gumicsere',
MAINTENANCE: 'Karbantartás',
UPGRADE: 'Fejlesztés',
RECALL: 'Visszahívás',
}
return labels[eventType] || eventType
}
/**
* Get the dot color class for the timeline based on event type.
*/
function getEventDotColor(eventType: string): string {
const colors: Record<string, string> = {
SERVICE: 'bg-emerald-500 border-emerald-300',
REPAIR: 'bg-amber-500 border-amber-300',
ACCIDENT: 'bg-red-500 border-red-300',
INSPECTION: 'bg-blue-500 border-blue-300',
TIRE_CHANGE: 'bg-purple-500 border-purple-300',
MAINTENANCE: 'bg-sky-500 border-sky-300',
UPGRADE: 'bg-indigo-500 border-indigo-300',
RECALL: 'bg-rose-500 border-rose-300',
}
return colors[eventType] || 'bg-slate-400 border-slate-300'
}
/**
* Get the badge color class for the event type pill.
*/
function getEventBadgeColor(eventType: string): string {
const colors: Record<string, string> = {
SERVICE: 'bg-emerald-100 text-emerald-700',
REPAIR: 'bg-amber-100 text-amber-700',
ACCIDENT: 'bg-red-100 text-red-700',
INSPECTION: 'bg-blue-100 text-blue-700',
TIRE_CHANGE: 'bg-purple-100 text-purple-700',
MAINTENANCE: 'bg-sky-100 text-sky-700',
UPGRADE: 'bg-indigo-100 text-indigo-700',
RECALL: 'bg-rose-100 text-rose-700',
}
return colors[eventType] || 'bg-slate-100 text-slate-700'
}
/**
* Fetch service events when the servicebook tab is activated.
* GET /assets/{vehicle.id}/events
*/
watch(() => activeTab.value, async (tab) => {
if (tab === 'servicebook' && props.vehicle?.id) {
eventsLoading.value = true
showAddForm.value = false
try {
const res = await api.get(`/assets/${props.vehicle.id}/events`)
serviceEvents.value = (res.data as ServiceEvent[]) || []
} catch (err: any) {
console.error('[VehicleDetailModal] Failed to fetch events:', err)
serviceEvents.value = []
} finally {
eventsLoading.value = false
}
}
})
/**
* Also fetch events when the vehicle prop changes while on the servicebook tab.
*/
watch(() => props.vehicle, (newVehicle) => {
if (activeTab.value === 'servicebook' && newVehicle?.id) {
eventsLoading.value = true
api.get(`/assets/${newVehicle.id}/events`)
.then(res => {
serviceEvents.value = (res.data as ServiceEvent[]) || []
})
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch events:', err); serviceEvents.value = [] })
.finally(() => { eventsLoading.value = false })
}
})
/**
* Reset the add event form to default values.
*/
function resetNewEventForm() {
newEvent.value = {
event_date: getLocalDateString(),
odometer_reading: props.vehicle?.current_mileage || null,
event_type: '',
description: '',
cost_amount: null,
}
selectedProvider.value = null
}
/**
* Submit a new service event.
* POST /assets/{vehicle.id}/events
*/
async function submitEvent() {
if (!props.vehicle?.id || !newEvent.value.event_type) return
savingEvent.value = true
try {
const payload: Record<string, any> = {
event_type: newEvent.value.event_type,
}
if (newEvent.value.event_date) {
payload.event_date = new Date(newEvent.value.event_date).toISOString()
}
if (newEvent.value.odometer_reading !== null && newEvent.value.odometer_reading !== undefined) {
payload.odometer_reading = newEvent.value.odometer_reading
}
if (newEvent.value.description) {
payload.description = newEvent.value.description
}
if (newEvent.value.cost_amount !== null && newEvent.value.cost_amount !== undefined && newEvent.value.cost_amount > 0) {
payload.cost_amount = newEvent.value.cost_amount
payload.currency = 'HUF'
}
// Include selected provider if one is chosen
if (selectedProvider.value) {
payload.provider_id = selectedProvider.value.id
payload.provider_name = selectedProvider.value.name
}
await api.post(`/assets/${props.vehicle.id}/events`, payload)
// Refresh events list
const res = await api.get(`/assets/${props.vehicle.id}/events`)
serviceEvents.value = (res.data as ServiceEvent[]) || []
// Refresh vehicle data via store to get updated current_mileage
const vehicleStore = useVehicleStore()
await vehicleStore.fetchVehicleById(String(props.vehicle.id))
// Reset form
showAddForm.value = false
resetNewEventForm()
} catch (err: any) {
console.error('[VehicleDetailModal] Failed to save event:', err)
} finally {
savingEvent.value = false
}
}
</script>
<style scoped>

View File

@@ -1,4 +1,12 @@
export default {
// ── Common / Global ──
common: {
cancel: 'Cancel',
save: 'Save',
saving: 'Saving...',
optional: 'optional',
select: 'Select',
},
landing: {
openGarage: 'Open Garage',
heroTitle: 'Digital Fleet',
@@ -86,6 +94,40 @@ export default {
detailedView: 'Detailed View',
modalPlaceholder: 'Content for this section will be available soon.',
},
finance: {
wallet: 'Wallet',
totalCredits: 'Total Credits',
purchased: 'Purchased',
bonus: 'Bonus',
earned: 'Earned',
voucher: 'Voucher',
topUp: 'Top Up',
live: 'LIVE',
retry: 'Retry',
// WalletDetailsModal keys
walletDetails: 'Wallet Details',
walletBreakdown: 'Balance Breakdown',
topUpWallet: 'Purchased',
referralRewards: 'Earned',
rewards: 'Bonus',
activeVouchers: 'Voucher',
totalBalance: 'Total Credits',
requestPayout: 'Request Payout',
transactionHistory: 'Transaction History',
all: 'All',
noTransactions: 'No transactions',
// Cost Manager keys
costManager: 'Cost Manager',
manageAllCosts: 'Manage All Costs',
costDate: 'Date',
costType: 'Type',
costAmount: 'Amount',
costVehicle: 'Vehicle',
costDescription: 'Description',
noCosts: 'No costs found',
costManagerDescription: 'Overview of all costs across your vehicles',
payoutComingSoon: 'Payout feature coming soon!',
},
zen: {
hide: 'Hide UI (Zen mode)',
show: 'Show UI',
@@ -421,6 +463,859 @@ export default {
hrsz: 'Parcel No.: {value}',
placeholder: '—',
},
servicebook: {
tabLabel: 'Service Book',
noEvents: 'No service events recorded yet.',
addEvent: 'Add Event',
cancel: 'Cancel',
saving: 'Saving...',
eventDate: 'Event Date',
odometer: 'Odometer (km)',
eventType: 'Event Type',
description: 'Description',
descriptionPlaceholder: 'Describe the service or repair...',
eventTypes: {
SERVICE: 'Service',
REPAIR: 'Repair',
ACCIDENT: 'Accident',
INSPECTION: 'Inspection',
TIRE_CHANGE: 'Tire Change',
MAINTENANCE: 'Maintenance',
UPGRADE: 'Upgrade',
RECALL: 'Recall',
},
eventSaved: 'Service event saved successfully!',
eventSaveError: 'Failed to save service event.',
loadError: 'Failed to load service events.',
atOdometer: 'at {km} km',
costAmount: 'Cost (optional)',
costPlaceholder: 'Enter amount...',
},
// ── Vehicle ──────────────────────────────────────────────────────
vehicle: {
edit_title: 'Edit Vehicle',
add_title: 'Add New Vehicle',
features_hint: 'Select the vehicle equipment and extra options.',
// Fuel types
fuelTypes: {
benzin: 'Petrol',
diesel: 'Diesel',
electric: 'Electric',
hybrid: 'Hybrid',
plugin_hybrid: 'Plug-in Hybrid',
lpg: 'LPG',
cng: 'CNG',
hydrogen: 'Hydrogen',
ethanol: 'Ethanol',
},
// Vehicle classes
classes: {
personal: 'Personal',
motorcycle: 'Motorcycle',
lcv: 'Light Commercial',
hgv: 'Heavy Duty',
machine: 'Construction Machine',
bus: 'Bus',
camper: 'Camper / Caravan',
trailer: 'Trailer',
boat: 'Boat',
other: 'Other',
},
// AC types
acTypes: {
manual: 'Manual',
automatic: 'Automatic',
dual_zone: 'Dual Zone',
tri_zone: 'Tri Zone',
quad_zone: 'Quad Zone',
},
// Cylinder layouts
cylinderLayouts: {
inline: 'Inline',
v: 'V-Configuration',
boxer: 'Boxer',
w: 'W-Configuration',
rotary: 'Rotary (Wankel)',
},
// Placeholders
placeholder_brand: 'BMW, Toyota, ...',
placeholder_model: 'X5, Corolla, ...',
placeholder_year: '2024',
placeholder_trim: 'e.g. Comfort, Sport, ...',
placeholder_name: 'e.g. Black Beauty',
placeholder_mileage: '0',
placeholder_custom_class: 'e.g. Quad, Segway, Golf Cart',
placeholder_engine_capacity: '1995',
placeholder_power_kw: '150',
placeholder_torque_nm: '320',
placeholder_type_designation: 'e.g. GTI, M Sport, AMG',
placeholder_curb_weight: '1500',
placeholder_max_weight: '2200',
placeholder_battery_capacity: '50',
placeholder_range: '400',
placeholder_color: 'e.g. Black, White',
placeholder_notes: 'e.g. Service history, notes...',
placeholder_final_mileage: '0',
// Extra labels
label_custom_class: 'Please specify the vehicle type',
label_dates_section: 'Dates',
label_first_registration: 'First Registration Date',
label_next_mot: 'Next MOT Expiry',
label_insurance_expiry: 'Insurance Expiry',
label_purchase_date: 'Purchase Date',
label_color: 'Color',
label_cylinder_count: 'Number of Cylinders',
label_battery_capacity: 'Battery Capacity (kWh)',
label_range: 'Range (km)',
label_ac_connector: 'AC Charging Connector',
label_dc_connector: 'DC Fast Charging Connector',
label_final_mileage: 'Final Odometer Reading',
label_archive_reason: 'Archive Reason',
// Danger zone
danger_zone: 'Danger Zone',
danger_zone_desc: 'Permanently remove the vehicle from the garage. This action cannot be undone!',
remove_vehicle: 'Remove Vehicle',
confirm_archive_title: 'Confirm Vehicle Archival',
confirm_archive_desc: 'The vehicle will be permanently removed from the garage. This action cannot be undone!',
confirm_archive: 'Permanent Removal',
archive_reason_sold: 'Sold',
archive_reason_scrapped: 'Scrapped',
archive_reason_stolen: 'Stolen',
archive_reason_transferred: 'Transferred to new owner',
archive_reason_other: 'Other',
// Drive types
driveTypes: {
fwd: 'Front-Wheel Drive (FWD)',
rwd: 'Rear-Wheel Drive (RWD)',
awd: 'All-Wheel Drive (AWD)',
'4wd': '4-Wheel Drive (4WD)',
chain: 'Chain',
shaft: 'Shaft',
belt: 'Belt',
},
// Transmission types
transmissionTypes: {
manual: 'Manual',
automatic: 'Automatic',
sequential: 'Sequential',
cvt: 'CVT (Continuously Variable)',
semi_auto: 'Semi-Automatic',
dct: 'DCT (Dual Clutch)',
manual_sequential: 'Manual (Sequential)',
},
// Body types
bodyTypes: {
sedan: 'Sedan',
hatchback: 'Hatchback',
wagon: 'Estate / Wagon',
estate: 'Estate / Wagon',
coupe: 'Coupé',
convertible: 'Convertible',
suv: 'SUV',
mpv: 'MPV',
pickup: 'Pickup',
van: 'Van',
},
// Labels
label_drive_type: 'Drive Type',
label_transmission: 'Transmission',
label_body_type: 'Body Type',
label_engine: 'Engine',
label_vin: 'VIN (Chassis Number)',
label_mileage: 'Current Mileage',
label_fuel_type: 'Fuel Type',
label_trim_level: 'Trim Level',
label_condition: 'Condition',
label_features: 'Features / Equipment',
label_dates: 'Dates',
label_mot_expiry: 'MOT Expiry',
label_document_expiry: 'Document Expiry',
label_reg_cert_number: 'Registration Certificate Number',
label_title_cert_number: 'Title Certificate Number',
label_documents: 'Documents',
label_ev_section: 'Electric Drive / Battery',
label_engine_capacity: 'Engine Capacity (cm³)',
label_power_kw: 'Power (kW)',
label_torque_nm: 'Torque (Nm)',
label_curb_weight: 'Curb Weight (kg)',
label_max_weight: 'Max Gross Weight (kg)',
label_type_designation: 'Type Designation',
label_ac_type: 'Air Conditioning',
label_cylinder_layout: 'Cylinder Layout',
label_door_count: 'Number of Doors',
label_seat_count: 'Number of Seats',
label_condition_select: 'Condition',
label_notes: 'Notes',
label_vehicle_class: 'Category',
label_license_plate: 'License Plate',
label_brand: 'Brand',
label_model: 'Model',
label_year: 'Year',
label_name: 'Nickname',
// Warranty
warranty_section_title: 'Warranty / Guarantee',
label_is_under_warranty: 'Valid Warranty',
label_warranty_expiry_date: 'Warranty Expiry Date',
// Tab labels
tab_basic: 'Basic Data',
tab_technical: 'Technical',
tab_administration: 'Administration',
tab_equipment: 'Equipment',
// Accordion labels
accordion_technical_features: 'Technical Features',
accordion_interior_features: 'Interior',
accordion_exterior_features: 'Exterior',
accordion_multimedia_features: 'Multimedia',
accordion_other_features: 'Other',
// Save button
save_changes: 'Save Changes',
add_vehicle: 'Add Vehicle',
saving: 'Saving...',
// Validation
required_fields_hint: 'Please fill in the required fields',
// Condition options
condition_excellent: 'Excellent',
condition_good: 'Good',
condition_fair: 'Fair',
condition_poor: 'Poor',
// Feature categories
featureCategories: {
technical: 'Technical',
interior: 'Interior',
exterior: 'Exterior',
multimedia: 'Multimedia',
other: 'Other',
},
// Features (extras)
features: {
abs: 'ABS',
esp: 'ESP (Electronic Stability)',
traction_control: 'Traction Control (TCS)',
hill_assist: 'Hill Start Assist',
hill_descent: 'Hill Descent Control',
lane_assist: 'Lane Keep Assist',
blind_spot: 'Blind Spot Monitor',
adaptive_cruise: 'Adaptive Cruise Control (ACC)',
parking_sensors_front: 'Front Parking Sensors',
parking_sensors_rear: 'Rear Parking Sensors',
rear_camera: 'Rear View Camera',
'360_camera': '360° Camera',
auto_parking: 'Auto Parking Assist',
start_stop: 'Start-Stop System',
keyless_go: 'Keyless Go',
night_vision: 'Night Vision',
traffic_sign_recognition: 'Traffic Sign Recognition',
driver_alert: 'Driver Alert',
emergency_brake: 'Emergency Brake Assist',
cross_traffic_alert: 'Cross Traffic Alert',
leather_seats: 'Leather Seats',
heated_seats_front: 'Front Heated Seats',
heated_seats_rear: 'Rear Heated Seats',
ventilated_seats: 'Ventilated Seats',
massage_seats: 'Massage Seats',
electric_seats: 'Electric Seat Adjustment',
memory_seats: 'Memory Seats',
sport_seats: 'Sport Seats',
heated_steering: 'Heated Steering Wheel',
ambient_lighting: 'Ambient Lighting',
panoramic_roof: 'Panoramic Roof',
sunroof: 'Sunroof',
auto_dim_mirror: 'Auto-Dimming Mirror',
electric_steering_adjust: 'Electric Steering Adjustment',
rear_blind: 'Rear Blind',
rear_side_blinds: 'Rear Side Blinds',
digital_instrument_cluster: 'Digital Instrument Cluster',
head_up_display: 'Head-Up Display (HUD)',
alloy_wheels: 'Alloy Wheels',
roof_rails: 'Roof Rails',
tow_bar: 'Tow Bar',
tinted_windows: 'Tinted Windows',
privacy_glass: 'Privacy Glass',
led_headlights: 'LED Headlights',
adaptive_headlights: 'Adaptive Headlights (AFS)',
fog_lights: 'Fog Lights',
cornering_lights: 'Cornering Lights',
rain_sensor: 'Rain Sensor',
light_sensor: 'Light Sensor',
auto_wipers: 'Auto Wipers',
electric_trunk: 'Electric Trunk',
hands_free_trunk: 'Hands-Free Trunk',
side_steps: 'Side Steps',
mudguards: 'Mudguards',
navigation: 'Navigation (GPS)',
apple_carplay: 'Apple CarPlay',
android_auto: 'Android Auto',
bluetooth: 'Bluetooth',
usb_charging: 'USB Charging',
wireless_charging: 'Wireless Charging',
dab_radio: 'DAB+ Digital Radio',
premium_sound: 'Premium Sound System',
subwoofer: 'Subwoofer',
rear_entertainment: 'Rear Entertainment',
wifi_hotspot: 'WiFi Hotspot',
voice_control: 'Voice Control',
gesture_control: 'Gesture Control',
spare_tire: 'Spare Tire',
tire_repair_kit: 'Tire Repair Kit',
first_aid_kit: 'First Aid Kit',
warning_triangle: 'Warning Triangle',
fire_extinguisher: 'Fire Extinguisher',
winter_tires: 'Winter Tires',
cargo_net: 'Cargo Net',
cargo_cover: 'Cargo Cover',
pet_barrier: 'Pet Barrier',
roof_box: 'Roof Box',
bike_rack: 'Bike Rack',
ski_rack: 'Ski Rack',
airbag_front: 'Front Airbag',
electric_windows: 'Electric Windows',
// ── Missing keys from Vue template ──
hill_start_assist: 'Hill Start Assist',
hill_descent_control: 'Hill Descent Control',
cruise_control: 'Cruise Control',
adaptive_cruise_control: 'Adaptive Cruise Control',
rear_view_camera: 'Rear View Camera',
auto_hold: 'Auto Hold',
airbag_driver: 'Driver Airbag',
airbag_passenger: 'Passenger Airbag',
airbag_side: 'Side Airbag',
airbag_curtain: 'Curtain Airbag',
isofix: 'ISOFIX',
tpms: 'Tire Pressure Monitoring System',
electric_seat_adjust: 'Electric Seat Adjustment',
heated_steering_wheel: 'Heated Steering Wheel',
auto_dimming_mirror: 'Auto-Dimming Mirror',
trunk_cover: 'Trunk Cover',
xenon_headlights: 'Xenon Headlights',
digital_cockpit: 'Digital Cockpit',
garage_kept: 'Garage Kept',
smoke_free: 'Smoke Free',
pet_free: 'Pet Free',
service_book: 'Service Book',
original_mileage: 'Verified Mileage',
first_owner: 'First Owner',
fleet_vehicle: 'Fleet Vehicle',
imported: 'Imported',
accident_free: 'Accident Free',
// ── Extra fallback keys from VehicleFormModal template ──
winter_tyres: 'Winter Tyres',
auto_headlights: 'Auto Headlights',
rear_spoiler: 'Rear Spoiler',
side_skirts: 'Side Skirts',
chrome_package: 'Chrome Package',
black_package: 'Black Package',
running_boards: 'Running Boards',
spare_tyre: 'Spare Tyre',
reflective_vest: 'Reflective Vest',
winter_package: 'Winter Package',
smoking_package: 'Smoking Package',
pet_friendly: 'Pet Friendly',
child_lock: 'Child Lock',
anti_theft: 'Anti-Theft',
gps_tracker: 'GPS Tracker',
service_history: 'Service History',
},
// ── Motorcycle-specific labels ──
label_strokes: 'Stroke',
label_cooling: 'Cooling',
label_fuel_mixture: 'Fuel Mixture',
label_valves: 'Number of Valves',
label_passenger_capacity: 'Passenger Capacity',
label_has_reverse_gear: 'Has Reverse Gear',
// Motorcycle strokes
motorcycleStrokes: {
'2_stroke': '2-Stroke',
'4_stroke': '4-Stroke',
electric: 'Electric',
},
// Motorcycle cooling
motorcycleCooling: {
air: 'Air Cooling',
liquid: 'Liquid Cooling',
oil: 'Oil Cooling',
air_oil: 'Air + Oil Cooling',
},
// Motorcycle mixture
motorcycleMixture: {
carburetor: 'Carburetor',
injection: 'Injection',
},
// Motorcycle feature categories
motorcycleFeatureCategories: {
technical: 'Technical',
frame_body: 'Frame & Body',
luggage: 'Luggage',
multimedia: 'Multimedia',
other: 'Other',
},
// Motorcycle features (extras)
motorcycleFeatures: {
// Technical
dual_front_disc: 'Dual Front Disc Brake',
front_disc: 'Front Disc Brake',
rear_disc: 'Rear Disc Brake',
chip_tuning: 'Chip Tuning',
electronic_suspension: 'Electronic Suspension',
onboard_computer: 'Onboard Computer',
metal_brake_line: 'Metal Brake Line',
rev_counter: 'Rev Counter',
immobiliser: 'Immobiliser',
catalyst: 'Catalytic Converter',
electric_starter: 'Electric Starter',
all_wheel_drive: 'All-Wheel Drive',
alarm: 'Alarm System',
sport_exhaust: 'Sport Exhaust',
sport_air_filter: 'Sport Air Filter',
cruise_control: 'Cruise Control',
turbo: 'Turbocharger',
'12v_system': '12V Power Socket',
heated_grip: 'Heated Grips',
abs: 'ABS',
seat_belt: 'Seat Belt',
dtc: 'DTC (Traction Control)',
fog_light: 'Fog Light',
airbag: 'Airbag',
xenon_headlight: 'Xenon Headlight',
// Frame & Body
full_extra: 'Full Extra',
leather_seat: 'Leather Seat',
heated_seat: 'Heated Seat',
backrest: 'Backrest',
center_stand: 'Center Stand',
footrest: 'Footrest',
windshield: 'Windshield',
plexiglass: 'Plexiglass',
tank_pad: 'Tank Pad',
tank_protector_leather: 'Tank Protector (Leather)',
seat_height_adjust: 'Seat Height Adjustment',
crash_bar: 'Crash Bar',
hand_guards: 'Hand Guards',
heated_mirror: 'Heated Mirror',
tow_hitch: 'Tow Hitch',
// Luggage
factory_cases: 'Factory Cases',
rear_case: 'Rear Case (Top Box)',
side_cases: 'Side Cases',
lockable_case: 'Lockable Case',
side_bag: 'Side Bag',
tank_bag: 'Tank Bag',
bag_holder_bracket: 'Bag Holder Bracket',
fork_bag: 'Fork Bag',
// Multimedia
cd_player: 'CD Player',
gps_navigation: 'GPS Navigation',
hi_fi: 'Hi-Fi Sound System',
radio: 'Radio',
info_display: 'Info Display',
// Other
under_warranty: 'Under Warranty',
us_model: 'US Model',
immediate_takeover: 'Immediate Takeover',
showroom_vehicle: 'Showroom Vehicle',
orderable: 'Orderable',
car_trade_in_possible: 'Car Trade-In Possible',
first_owner: 'First Owner',
garage_kept: 'Garage Kept',
female_owner: 'Female Owner',
low_mileage: 'Low Mileage',
second_owner: 'Second Owner',
motorcycle_trade_in_possible: 'Motorcycle Trade-In Possible',
track_fairing: 'Track Fairing',
regularly_maintained: 'Regularly Maintained',
service_book: 'Service Book',
title_certificate: 'Title Certificate',
},
// ── LCV-specific labels ──
label_cargo_section: '📦 CARGO SPECS',
label_cargo_volume_m3: 'Cargo Volume (m³)',
label_cargo_length_mm: 'Cargo Length (mm)',
label_cargo_width_mm: 'Cargo Width (mm)',
label_cargo_height_mm: 'Cargo Height (mm)',
label_wheelhouse_distance_mm: 'Wheelhouse Distance (mm)',
// LCV body types
lcvBodyTypes: {
closed_van: 'Closed Van',
pickup: 'Pickup',
double_cab_chassis: 'Double Cab Chassis',
single_cab_chassis: 'Single Cab Chassis',
box_tail_lift: 'Box Body with Tail Lift',
tipper: 'Tipper',
dropside: 'Dropside',
refrigerated_van: 'Refrigerated Van',
crew_cab_pickup: 'Crew Cab Pickup',
window_van: 'Window Van',
combi_van: 'Combi Van',
platform_truck: 'Platform Truck',
},
// LCV feature categories
lcvFeatureCategories: {
technical: 'Technical',
interior: 'Interior',
exterior: 'Exterior',
multimedia: 'Multimedia',
},
// LCV features (extras)
lcvFeatures: {
// Technical
abs: 'ABS',
asr: 'ASR (Traction Control)',
gps_tracker: 'GPS Tracker',
immobiliser: 'Immobiliser',
alarm: 'Alarm',
cruise_control: 'Cruise Control',
parking_sensors_rear: 'Rear Parking Sensors',
// Interior
curtain_airbag: 'Curtain Airbag',
rear_side_airbag: 'Rear Side Airbag',
switchable_airbag: 'Switchable Passenger Airbag',
side_airbag: 'Side Airbag',
passenger_airbag: 'Passenger Airbag',
driver_airbag: 'Driver Airbag',
roll_bar: 'Roll Bar',
cargo_tie_down: 'Cargo Tie-Down Points',
isofix: 'ISOFIX Child Seat Anchors',
full_extra: 'Full Extra',
auxiliary_heating: 'Auxiliary Heater (Webasto)',
leather_interior: 'Leather Interior',
heated_seat: 'Heated Seat',
partition_wall: 'Partition Wall',
seat_height_adjustment: 'Seat Height Adjustment',
adjustable_steering_wheel: 'Adjustable Steering Wheel',
central_locking: 'Central Locking',
trip_computer: 'Trip Computer',
power_steering: 'Power Steering',
// Exterior
power_windows: 'Power Windows',
power_mirrors: 'Power Mirrors',
heated_mirrors: 'Heated Mirrors',
alloy_wheels: 'Alloy Wheels',
tinted_glass: 'Tinted Glass',
tow_bar: 'Tow Bar',
sunroof: 'Sunroof',
fog_lights: 'Fog Lights',
xenon_headlights: 'Xenon Headlights',
// Multimedia
cd_changer: 'CD Changer',
cd_radio: 'CD Radio',
gps: 'GPS Navigation',
hifi: 'Hi-Fi Sound System',
radio_cassette: 'Radio Cassette',
},
// ── HGV-specific labels ──
label_hgv_section: '🚛 HGV SPECS',
label_operating_hours: 'Operating Hours',
label_bed_count: 'Number of Bunks',
label_axle_count: 'Number of Axles',
label_driven_axle_count: 'Driven Axles',
label_payload_capacity_kg: 'Payload Capacity (kg)',
label_working_width_mm: 'Working Width (mm)',
label_lifting_height_mm: 'Lifting Height (mm)',
label_ground_clearance_mm: 'Ground Clearance (mm)',
label_pto_type: 'PTO Type',
label_has_differential_lock: 'Differential Lock',
// HGV body types
hgvBodyTypes: {
chassis: 'Chassis',
chassis_cab: 'Chassis Cab',
tipper: 'Tipper',
tipper_3_way: '3-Way Tipper',
tipper_double: 'Double Sided Tipper',
tipper_trailer: 'Tipper Trailer',
box: 'Box Body',
box_tail_lift: 'Box Body with Tail Lift',
box_insulated: 'Insulated Box Body',
box_curtain_side: 'Curtain Side Box Body',
curtain_sider: 'Curtain Sider',
flatbed: 'Flatbed',
flatbed_stake: 'Stake Side Flatbed',
flatbed_dropside: 'Dropside Flatbed',
refrigerated: 'Refrigerated',
refrigerated_trailer: 'Refrigerated Trailer',
tanker: 'Tanker',
tanker_fuel: 'Fuel Tanker',
tanker_milk: 'Milk Tanker',
tanker_chemical: 'Chemical Tanker',
tanker_powder: 'Powder Tanker',
tractor_unit: 'Tractor Unit',
tractor_unit_4x2: 'Tractor Unit 4x2',
tractor_unit_6x2: 'Tractor Unit 6x2',
tractor_unit_6x4: 'Tractor Unit 6x4',
tractor_unit_8x4: 'Tractor Unit 8x4',
semi_trailer: 'Semi-Trailer',
semi_trailer_curtain: 'Curtain Sider Semi-Trailer',
semi_trailer_box: 'Box Semi-Trailer',
semi_trailer_tipper: 'Tipper Semi-Trailer',
semi_trailer_tanker: 'Tanker Semi-Trailer',
semi_trailer_flatbed: 'Flatbed Semi-Trailer',
semi_trailer_low_loader: 'Low Loader Semi-Trailer',
semi_trailer_car_transporter: 'Car Transporter Semi-Trailer',
container: 'Container',
container_carrier: 'Container Carrier',
swap_body: 'Swap Body',
hook_lift: 'Hook Lift',
crane: 'Crane',
crane_truck: 'Crane Truck',
crane_trailer: 'Crane Trailer',
fire_truck: 'Fire Truck',
fire_truck_aerial: 'Aerial Fire Truck',
ambulance: 'Ambulance',
hearse: 'Hearse',
armored: 'Armored',
livestock: 'Livestock Carrier',
car_transporter: 'Car Transporter',
mobile_shop: 'Mobile Shop',
workshop: 'Workshop Truck',
tow_truck: 'Tow Truck',
street_sweeper: 'Street Sweeper',
refuse_truck: 'Refuse Truck',
concrete_mixer: 'Concrete Mixer',
concrete_pump: 'Concrete Pump',
dump_truck: 'Dump Truck',
timber_truck: 'Timber Truck',
log_trailer: 'Log Trailer',
double_cab: 'Double Cab',
triple_cab: 'Triple Cab',
crew_cab: 'Crew Cab',
sleeper_cab: 'Sleeper Cab',
day_cab: 'Day Cab',
glider_kit: 'Glider Kit',
},
// HGV transmission types
hgvTransmissionTypes: {
manual: 'Manual',
manual_async: 'Manual (Async)',
semi_auto: 'Semi-Automatic',
auto: 'Automatic',
manual_split: 'Manual Split Gearbox',
semi_auto_split: 'Semi-Auto Split Gearbox',
auto_split: 'Auto Split Gearbox',
cvt: 'CVT',
},
// PTO types
ptoTypes: {
front: 'Front PTO',
engine_mounted: 'Engine-Mounted PTO',
gearbox_mounted: 'Gearbox-Mounted PTO',
},
// HGV feature categories
hgvFeatureCategories: {
technical_work: 'Technical / Work',
cabin: 'Cabin',
multimedia: 'Multimedia',
},
// HGV features (extras)
hgvFeatures: {
// Technical / Work
diff_lock_front: 'Front Differential Lock',
diff_lock_rear: 'Rear Differential Lock',
diff_lock_center: 'Center Differential Lock',
diff_lock_full: 'Full Differential Lock',
cross_axle_lock: 'Cross-Axle Differential Lock',
retarder: 'Retarder',
exhaust_brake: 'Exhaust Brake',
engine_brake: 'Engine Brake',
auxiliary_brake: 'Auxiliary Brake',
hill_holder: 'Hill Holder',
hill_descent_control: 'Hill Descent Control',
traction_control: 'Traction Control',
stability_control: 'Stability Control (ESP)',
roll_stability: 'Roll Stability Program (RSP)',
lane_departure: 'Lane Departure Warning',
adaptive_cruise: 'Adaptive Cruise Control (ACC)',
collision_warning: 'Collision Warning',
emergency_brake_assist: 'Emergency Brake Assist',
blind_spot_monitor: 'Blind Spot Monitor',
traffic_sign_recognition: 'Traffic Sign Recognition',
driver_alert: 'Driver Alert System',
tyre_pressure_monitor: 'Tyre Pressure Monitor (TPMS)',
reverse_camera: 'Reverse Camera',
'360_camera': '360° Camera',
side_camera: 'Side Camera',
front_camera: 'Front Camera',
rear_camera: 'Rear Camera',
parking_sensors: 'Parking Sensors',
front_parking_sensors: 'Front Parking Sensors',
rear_parking_sensors: 'Rear Parking Sensors',
side_parking_sensors: 'Side Parking Sensors',
tachograph: 'Tachograph',
digital_tachograph: 'Digital Tachograph',
smart_tachograph: 'Smart Tachograph (G2V2)',
gps_tracker: 'GPS Tracker',
fleet_management: 'Fleet Management System',
fuel_monitoring: 'Fuel Monitoring System',
eco_driving: 'Eco Driving Assistant',
weigh_in_motion: 'Weigh-In-Motion System',
load_securing: 'Load Securing System',
// Cabin
sleeper_cabin: 'Sleeper Cabin',
single_bunk: 'Single Bunk',
double_bunk: 'Double Bunk',
upper_bunk: 'Upper Bunk',
lower_bunk: 'Lower Bunk',
fridge: 'Refrigerator',
freezer: 'Freezer',
microwave: 'Microwave Oven',
coffee_machine: 'Coffee Machine',
water_heater: 'Water Heater',
air_conditioner: 'Air Conditioner',
parking_heater: 'Parking Heater',
auxiliary_heater: 'Auxiliary Heater',
night_heater: 'Night Heater',
roof_vent: 'Roof Vent',
power_vent: 'Power Roof Vent',
sun_visor: 'Sun Visor',
curtain_set: 'Curtain Set',
cabin_lighting: 'Cabin Lighting',
led_cabin_lighting: 'LED Cabin Lighting',
reading_light: 'Reading Light',
cabin_table: 'Cabin Table',
passenger_seat: 'Passenger Seat',
passenger_seat_swivel: 'Swivel Passenger Seat',
driver_seat_suspension: 'Air Suspension Driver Seat',
seat_heating: 'Seat Heating',
seat_ventilation: 'Seat Ventilation',
armrest: 'Armrest Seat',
storage_box: 'Storage Box',
wardrobe: 'Wardrobe',
// Multimedia
navigation: 'Navigation (GPS)',
apple_carplay: 'Apple CarPlay',
android_auto: 'Android Auto',
bluetooth: 'Bluetooth',
usb_charging: 'USB Charging',
wireless_charging: 'Wireless Charging',
dab_radio: 'DAB+ Digital Radio',
cb_radio: 'CB Radio',
satellite_radio: 'Satellite Radio',
digital_tv: 'Digital TV',
dvb_t: 'DVB-T TV',
dvb_s: 'DVB-S Satellite TV',
premium_sound: 'Premium Sound System',
subwoofer: 'Subwoofer',
rear_entertainment: 'Rear Entertainment System',
wifi_hotspot: 'WiFi Hotspot',
},
},
// ── Service Finder ────────────────────────────────────────────────
serviceFinder: {
title: '🔧 Service Finder',
heroTitle: '🔧 <span class="text-sf-accent">Service</span> Finder',
heroSubtitle: 'Find the best auto service providers in one place',
// Dashboard Card 3 — Launchpad
plannedMaintenance: 'Planned Maintenance',
sosTitle: 'Emergency Help',
sosSubtitle: 'Need immediate roadside assistance?',
comingSoon: 'Coming Soon',
searchPlaceholder: 'Service or provider (e.g. AC repair)',
cityPlaceholder: 'City or ZIP Code',
searchButton: 'Search',
searching: 'Searching...',
quickFilters: 'Quick filters:',
resultsTitle: 'Results ({count})',
startSearching: 'Start searching!',
noResults: 'No results for your search.',
pageInfo: 'Page: {current} / {total}',
backToDashboard: 'Back to Dashboard',
noResultsTitle: 'No results',
noResultsHint: 'Try different search terms or browse using the quick filters!',
showAll: 'Show all providers',
previous: 'Previous',
next: 'Next',
// Source badges
sourceVerified: 'Verified',
sourceCommunity: 'Community',
sourceRobot: 'Robot Data',
sourceVerifiedTitle: 'Verified provider',
sourceCommunityTitle: 'Community recommended',
sourceRobotTitle: 'Robot collected data',
sourceVerifiedLabel: 'Verified provider',
sourceCommunityLabel: 'Community recommended',
sourceRobotLabel: 'Robot collected',
addressNotAvailable: 'Address not available',
noCategory: 'No category',
// Quick filter chips
filterAutoSzerelo: '🔧 Car Mechanic',
filterGumiszerviz: '🛞 Tire Service',
filterKarosszeria: '🔩 Body Shop',
filterAutoMento: '🚛 Tow Truck',
filterBenzinkut: '⛽ Gas Station',
filterAutoKozmetika: '✨ Car Detailing',
// SOS Tab
sosTabTitle: 'SOS Rescue',
sosPlaceholder: '🚧 This feature is under development. You will be able to request emergency roadside assistance here soon!',
// Planned Tab
plannedTabTitle: 'Planned Search',
// Provider Detail Modal
detailTitle: 'Provider Details',
detailCategory: 'Category',
detailAddress: 'Address',
detailSource: 'Source',
detailSpecialization: 'Specialization',
detailRating: 'Rating',
detailNoRating: 'No rating yet',
detailNoSpecialization: 'Not specified',
editRequest: '✏️ Complete Data (Earn Points!)',
close: 'Close',
},
// ── Provider ──────────────────────────────────────────────────────
provider: {
quickAddTitle: 'Add New Provider',
quickAddSubtitle: 'Help the community! You earn points for adding.',
nameLabel: 'Provider Name',
nameRequired: 'Required',
namePlaceholder: 'e.g. MOL Auto Service',
categoryLabel: 'Category',
categoryRequired: 'Required',
categoryPlaceholder: 'Select a category',
zipLabel: 'ZIP Code',
zipOptional: 'optional',
zipPlaceholder: 'e.g. 1011',
cityLabel: 'City',
cityOptional: 'optional',
cityPlaceholder: 'e.g. Budapest',
streetLabel: 'Street, House Number',
streetOptional: 'optional',
streetPlaceholder: 'e.g. Kossuth Street 1',
submitButton: 'Register Provider & Earn Points!',
submitting: 'Registering...',
closeButton: 'Close',
successMessage: 'Successfully registered! You earned {points} points!',
successSubtext: 'Thank you for helping build the community!',
errorMessage: 'An error occurred while registering the provider. Please try again!',
// Autocomplete
autocompletePlaceholder: 'Search provider...',
autocompleteNoResults: 'No results found.',
autocompleteAddNew: '+ Add new provider and earn points!',
autocompleteClear: 'Clear selection',
autocompleteVerified: '✓ Verified',
autocompleteSourceVerified: 'Verified',
autocompleteSourceRobot: 'Robot collected',
autocompleteSourceCommunity: 'Community',
// Soft-deduplication
duplicateWarning: 'Similar providers found nearby: {matches}. Are you sure you want to add a new one?',
duplicateWarningShort: 'Similar provider found nearby: {name}. Are you sure you want to add a new one?',
// Edit Modal
editTitle: 'Edit Provider Data',
streetNameLabel: 'Street Name',
streetNamePlaceholder: 'e.g. Egressy',
streetTypeLabel: 'Street Type',
streetTypePlaceholder: 'Select type...',
houseNumberLabel: 'House Number',
houseNumberPlaceholder: 'e.g. 4',
phoneLabel: 'Phone',
phonePlaceholder: 'e.g. +36 20 123 4567',
emailLabel: 'Email',
emailPlaceholder: 'e.g. info@provider.hu',
websiteLabel: 'Website',
websitePlaceholder: 'e.g. https://provider.hu',
tagsLabel: 'Service Tags',
tagsPlaceholder: 'Add tags (comma-separated)',
editInfo: 'Your edits will be reviewed and may earn gamification points!',
},
admin: {
services: {
title: 'Services',

View File

@@ -1,4 +1,12 @@
export default {
// ── Common / Globális ──
common: {
cancel: 'Mégse',
save: 'Mentés',
saving: 'Mentés...',
optional: 'opcionális',
select: 'Válassz',
},
landing: {
openGarage: 'Garázs Nyitása',
heroTitle: 'Digitális Flotta',
@@ -86,6 +94,40 @@ export default {
detailedView: 'Részletes Nézet',
modalPlaceholder: 'A szekció tartalma hamarosan elérhető lesz.',
},
finance: {
wallet: 'Pénztárca',
totalCredits: 'Összes Kredit',
purchased: 'Vásárolt',
bonus: 'Bónusz',
earned: 'Keresett',
voucher: 'Voucher',
topUp: 'Egyenleg Feltöltése',
live: 'ÉLŐ',
retry: 'Újra',
// WalletDetailsModal keys
walletDetails: 'Pénztárca Részletek',
walletBreakdown: 'Egyenleg Bontás',
topUpWallet: 'Vásárolt',
referralRewards: 'Keresett',
rewards: 'Bónusz',
activeVouchers: 'Voucher',
totalBalance: 'Összes Kredit',
requestPayout: 'Kifizetés Igénylése',
transactionHistory: 'Tranzakció Előzmények',
all: 'Összes',
noTransactions: 'Nincsenek tranzakciók',
// Cost Manager keys
costManager: 'Költség Menedzser',
manageAllCosts: 'Összes költség kezelése',
costDate: 'Dátum',
costType: 'Típus',
costAmount: 'Összeg',
costVehicle: 'Jármű',
costDescription: 'Leírás',
noCosts: 'Nincsenek költségek',
costManagerDescription: 'Az összes járműhöz tartozó költségek áttekintése',
payoutComingSoon: 'Kifizetési funkció hamarosan!',
},
zen: {
hide: 'UI elrejtése (Zen mód)',
show: 'UI megjelenítése',
@@ -421,6 +463,859 @@ export default {
hrsz: 'Hrsz.: {value}',
placeholder: '—',
},
servicebook: {
tabLabel: 'Szervizkönyv',
noEvents: 'Még nincsenek szerviz események rögzítve.',
addEvent: 'Új bejegyzés',
cancel: 'Mégsem',
saving: 'Mentés...',
eventDate: 'Esemény dátuma',
odometer: 'Km óra állás (km)',
eventType: 'Esemény típusa',
description: 'Leírás',
descriptionPlaceholder: 'Írd le a szerviz vagy javítás részleteit...',
eventTypes: {
SERVICE: 'Szerviz',
REPAIR: 'Javítás',
ACCIDENT: 'Baleset',
INSPECTION: 'Vizsga',
TIRE_CHANGE: 'Gumicsere',
MAINTENANCE: 'Karbantartás',
UPGRADE: 'Fejlesztés',
RECALL: 'Visszahívás',
},
eventSaved: 'Szerviz esemény sikeresen elmentve!',
eventSaveError: 'Nem sikerült elmenteni a szerviz eseményt.',
loadError: 'Nem sikerült betölteni a szerviz eseményeket.',
atOdometer: '{km} km-nél',
costAmount: 'Költség (opcionális)',
costPlaceholder: 'Add meg az összeget...',
},
// ── Vehicle / Jármű ─────────────────────────────────────────────
vehicle: {
edit_title: 'Jármű szerkesztése',
add_title: 'Új jármű hozzáadása',
features_hint: 'Válaszd ki a jármű felszereltségét és extra opcióit.',
// Fuel types
fuelTypes: {
benzin: 'Benzin',
diesel: 'Dízel',
electric: 'Elektromos',
hybrid: 'Hibrid',
plugin_hybrid: 'Plugin Hibrid',
lpg: 'LPG',
cng: 'CNG',
hydrogen: 'Hidrogén',
ethanol: 'Etanol',
},
// Vehicle classes
classes: {
personal: 'Személyes',
motorcycle: 'Motorkerékpár',
lcv: 'Kishaszonjármű',
hgv: 'Haszongépjármű',
machine: 'Munkagép',
bus: 'Busz',
camper: 'Lakóautó / Lakókocsi',
trailer: 'Utánfutó',
boat: 'Hajó',
other: 'Egyéb',
},
// AC types
acTypes: {
manual: 'Manuális',
automatic: 'Automata',
dual_zone: 'Kétzónás',
tri_zone: 'Háromzónás',
quad_zone: 'Négyzónás',
},
// Cylinder layouts
cylinderLayouts: {
inline: 'Soros',
v: 'V-elrendezés',
boxer: 'Boxer',
w: 'W-elrendezés',
rotary: 'Rotációs (Wankel)',
},
// Placeholders
placeholder_brand: 'BMW, Toyota, ...',
placeholder_model: 'X5, Corolla, ...',
placeholder_year: '2024',
placeholder_trim: 'Pl. Comfort, Sport, ...',
placeholder_name: 'Pl. Béla, Fekete Özvegy',
placeholder_mileage: '0',
placeholder_custom_class: 'Pl. Quad, Segway, Golfkocsi',
placeholder_engine_capacity: '1995',
placeholder_power_kw: '150',
placeholder_torque_nm: '320',
placeholder_type_designation: 'Pl. GTI, M Sport, AMG',
placeholder_curb_weight: '1500',
placeholder_max_weight: '2200',
placeholder_battery_capacity: '50',
placeholder_range: '400',
placeholder_color: 'Pl. Fekete, Fehér',
placeholder_notes: 'Pl. Szerviztörténet, megjegyzések...',
placeholder_final_mileage: '0',
// Extra labels
label_custom_class: 'Kérjük, adja meg a jármű típusát',
label_dates_section: 'Dátumok',
label_first_registration: 'Első forgalomba helyezés',
label_next_mot: 'Következő műszaki érvényesség',
label_insurance_expiry: 'Biztosítás lejárta',
label_purchase_date: 'Vásárlás dátuma',
label_color: 'Szín',
label_cylinder_count: 'Hengerek száma',
label_battery_capacity: 'Akkumulátor kapacitás (kWh)',
label_range: 'Hatótáv (km)',
label_ac_connector: 'AC töltő csatlakozó',
label_dc_connector: 'DC gyorstöltő csatlakozó',
label_final_mileage: 'Végleges kilométeróra állás',
label_archive_reason: 'Archiválás oka',
// Danger zone
danger_zone: 'Veszélyzóna',
danger_zone_desc: 'A jármű végleges eltávolítása a garázsból. Ez a művelet nem vonható vissza!',
remove_vehicle: 'Jármű eltávolítása',
confirm_archive_title: 'Jármű archiválásának megerősítése',
confirm_archive_desc: 'A jármű véglegesen eltávolításra kerül a garázsból. Ez a művelet nem vonható vissza!',
confirm_archive: 'Végleges eltávolítás',
archive_reason_sold: 'Eladva',
archive_reason_scrapped: 'Roncsként selejtezve',
archive_reason_stolen: 'Ellopva',
archive_reason_transferred: 'Más tulajdonba került',
archive_reason_other: 'Egyéb',
// Drive types
driveTypes: {
fwd: 'Elsőkerék (FWD)',
rwd: 'Hátsókerék (RWD)',
awd: 'Összkerék (AWD)',
'4wd': 'Összkerék (4WD)',
chain: 'Lánc',
shaft: 'Kardán',
belt: 'Szíj',
},
// Transmission types
transmissionTypes: {
manual: 'Manuális',
automatic: 'Automata',
sequential: 'Szekvenciális',
cvt: 'Fokozatmentes automata',
semi_auto: 'Félautomata',
dct: 'DCT (Dupla kuplungos)',
manual_sequential: 'Kézi (Szekvenciális)',
},
// Body types
bodyTypes: {
sedan: 'Sedan',
hatchback: 'Ferdehátú',
wagon: 'Kombi',
estate: 'Kombi',
coupe: 'Kupé',
convertible: 'Kabrió',
suv: 'SUV',
mpv: 'Egyterű (MPV)',
pickup: 'Pickup',
van: 'Furgon',
},
// Labels
label_drive_type: 'Meghajtás',
label_transmission: 'Váltó',
label_body_type: 'Karosszéria típus',
label_engine: 'Motor',
label_vin: 'Alvázszám (VIN)',
label_mileage: 'Jelenlegi km óra állás',
label_fuel_type: 'Üzemanyag',
label_trim_level: 'Felszereltség',
label_condition: 'Állapot',
label_features: 'Extrák / Felszereltség',
label_dates: 'Dátumok',
label_mot_expiry: 'Műszaki érvényesség',
label_document_expiry: 'Dokumentumok érvényessége',
label_reg_cert_number: 'Forgalmi engedély száma',
label_title_cert_number: 'Törzskönyv száma',
label_documents: 'Dokumentumok',
label_ev_section: 'Elektromos Hajtás / Akkumulátor',
label_engine_capacity: 'Hengerűrtartalom (cm³)',
label_power_kw: 'Teljesítmény (kW)',
label_torque_nm: 'Nyomaték (Nm)',
label_curb_weight: 'Saját tömeg (kg)',
label_max_weight: 'Össztömeg (kg)',
label_type_designation: 'Típusjel',
label_ac_type: 'Légkondícionáló',
label_cylinder_layout: 'Hengerelrendezés',
label_door_count: 'Ajtók száma',
label_seat_count: 'Ülések száma',
label_condition_select: 'Állapot',
label_notes: 'Megjegyzés',
label_vehicle_class: 'Kategória',
label_license_plate: 'Rendszám',
label_brand: 'Márka',
label_model: 'Modell',
label_year: 'Évjárat',
label_name: 'Becenév',
// Warranty
warranty_section_title: 'Jótállás / Garancia',
label_is_under_warranty: 'Érvényes jótállás',
label_warranty_expiry_date: 'Jótállás érvényessége',
// Tab labels
tab_basic: 'Alapadatok',
tab_technical: 'Műszaki',
tab_administration: 'Adminisztráció',
tab_equipment: 'Felszereltség',
// Accordion labels
accordion_technical_features: 'Technikai extrák',
accordion_interior_features: 'Belső tér',
accordion_exterior_features: 'Külső',
accordion_multimedia_features: 'Multimédia',
accordion_other_features: 'Egyéb',
// Save button
save_changes: 'Módosítások mentése',
add_vehicle: 'Jármű hozzáadása',
saving: 'Mentés...',
// Validation
required_fields_hint: 'Kérjük töltse ki a kötelező mezőket',
// Condition options
condition_excellent: 'Kiváló',
condition_good: 'Jó',
condition_fair: 'Megfelelő',
condition_poor: 'Rossz',
// Feature categories
featureCategories: {
technical: 'Technikai',
interior: 'Belső tér',
exterior: 'Külső',
multimedia: 'Multimédia',
other: 'Egyéb',
},
// Features (extras)
features: {
abs: 'ABS',
esp: 'ESP (Menetstabilizáló)',
traction_control: 'Kipörgésgátló (TCS)',
hill_assist: 'Hegymeneti tartó',
hill_descent: 'Lejtmenetvezérlő',
lane_assist: 'Sávtartó asszisztens',
blind_spot: 'Holtpont-figyelő',
adaptive_cruise: 'Adaptív tempomat (ACC)',
parking_sensors_front: 'Első parkolóérzékelők',
parking_sensors_rear: 'Hátsó parkolóérzékelők',
rear_camera: 'Tolatókamera',
'360_camera': '360°-os kamera',
auto_parking: 'Automatikus parkolóasszisztens',
start_stop: 'Start-Stop rendszer',
keyless_go: 'Kulcs nélküli indítás (Keyless Go)',
night_vision: 'Éjjellátó (Night Vision)',
traffic_sign_recognition: 'Táblafelismerő rendszer',
driver_alert: 'Fáradtságfigyelő',
emergency_brake: 'Vészfék asszisztens',
cross_traffic_alert: 'Keresztirányú forgalom figyelő',
leather_seats: 'Bőrülés',
heated_seats_front: 'Első ülésfűtés',
heated_seats_rear: 'Hátsó ülésfűtés',
ventilated_seats: 'Szellőztetett ülések',
massage_seats: 'Masszázsülés',
electric_seats: 'Elektromos ülésállítás',
memory_seats: 'Ülésmemória',
sport_seats: 'Sportülés',
heated_steering: 'Fűthető kormány',
ambient_lighting: 'Hangulatvilágítás (Ambient Light)',
panoramic_roof: 'Panorámatető',
sunroof: 'Napfénytető',
auto_dim_mirror: 'Automatikusan sötétedő belső tükör',
electric_steering_adjust: 'Elektromos kormányállítás',
rear_blind: 'Hátsó roló',
rear_side_blinds: 'Hátsó oldalsó rolók',
digital_instrument_cluster: 'Digitális műszeregység',
head_up_display: 'Head-Up Display (HUD)',
alloy_wheels: 'Könnyűfém felni',
roof_rails: 'Tetősín (Roof Rails)',
tow_bar: 'Vontatóhorog (vonóhorog)',
tinted_windows: 'Sötétített üvegek',
privacy_glass: 'Privacy Glass',
led_headlights: 'LED fényszóró',
adaptive_headlights: 'Adaptív fényszóró (AFS)',
fog_lights: 'Ködlámpa',
cornering_lights: 'Kanyarvilágítás',
rain_sensor: 'Esőérzékelő',
light_sensor: 'Fényérzékelő',
auto_wipers: 'Automatikus ablaktörlő',
electric_trunk: 'Elektromos csomagtérajtó',
hands_free_trunk: 'Kihangosítható csomagtérajtó (Hands-Free)',
side_steps: 'Oldallépcső',
mudguards: 'Sárvédő',
navigation: 'Navigáció (GPS)',
apple_carplay: 'Apple CarPlay',
android_auto: 'Android Auto',
bluetooth: 'Bluetooth',
usb_charging: 'USB töltőcsatlakozó',
wireless_charging: 'Vezeték nélküli töltő',
dab_radio: 'DAB+ digitális rádió',
premium_sound: 'Prémium hangrendszer',
subwoofer: 'Mélynyomó (Subwoofer)',
rear_entertainment: 'Hátsó szórakoztató rendszer',
wifi_hotspot: 'WiFi Hotspot',
voice_control: 'Hangvezérlés',
gesture_control: 'Gesztusvezérlés',
spare_tire: 'Pótkerék',
tire_repair_kit: 'Kerékpár-javító készlet',
first_aid_kit: 'Elsősegély csomag',
warning_triangle: 'Figyelmeztető háromszög',
fire_extinguisher: 'Tűzoltó készülék',
winter_tires: 'Téli gumi',
cargo_net: 'Csomagtér háló',
cargo_cover: 'Csomagtér takaró',
pet_barrier: 'Kutya rács / elválasztó',
roof_box: 'Tetős doboz',
bike_rack: 'Kerékpártartó',
ski_rack: 'Sítartó',
airbag_front: 'Első légzsák',
electric_windows: 'Elektromos ablakemelő',
// ── Additional fallback keys from Vue template ──
hill_start_assist: 'Hegyi start segéd',
hill_descent_control: 'Lejtmenet szabályzó',
cruise_control: 'Tempomat',
adaptive_cruise_control: 'Adaptív tempomat',
rear_view_camera: 'Tolatókamera',
auto_hold: 'Auto Hold',
airbag_driver: 'Vezető légzsák',
airbag_passenger: 'Utas légzsák',
airbag_side: 'Oldallégzsák',
airbag_curtain: 'Függönylégzsák',
isofix: 'Isofix',
tpms: 'Guminyomás monitor',
electric_seat_adjust: 'Elektromos ülésállítás',
heated_steering_wheel: 'Fűtött kormány',
auto_dimming_mirror: 'Automata sötétedő tükör',
trunk_cover: 'Csomagtér takaró',
xenon_headlights: 'Xenon fényszóró',
digital_cockpit: 'Digitális műszerfal',
garage_kept: 'Garázsban tartott',
smoke_free: 'Nem dohányzó',
pet_free: 'Háziállat mentes',
service_book: 'Szervizkönyvvel',
original_mileage: 'Igazolt futásteljesítmény',
first_owner: 'Első tulajdonos',
fleet_vehicle: 'Flottás jármű',
imported: 'Importált',
accident_free: 'Balesetmentes',
// ── Extra fallback keys from VehicleFormModal template ──
winter_tyres: 'Téli gumi',
auto_headlights: 'Automatikus fényszóró',
rear_spoiler: 'Hátsó spoiler',
side_skirts: 'Oldalsó szoknya',
chrome_package: 'Króm csomag',
black_package: 'Fekete csomag',
running_boards: 'Oldallépcső',
spare_tyre: 'Pótkerék',
reflective_vest: 'Reflexiós mellény',
winter_package: 'Téli csomag',
smoking_package: 'Dohányzó csomag',
pet_friendly: 'Kisállat barát',
child_lock: 'Gyerekzár',
anti_theft: 'Riasztó',
gps_tracker: 'GPS követő',
service_history: 'Szerviztörténet',
},
// ── Motorcycle-specific labels ──
label_strokes: 'Munkaütem',
label_cooling: 'Hűtés',
label_fuel_mixture: 'Keverékképzés',
label_valves: 'Szelepek száma',
label_passenger_capacity: 'Szállítható személyek száma',
label_has_reverse_gear: 'Hátramenettel rendelkezik',
// Motorcycle strokes
motorcycleStrokes: {
'2_stroke': '2 ütemű',
'4_stroke': '4 ütemű',
electric: 'Elektromos',
},
// Motorcycle cooling
motorcycleCooling: {
air: 'Levegőhűtés',
liquid: 'Vízhűtés',
oil: 'Olajhűtés',
air_oil: 'Levegő/Olaj hűtés',
},
// Motorcycle mixture
motorcycleMixture: {
carburetor: 'Karburátor',
injection: 'Befecskendezés',
},
// Motorcycle feature categories
motorcycleFeatureCategories: {
technical: 'Műszaki',
frame_body: 'Váz / Idom',
luggage: 'Táska / Doboz',
multimedia: 'Multimédia',
other: 'Egyéb',
},
// Motorcycle features (extras)
motorcycleFeatures: {
// Technical
dual_front_disc: 'Dupla tárcsafék elöl',
front_disc: 'Tárcsafék elöl',
rear_disc: 'Tárcsafék hátul',
chip_tuning: 'Chiptuning',
electronic_suspension: 'Elektromos futómű állítás',
onboard_computer: 'Fedélzeti computer',
metal_brake_line: 'Fém fékcső',
rev_counter: 'Fordulatszámmérő',
immobiliser: 'Immobiliser',
catalyst: 'Katalizátor',
electric_starter: 'Önindító',
all_wheel_drive: 'Összkerékhajtás',
alarm: 'Riasztó',
sport_exhaust: 'Sport kipufogó',
sport_air_filter: 'Sport légszűrő',
cruise_control: 'Tempomat',
turbo: 'Turbó',
'12v_system': '12 V rendszer',
heated_grip: 'Markolat fűtés',
abs: 'ABS (blokkolásgátló)',
seat_belt: 'Biztonsági öv',
dtc: 'DTC',
fog_light: 'Ködlámpa',
airbag: 'Légzsák',
xenon_headlight: 'Xenon fényszóró',
// Frame / Body
full_extra: 'Full extra',
leather_seat: 'Bőrülés',
heated_seat: 'Fűthető ülés',
backrest: 'Háttámla',
center_stand: 'Középsztender',
footrest: 'Lábtartó',
windshield: 'Motoros szélvédő',
plexiglass: 'Plexi',
tank_pad: 'Tankpad',
tank_protector_leather: 'Tankvédő bőr',
seat_height_adjust: 'Ülésmagasság állítás',
crash_bar: 'Bukócső / bukógomba',
hand_guards: 'Kézvédők',
heated_mirror: 'Fűthető tükör',
tow_hitch: 'Vonóhorog',
// Luggage
factory_cases: 'Gyári dobozok',
rear_case: 'Hátsó doboz',
side_cases: 'Oldalsó dobozok',
lockable_case: 'Zárható doboz',
side_bag: 'Oldaltáska',
tank_bag: 'Tank táska',
bag_holder_bracket: 'Táskatartó konzol',
fork_bag: 'Villatáska',
// Multimedia
cd_player: 'CD tár',
gps_navigation: 'GPS (navigáció)',
hi_fi: 'Hi-Fi',
radio: 'Rádiós magnó',
info_display: 'Információs kijelző',
// Other
under_warranty: 'Garanciális',
us_model: 'Amerikai modell',
immediate_takeover: 'Azonnal elvihető',
showroom_vehicle: 'Bemutató jármű',
orderable: 'Rendelhető',
car_trade_in_possible: 'Autóbeszámítás lehetséges',
first_owner: 'Első tulajdonostól',
garage_kept: 'Garázsban tartott',
female_owner: 'Hölgy tulajdonostól',
low_mileage: 'Keveset futott',
second_owner: 'Második tulajdonostól',
motorcycle_trade_in_possible: 'Motorbeszámítás lehetséges',
track_fairing: 'Pályaidom',
regularly_maintained: 'Rendszeresen karbantartott',
service_book: 'Szervizkönyv',
title_certificate: 'Törzskönyv',
},
// ── LCV-specific labels ──
label_cargo_section: '📦 RAKTÉR ADATOK',
label_cargo_volume_m3: 'Raktér térfogat (m³)',
label_cargo_length_mm: 'Raktér hossz (mm)',
label_cargo_width_mm: 'Raktér szélesség (mm)',
label_cargo_height_mm: 'Raktér magasság (mm)',
label_wheelhouse_distance_mm: 'Kerékdob távolság (mm)',
// LCV body types
lcvBodyTypes: {
closed_van: 'Zárt furgon',
pickup: 'Pickup',
double_cab_chassis: 'Dupla kabin alváz',
single_cab_chassis: 'Egyes kabin alváz',
box_tail_lift: 'Dobozos raktér emelővel',
tipper: 'Billenős plató',
dropside: 'Billenő oldalfalú plató',
refrigerated_van: 'Hűtött furgon',
crew_cab_pickup: 'Crew Cab Pickup',
window_van: 'Ablakos furgon',
combi_van: 'Combi furgon',
platform_truck: 'Platós teherautó',
},
// LCV feature categories
lcvFeatureCategories: {
technical: 'Technikai',
interior: 'Belső tér',
exterior: 'Külső',
multimedia: 'Multimédia',
},
// LCV features (extras)
lcvFeatures: {
// Technical
abs: 'ABS',
asr: 'ASR (Kipörgésgátló)',
gps_tracker: 'GPS nyomkövető',
immobiliser: 'Immobiliser',
alarm: 'Riasztó',
cruise_control: 'Tempomat',
parking_sensors_rear: 'Hátsó parkolóérzékelők',
// Interior
curtain_airbag: 'Függönylégzsák',
rear_side_airbag: 'Hátsó oldallégzsák',
switchable_airbag: 'Kikapcsolható utaslégzsák',
side_airbag: 'Oldallégzsák',
passenger_airbag: 'Utaslégzsák',
driver_airbag: 'Vezető légzsák',
roll_bar: 'Bukókeret',
cargo_tie_down: 'Rögzítő pontok a raktérben',
isofix: 'Isofix gyerekülés rögzítés',
full_extra: 'Full extra',
auxiliary_heating: 'Kiegészítő fűtés (Webasto)',
leather_interior: 'Bőr belső',
heated_seat: 'Fűthető ülés',
partition_wall: 'Válaszfal',
seat_height_adjustment: 'Ülésmagasság állítás',
adjustable_steering_wheel: 'Állítható kormány',
central_locking: 'Központi zár',
trip_computer: 'Fedélzeti számítógép',
power_steering: 'Szervokormány',
// Exterior
power_windows: 'Elektromos ablakemelő',
power_mirrors: 'Elektromos tükör állítás',
heated_mirrors: 'Fűthető tükör',
alloy_wheels: 'Könnyűfém felni',
tinted_glass: 'Sötétített üvegek',
tow_bar: 'Vontatóhorog',
sunroof: 'Napfénytető',
fog_lights: 'Ködlámpa',
xenon_headlights: 'Xenon fényszóró',
// Multimedia
cd_changer: 'CD váltó',
cd_radio: 'CD rádió',
gps: 'GPS navigáció',
hifi: 'Hi-Fi hangrendszer',
radio_cassette: 'Rádiós kazettás magnó',
},
// ── HGV-specific labels ──
label_hgv_section: '🚛 TEHERGÉPKOCSI ADATOK',
label_operating_hours: 'Üzemóra',
label_bed_count: 'Fekvőhelyek száma',
label_axle_count: 'Tengelyek száma',
label_driven_axle_count: 'Hajtott tengelyek száma',
label_payload_capacity_kg: 'Hasznos teherbírás (kg)',
label_working_width_mm: 'Munkaszélesség (mm)',
label_lifting_height_mm: 'Emelési magasság (mm)',
label_ground_clearance_mm: 'Hasmagasság (mm)',
label_pto_type: 'Kihajtás típusa (PTO)',
label_has_differential_lock: 'Differenciálzár',
// HGV body types
hgvBodyTypes: {
chassis: 'Alváz',
chassis_cab: 'Alvázas fülke',
tipper: 'Billenős',
tipper_3_way: '3 irányba billenő',
tipper_double: 'Kétoldalt billenő',
tipper_trailer: 'Billenős pótkocsi',
box: 'Dobozos',
box_tail_lift: 'Dobozos raktér emelővel',
box_insulated: 'Hőszigetelt dobozos',
box_curtain_side: 'Függönyoldalas dobozos',
curtain_sider: 'Függönyoldalas',
flatbed: 'Platós',
flatbed_stake: 'Rakodóléces platós',
flatbed_dropside: 'Billenő oldalfalú platós',
refrigerated: 'Hűtött',
refrigerated_trailer: 'Hűtött pótkocsi',
tanker: 'Tartályos',
tanker_fuel: 'Üzemanyag szállító tartály',
tanker_milk: 'Tej szállító tartály',
tanker_chemical: 'Vegyi anyag szállító tartály',
tanker_powder: 'Poranyag szállító tartály',
tractor_unit: 'Vontató (nyerges)',
tractor_unit_4x2: 'Vontató 4x2',
tractor_unit_6x2: 'Vontató 6x2',
tractor_unit_6x4: 'Vontató 6x4',
tractor_unit_8x4: 'Vontató 8x4',
semi_trailer: 'Félpótkocsi',
semi_trailer_curtain: 'Függönyoldalas félpótkocsi',
semi_trailer_box: 'Dobozos félpótkocsi',
semi_trailer_tipper: 'Billenős félpótkocsi',
semi_trailer_tanker: 'Tartályos félpótkocsi',
semi_trailer_flatbed: 'Platós félpótkocsi',
semi_trailer_low_loader: 'Mélyágyas félpótkocsi',
semi_trailer_car_transporter: 'Autószállító félpótkocsi',
container: 'Konténer',
container_carrier: 'Konténer szállító',
swap_body: 'Cserélhető felépítmény',
hook_lift: 'Rakodókaros (Hook Lift)',
crane: 'Daru',
crane_truck: 'Darus teherautó',
crane_trailer: 'Darus pótkocsi',
fire_truck: 'Tűzoltóautó',
fire_truck_aerial: 'Léptetőkosaras tűzoltó',
ambulance: 'Mentőautó',
hearse: 'Halottaskocsi',
armored: 'Páncélozott',
livestock: 'Állatszállító',
car_transporter: 'Autószállító',
mobile_shop: 'Mozgó bolt',
workshop: 'Műhelyautó',
tow_truck: 'Mentő / Szállító',
street_sweeper: 'Utcaseprő',
refuse_truck: 'Szemetes',
concrete_mixer: 'Betonszállító',
concrete_pump: 'Betonszivattyú',
dump_truck: 'Billenős teherautó',
timber_truck: 'Faszállító',
log_trailer: 'Farönk szállító pótkocsi',
double_cab: 'Dupla kabin',
triple_cab: 'Háromajtós kabin',
crew_cab: 'Crew Cab',
sleeper_cab: 'Alvófülkés',
day_cab: 'Nappali fülke',
glider_kit: 'Glider Kit (felújított)',
},
// HGV transmission types
hgvTransmissionTypes: {
manual: 'Manuális',
manual_async: 'Manuális (aszinkron)',
semi_auto: 'Félautomata',
auto: 'Automata',
manual_split: 'Manuális felezős',
semi_auto_split: 'Félautomata felezős',
auto_split: 'Automata felezős',
cvt: 'Fokozatmentes (CVT)',
},
// PTO types
ptoTypes: {
front: 'Első kihajtás (Front)',
engine_mounted: 'Motorra szerelt (Engine-mounted)',
gearbox_mounted: 'Váltóra szerelt (Gearbox-mounted)',
},
// HGV feature categories
hgvFeatureCategories: {
technical_work: 'Műszaki / Munka',
cabin: 'Fülke',
multimedia: 'Multimédia',
},
// HGV features (extras)
hgvFeatures: {
// Technical / Work
diff_lock_front: 'Első differenciálzár',
diff_lock_rear: 'Hátsó differenciálzár',
diff_lock_center: 'Központi differenciálzár',
diff_lock_full: 'Teljes differenciálzár',
cross_axle_lock: 'Kereszttengely differenciálzár',
retarder: 'Retarder',
exhaust_brake: 'Kipufogófék',
engine_brake: 'Motorfék',
auxiliary_brake: 'Segédfék',
hill_holder: 'Hegymeneti tartó',
hill_descent_control: 'Lejtmenetvezérlő',
traction_control: 'Kipörgésgátló',
stability_control: 'Stabilitás szabályzó (ESP)',
roll_stability: 'Borulásgátló (RSP)',
lane_departure: 'Sávelhagyás figyelő',
adaptive_cruise: 'Adaptív tempomat (ACC)',
collision_warning: 'Ütközés figyelmeztető',
emergency_brake_assist: 'Vészfék asszisztens',
blind_spot_monitor: 'Holtpont figyelő',
traffic_sign_recognition: 'Táblafelismerő',
driver_alert: 'Fáradtságfigyelő',
tyre_pressure_monitor: 'Guminyomás monitor (TPMS)',
reverse_camera: 'Tolatókamera',
'360_camera': '360°-os kamera',
side_camera: 'Oldalsó kamera',
front_camera: 'Első kamera',
rear_camera: 'Hátsó kamera',
parking_sensors: 'Parkolóérzékelők',
front_parking_sensors: 'Első parkolóérzékelők',
rear_parking_sensors: 'Hátsó parkolóérzékelők',
side_parking_sensors: 'Oldalsó parkolóérzékelők',
tachograph: 'Menetíró (Tachográf)',
digital_tachograph: 'Digitális menetíró',
smart_tachograph: 'Smart Tachográf (G2V2)',
gps_tracker: 'GPS nyomkövető',
fleet_management: 'Flottamenedzsment rendszer',
fuel_monitoring: 'Üzemanyag monitorozás',
eco_driving: 'Eco Driving asszisztens',
weigh_in_motion: 'Menet közbeni mérlegelés',
load_securing: 'Rakományrögzítő rendszer',
// Cabin
sleeper_cabin: 'Alvófülke',
single_bunk: 'Egyágyas fekvőhely',
double_bunk: 'Kétágyas fekvőhely',
upper_bunk: 'Felső ágy',
lower_bunk: 'Alsó ágy',
fridge: 'Hűtőszekrény',
freezer: 'Fagyasztó',
microwave: 'Mikrohullámú sütő',
coffee_machine: 'Kávéfőző',
water_heater: 'Vízforraló',
air_conditioner: 'Légkondícionáló',
parking_heater: 'Parkolófűtés',
auxiliary_heater: 'Kiegészítő fűtés',
night_heater: 'Éjszakai fűtés',
roof_vent: 'Tetőszellőző',
power_vent: 'Elektromos tetőszellőző',
sun_visor: 'Napellenző',
curtain_set: 'Függönykészlet',
cabin_lighting: 'Fülkevilágítás',
led_cabin_lighting: 'LED fülkevilágítás',
reading_light: 'Olvasólámpa',
cabin_table: 'Fülkeasztal',
passenger_seat: 'Utasülés',
passenger_seat_swivel: 'Forgatható utasülés',
driver_seat_suspension: 'Légrugós vezetőülés',
seat_heating: 'Ülésfűtés',
seat_ventilation: 'Ülésszellőztetés',
armrest: 'Karfás ülés',
storage_box: 'Tároló rekesz',
wardrobe: 'Gardrób',
// Multimedia
navigation: 'Navigáció (GPS)',
apple_carplay: 'Apple CarPlay',
android_auto: 'Android Auto',
bluetooth: 'Bluetooth',
usb_charging: 'USB töltő',
wireless_charging: 'Vezeték nélküli töltő',
dab_radio: 'DAB+ digitális rádió',
cb_radio: 'CB Rádió',
satellite_radio: 'Műholdas rádió',
digital_tv: 'Digitális TV',
dvb_t: 'DVB-T TV',
dvb_s: 'DVB-S műholdas TV',
premium_sound: 'Prémium hangrendszer',
subwoofer: 'Mélynyomó',
rear_entertainment: 'Hátsó szórakoztató rendszer',
wifi_hotspot: 'WiFi Hotspot',
},
},
// ── Service Finder ────────────────────────────────────────────────
serviceFinder: {
title: '🔧 Service Finder',
heroTitle: '🔧 <span class="text-sf-accent">Service</span> Finder',
heroSubtitle: 'Találd meg a legjobb autós szolgáltatókat egy helyen',
// Dashboard Card 3 — Indítópult
plannedMaintenance: 'Tervezett Karbantartás',
sosTitle: 'Segélyhívás',
sosSubtitle: 'Azonnali úti segítségre van szükséged?',
comingSoon: 'Hamarosan',
searchPlaceholder: 'Szolgáltató vagy szolgáltatás (pl. Klíma)',
cityPlaceholder: 'Település vagy Irányítószám',
searchButton: 'Keresés',
searching: 'Keresés...',
quickFilters: 'Gyors szűrők:',
resultsTitle: 'Találatok ({count})',
startSearching: 'Kezdj el keresni!',
noResults: 'Nincs találat a megadott keresésre.',
pageInfo: 'Oldal: {current} / {total}',
backToDashboard: 'Vissza a vezérlőpultra',
noResultsTitle: 'Nincs találat',
noResultsHint: 'Próbáld meg más keresőszavakkal, vagy böngéssz a gyors szűrők segítségével!',
showAll: 'Összes szolgáltató mutatása',
previous: 'Előző',
next: 'Következő',
// Source badges
sourceVerified: 'Hitelesített',
sourceCommunity: 'Közösségi',
sourceRobot: 'Robot adat',
sourceVerifiedTitle: 'Hitelesített szolgáltató',
sourceCommunityTitle: 'Közösség által ajánlott',
sourceRobotTitle: 'Robot által gyűjtött adat',
sourceVerifiedLabel: 'Hitelesített szolgáltató',
sourceCommunityLabel: 'Közösség által ajánlott',
sourceRobotLabel: 'Robot által gyűjtött',
addressNotAvailable: 'Cím nem elérhető',
noCategory: 'Nincs kategória',
// Quick filter chips
filterAutoSzerelo: '🔧 Autószerelő',
filterGumiszerviz: '🛞 Gumiszerviz',
filterKarosszeria: '🔩 Karosszéria',
filterAutoMento: '🚛 Autómentő',
filterBenzinkut: '⛽ Benzinkút',
filterAutoKozmetika: '✨ Autókozmetika',
// SOS Tab
sosTabTitle: 'SOS Mentés',
sosPlaceholder: '🚧 Ez a funkció fejlesztés alatt áll. Hamarosan kérhető lesz itt az azonnali úti segítség!',
// Planned Tab
plannedTabTitle: 'Tervezett Keresés',
// Provider Detail Modal
detailTitle: 'Szolgáltató adatlap',
detailCategory: 'Kategória',
detailAddress: 'Cím',
detailSource: 'Forrás',
detailSpecialization: 'Szakértelem',
detailRating: 'Értékelés',
detailNoRating: 'Még nincs értékelés',
detailNoSpecialization: 'Nincs megadva',
editRequest: '✏️ Adatok kiegészítése (Pontszerzés!)',
close: 'Bezárás',
},
// ── Provider ──────────────────────────────────────────────────────
provider: {
quickAddTitle: 'Új szolgáltató rögzítése',
quickAddSubtitle: 'Segíts a közösségnek! Pontokat kapsz a hozzáadásért.',
nameLabel: 'Szolgáltató neve',
nameRequired: 'Kötelező',
namePlaceholder: 'Pl. MOL Autószerviz',
categoryLabel: 'Kategória',
categoryRequired: 'Kötelező',
categoryPlaceholder: 'Válassz kategóriát',
zipLabel: 'Irányítószám',
zipOptional: 'opcionális',
zipPlaceholder: 'Pl. 1011',
cityLabel: 'Város',
cityOptional: 'opcionális',
cityPlaceholder: 'Pl. Budapest',
streetLabel: 'Utca, házszám',
streetOptional: 'opcionális',
streetPlaceholder: 'Pl. Kossuth utca 1',
submitButton: 'Szolgáltató rögzítése és pontszerzés!',
submitting: 'Rögzítés...',
closeButton: 'Bezárás',
successMessage: 'Sikeres rögzítés! Szereztél {points} pontot!',
successSubtext: 'Köszönjük, hogy segítesz a közösség építésében!',
errorMessage: 'Hiba történt a szolgáltató rögzítése során. Kérjük, próbáld újra!',
// Autocomplete
autocompletePlaceholder: 'Szolgáltató keresése...',
autocompleteNoResults: 'Nincs találat a keresésre.',
autocompleteAddNew: '+ Új szolgáltató rögzítése és pontszerzés!',
autocompleteClear: 'Kiválasztás törlése',
autocompleteVerified: '✓ Ellenőrzött',
autocompleteSourceVerified: 'Ellenőrzött',
autocompleteSourceRobot: 'Robot által gyűjtött',
autocompleteSourceCommunity: 'Közösségi',
// Soft-deduplication
duplicateWarning: 'Hasonló szolgáltatókat találtunk a környéken: {matches}. Biztosan újat szeretnél rögzíteni?',
duplicateWarningShort: 'Hasonló szolgáltató található a környéken: {name}. Biztosan újat szeretnél rögzíteni?',
// Edit Modal
editTitle: 'Szolgáltató adatok szerkesztése',
streetNameLabel: 'Utca neve',
streetNamePlaceholder: 'Pl. Egressy',
streetTypeLabel: 'Közterület jellege',
streetTypePlaceholder: 'Válassz típust...',
houseNumberLabel: 'Házszám',
houseNumberPlaceholder: 'Pl. 4',
phoneLabel: 'Telefonszám',
phonePlaceholder: 'Pl. +36 20 123 4567',
emailLabel: 'Email',
emailPlaceholder: 'Pl. info@szolgaltato.hu',
websiteLabel: 'Weboldal',
websitePlaceholder: 'Pl. https://szolgaltato.hu',
tagsLabel: 'Szolgáltatás Címkék',
tagsPlaceholder: 'Címkék hozzáadása (vesszővel elválasztva)',
editInfo: 'A szerkesztéseid ellenőrzésre kerülnek, és gamifikációs pontokat szerezhetsz!',
},
admin: {
services: {
title: 'Szolgáltatások',

View File

@@ -14,6 +14,9 @@
</template>
<template #right>
<!-- Language Switcher -->
<LanguageSwitcher />
<!-- Hamburger Menu (Private Garage Navigation) -->
<div class="relative hamburger-container">
<button
@@ -100,6 +103,7 @@ 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'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
const router = useRouter()
const route = useRoute()

View File

@@ -18,6 +18,11 @@ const router = createRouter({
path: '',
name: 'dashboard',
component: () => import('../views/DashboardView.vue')
},
{
path: 'service-finder',
name: 'service-finder',
component: () => import('../views/ServiceFinderView.vue')
}
]
},

View File

@@ -0,0 +1,18 @@
/**
* Returns today's date as a YYYY-MM-DD string in the **local** timezone.
*
* Why this exists:
* `new Date().toISOString().split('T')[0]` returns the date in UTC.
* In timezones ahead of UTC (e.g. CEST = UTC+2), a local time like
* 01:00 AM is still the *previous day* in UTC, causing date-picker
* defaults to show yesterday.
*
* This function compensates for the timezone offset so the returned
* string always matches the user's local calendar date.
*/
export function getLocalDateString(): string {
const d = new Date()
// Shift by the timezone offset so toISOString reflects local date
d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
return d.toISOString().split('T')[0]
}

View File

@@ -0,0 +1,147 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api/axios'
/**
* Catalog Store
* =============
* Manages cascading vehicle catalog data (brands → models → years → trims)
* fetched from the backend /catalog endpoints.
*
* Used by VehicleFormModal.vue for the brand/model/year/trim autocomplete dropdowns.
*/
export const useCatalogStore = defineStore('catalog', () => {
// ── State ──────────────────────────────────────────────────────────
/** List of brand names (string[]) */
const brands = ref<string[]>([])
/** List of models for the currently selected brand */
const models = ref<any[]>([])
/** List of trims for the currently selected brand+model */
const trims = ref<any[]>([])
/** Loading flags */
const isBrandsLoading = ref(false)
const isModelsLoading = ref(false)
const isTrimsLoading = ref(false)
/** Error state */
const error = ref<string | null>(null)
// ── Actions ────────────────────────────────────────────────────────
/**
* Fetch all available brands from the catalog.
* GET /catalog/brands
*/
async function fetchBrands() {
if (brands.value.length > 0) return // already loaded
isBrandsLoading.value = true
error.value = null
try {
const res = await api.get('/catalog/brands')
brands.value = res.data as string[]
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Nem sikerült betölteni a márkákat.'
error.value = message
console.error('[CatalogStore] fetchBrands error:', err)
} finally {
isBrandsLoading.value = false
}
}
/**
* Fetch models for a given brand, optionally filtered by vehicle_class.
* GET /catalog/brands/{brand}/models?vehicle_class={vehicle_class}
*/
async function fetchModels(brand: string, vehicle_class?: string) {
if (!brand) {
models.value = []
return
}
isModelsLoading.value = true
error.value = null
try {
const params: Record<string, string> = {}
if (vehicle_class) params.vehicle_class = vehicle_class
const res = await api.get(`/catalog/brands/${encodeURIComponent(brand)}/models`, { params })
models.value = res.data as any[]
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Nem sikerült betölteni a modelleket.'
error.value = message
console.error('[CatalogStore] fetchModels error:', err)
models.value = []
} finally {
isModelsLoading.value = false
}
}
/**
* Fetch trims for a given brand + model + optional year.
* GET /catalog/brands/{brand}/models/{model}/trims?year={year}
*/
async function fetchTrims(brand: string, model: string, year?: string) {
if (!brand || !model) {
trims.value = []
return
}
isTrimsLoading.value = true
error.value = null
try {
const params: Record<string, string> = {}
if (year) params.year = year
const res = await api.get(
`/catalog/brands/${encodeURIComponent(brand)}/models/${encodeURIComponent(model)}/trims`,
{ params }
)
trims.value = res.data as any[]
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Nem sikerült betölteni a felszereltségeket.'
error.value = message
console.error('[CatalogStore] fetchTrims error:', err)
trims.value = []
} finally {
isTrimsLoading.value = false
}
}
/**
* Clear all catalog data (e.g. on logout or brand reset).
*/
function clearCatalog() {
brands.value = []
models.value = []
trims.value = []
error.value = null
}
return {
// State
brands,
models,
trims,
isBrandsLoading,
isModelsLoading,
isTrimsLoading,
error,
// Actions
fetchBrands,
fetchModels,
fetchTrims,
clearCatalog,
}
})

View File

@@ -0,0 +1,92 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api/axios'
/**
* Wallet balance shape returned by GET /billing/wallet/balance
*/
export interface WalletBalance {
earned: number
purchased: number
service_coins: number
voucher: number
total: number
}
export const useFinanceStore = defineStore('finance', () => {
// ── State ──────────────────────────────────────────────────────────
const balance = ref<WalletBalance | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// ── Getters ────────────────────────────────────────────────────────
/** Total credits across all wallet pockets */
const totalCredits = computed(() => balance.value?.total ?? 0)
/** Purchased credits (manually topped up) */
const purchasedCredits = computed(() => balance.value?.purchased ?? 0)
/** Service Coins (bonus/reward credits) */
const serviceCoins = computed(() => balance.value?.service_coins ?? 0)
/** Earned credits (from activities) */
const earnedCredits = computed(() => balance.value?.earned ?? 0)
/** Voucher balance */
const voucherBalance = computed(() => balance.value?.voucher ?? 0)
/** Whether the wallet has any credits */
const hasCredits = computed(() => totalCredits.value > 0)
// ── Actions ────────────────────────────────────────────────────────
/**
* Fetch the current user's wallet balance from the backend.
* GET /billing/wallet/balance
*/
async function fetchWalletBalance() {
isLoading.value = true
error.value = null
try {
const res = await api.get('/billing/wallet/balance')
balance.value = res.data as WalletBalance
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Nem sikerült betölteni a pénztárca egyenleget.'
error.value = message
console.error('[FinanceStore] fetchWalletBalance error:', err)
} finally {
isLoading.value = false
}
}
/**
* Clear any stored error.
*/
function clearError() {
error.value = null
}
return {
// State
balance,
isLoading,
error,
// Getters
totalCredits,
purchasedCredits,
serviceCoins,
earnedCredits,
voucherBalance,
hasCredits,
// Actions
fetchWalletBalance,
clearError,
}
})

View File

@@ -37,6 +37,10 @@ export interface Vehicle {
created_at: string
updated_at: string | null
// Warranty
is_under_warranty: boolean
warranty_expiry_date: string | null
// Sales
is_for_sale: boolean
price: number | null
@@ -48,6 +52,34 @@ export interface Vehicle {
// Organization association (from backend AssetResponse)
current_organization_id: number | null
owner_organization_id: number | null
// JSONB individual_equipment from backend (car_specs, ev_specs, dates, etc.)
individual_equipment?: {
car_specs?: {
body_type?: string
ac_type?: string
cylinder_layout?: string
features?: string[]
}
ev_specs?: {
battery_capacity_kwh?: number
battery_soh?: number
ac_connector?: string
ac_power_kw?: number
dc_connector?: string
dc_power_kw?: number
range_wltp?: number
range_highway?: number
range_winter?: number
fast_charge_support?: boolean
green_plate_eligible?: boolean
}
dates?: {
mot_expiry?: string
document_expiry?: string
}
[key: string]: any
}
}
export const useVehicleStore = defineStore('vehicle', () => {
@@ -99,6 +131,29 @@ export const useVehicleStore = defineStore('vehicle', () => {
}
}
/**
* Fetch a single vehicle by its ID.
* GET /assets/vehicles/{id}
* Used to refresh vehicle data after odometer updates or other mutations.
*/
async function fetchVehicleById(id: string): Promise<Vehicle | null> {
try {
const res = await api.get(`/assets/vehicles/${id}`)
const updated = res.data as Vehicle
// Update the vehicle in local state
const index = vehicles.value.findIndex(v => v.id === id)
if (index !== -1) {
vehicles.value[index] = updated
}
return updated
} catch (err: any) {
console.error('[VehicleStore] fetchVehicleById error:', err)
return null
}
}
/**
* Create a new vehicle via POST /assets/vehicles.
* On success, adds the new vehicle to the local state and refreshes the list.
@@ -239,6 +294,7 @@ export const useVehicleStore = defineStore('vehicle', () => {
// Actions
fetchVehicles,
fetchVehicleById,
createVehicle,
updateVehicle,
setPrimaryVehicle,

View File

@@ -163,51 +163,40 @@
</div>
</div>
<!--
Card 3: 🔧 Service Finder (Szerviz kereső)
<!--
Card 3: 🔧 Service Finder (3-Way Indítópult)
-->
<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 cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="openCard('costs')"
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"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🔧 Service Finder</span>
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.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">
<!-- Active notifications / service alerts -->
<div
v-for="(notif, idx) in notifications"
:key="idx"
class="flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm"
>
<span class="mt-0.5 text-base">{{ notif.icon }}</span>
<div>
<p class="text-slate-800 font-medium">{{ notif.title }}</p>
<p class="text-slate-400 text-xs mt-0.5">{{ notif.time }}</p>
</div>
</div>
<!-- Empty state -->
<div
v-if="notifications.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm">{{ t('dashboard.noNotifications') }}</p>
</div>
</div>
<!-- Content: 3 simple buttons stacked vertically -->
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
<button
@click="router.push('/dashboard/service-finder?mode=planned')"
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🔍 {{ t('serviceFinder.plannedMaintenance') }}
</button>
<button
@click="router.push('/dashboard/service-finder?mode=sos')"
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🚨 SOS {{ t('serviceFinder.sosTitle') }}
</button>
<button
@click="isSfQuickAddOpen = true"
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
{{ t('provider.quickAddTitle') }}
</button>
</div>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('menu.features') }} 🔍
</button>
</div>
<!--
<!--
Card 4: 🏆 Gamification (Játékosítás)
-->
<div
@@ -401,6 +390,14 @@
@saved="onEditVehicleSaved"
@deleted="onVehicleDeleted"
/>
<!--
Provider Quick Add Modal (Service Finder Card 3)
-->
<ProviderQuickAddModal
:is-open="isSfQuickAddOpen"
@close="isSfQuickAddOpen = false"
@saved="onSfQuickAddSaved"
/>
</div><!-- end min-h-screen -->
</template>
@@ -418,6 +415,7 @@ import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue'
import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue'
import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue'
import ProviderQuickAddModal from '../components/provider/ProviderQuickAddModal.vue'
const { t } = useI18n()
@@ -438,6 +436,18 @@ const openCard = (cardId: string, targetId: string | null = null) => {
selectedTargetId.value = targetId
}
// ── Service Finder (Card 3) — Launcher ──
const isSearchModalOpen = ref(false)
const isSfQuickAddOpen = ref(false)
/**
* Called when ProviderQuickAddModal saves a new provider.
*/
function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
isSfQuickAddOpen.value = false
alert(`🎉 ${t('provider.successMessage', { points: 0 })}`)
}
// ── Vehicle Detail Modal (Deep Link from plate click) ──
const isVehicleDetailOpen = ref(false)
const detailVehicle = ref<VehicleData | null>(null)

View File

@@ -21,15 +21,10 @@
</template>
</BaseHeader>
<!-- Backdrop overlay: click outside the card closes the profile -->
<div
class="fixed inset-0 z-10 bg-black/30 backdrop-blur-[2px]"
@click="closeProfile"
>
<!-- Content (card) @click.stop prevents backdrop click from propagating -->
<!-- Profile Content padding-top clears the sticky header -->
<div class="relative z-10 pt-20">
<div
@click.stop
class="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 overflow-y-auto max-h-screen"
class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8"
>
<!-- Loading Spinner -->
<div
@@ -906,7 +901,7 @@
</div>
</div>
</Teleport>
</div><!-- /backdrop overlay -->
</div><!-- /content wrapper -->
</div><!-- /root -->
</template>

View File

@@ -0,0 +1,603 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-800">
<!-- Hero Search Section -->
<div class="relative overflow-hidden bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
<!-- Decorative background circles -->
<div class="pointer-events-none absolute -right-20 -top-20 h-64 w-64 rounded-full bg-sf-accent/10 blur-3xl" />
<div class="pointer-events-none absolute -bottom-20 -left-20 h-48 w-48 rounded-full bg-sf-blue/10 blur-3xl" />
<div class="relative mx-auto max-w-4xl">
<!-- Title -->
<h1 class="mb-2 text-center text-4xl font-extrabold tracking-tight text-white sm:text-5xl" v-html="t('serviceFinder.heroTitle')" />
<p class="mb-8 text-center text-lg text-slate-300">
{{ t('serviceFinder.heroSubtitle') }}
</p>
<!-- Dual Search Bar -->
<div class="mx-auto flex max-w-2xl flex-col gap-3 sm:flex-row">
<!-- Input 1: Service / Provider -->
<div class="relative flex-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="t('serviceFinder.searchPlaceholder')"
class="w-full rounded-2xl border-2 border-transparent bg-white/10 py-4 pl-12 pr-4 text-base text-white placeholder-slate-400 backdrop-blur-sm transition-all focus:border-sf-accent focus:bg-white/15 focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
@keyup.enter="handleSearch"
/>
</div>
<!-- Input 2: City / ZIP -->
<div class="relative flex-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<input
v-model="searchCity"
type="text"
:placeholder="t('serviceFinder.cityPlaceholder')"
class="w-full rounded-2xl border-2 border-transparent bg-white/10 py-4 pl-12 pr-4 text-base text-white placeholder-slate-400 backdrop-blur-sm transition-all focus:border-sf-accent focus:bg-white/15 focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
@keyup.enter="handleSearch"
/>
</div>
<button
@click="handleSearch"
:disabled="isSearching"
class="inline-flex items-center justify-center gap-2 rounded-2xl bg-sf-accent px-8 py-4 text-base font-bold text-white shadow-lg transition-all hover:bg-sf-accent/90 hover:shadow-xl disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer"
>
<svg
v-if="isSearching"
class="h-5 w-5 animate-spin"
fill="none" viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{{ isSearching ? t('serviceFinder.searching') : t('serviceFinder.searchButton') }}
</button>
</div>
<!-- Quick filter chips -->
<div class="mt-6 flex flex-wrap items-center justify-center gap-2">
<span class="text-xs font-medium text-slate-400">{{ t('serviceFinder.quickFilters') }}</span>
<button
v-for="chip in quickFilters"
:key="chip.key"
@click="applyQuickFilter(chip.key)"
class="rounded-full border border-white/20 px-3 py-1 text-xs font-medium text-slate-300 transition-all hover:border-sf-accent hover:bg-sf-accent/20 hover:text-white cursor-pointer"
>
{{ chip.label }}
</button>
</div>
</div>
</div>
<!-- Tab Bar (Planned Search / SOS Rescue) -->
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex items-center gap-1 border-b border-slate-200 pt-4">
<button
@click="activeTab = 'planned'"
class="inline-flex items-center gap-2 rounded-t-xl px-5 py-3 text-sm font-semibold transition-all cursor-pointer"
:class="activeTab === 'planned'
? 'bg-white text-sf-accent shadow-sm border border-b-0 border-slate-200'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'"
>
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{{ t('serviceFinder.plannedTabTitle') }}
</button>
<button
@click="activeTab = 'sos'"
class="inline-flex items-center gap-2 rounded-t-xl px-5 py-3 text-sm font-semibold transition-all cursor-pointer"
:class="activeTab === 'sos'
? 'bg-white text-red-600 shadow-sm border border-b-0 border-slate-200'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'"
>
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
{{ t('serviceFinder.sosTabTitle') }}
</button>
</div>
</div>
<!-- Tab Content -->
<!-- Tab: Planned Search (existing search UI) -->
<template v-if="activeTab === 'planned'">
<!-- Results Section -->
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Results header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-slate-800">
{{ hasSearched ? t('serviceFinder.resultsTitle', { count: totalResults }) : t('serviceFinder.startSearching') }}
</h2>
<p v-if="hasSearched" class="text-sm text-slate-500">
{{ totalResults === 0 ? t('serviceFinder.noResults') : t('serviceFinder.pageInfo', { current: currentPage, total: totalPages }) }}
</p>
</div>
<!-- Back to Dashboard -->
<button
@click="goBack"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition-all hover:bg-slate-50 hover:shadow-md cursor-pointer"
>
<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('serviceFinder.backToDashboard') }}
</button>
</div>
<!-- Loading skeleton -->
<div
v-if="isSearching"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="n in 6"
:key="n"
class="animate-pulse rounded-2xl bg-white p-6 shadow-sm"
>
<div class="mb-3 h-5 w-3/4 rounded bg-slate-200" />
<div class="mb-2 h-4 w-1/2 rounded bg-slate-200" />
<div class="mb-4 h-4 w-2/3 rounded bg-slate-200" />
<div class="flex gap-2">
<div class="h-6 w-16 rounded-full bg-slate-200" />
<div class="h-6 w-20 rounded-full bg-slate-200" />
</div>
</div>
</div>
<!-- Empty state -->
<div
v-if="hasSearched && !isSearching && results.length === 0"
class="flex flex-col items-center justify-center rounded-2xl bg-white py-20 shadow-sm"
>
<span class="mb-4 text-6xl">🔍</span>
<h3 class="mb-2 text-xl font-bold text-slate-700">{{ t('serviceFinder.noResultsTitle') }}</h3>
<p class="mb-6 max-w-md text-center text-slate-500">
{{ t('serviceFinder.noResultsHint') }}
</p>
<button
@click="searchQuery = ''; searchCity = ''; handleSearch()"
class="rounded-xl bg-sf-accent/10 px-6 py-3 text-sm font-semibold text-sf-accent transition-all hover:bg-sf-accent/20 cursor-pointer"
>
{{ t('serviceFinder.showAll') }}
</button>
</div>
<!-- Results Grid (clickable cards) -->
<div
v-if="!isSearching && results.length > 0"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="provider in results"
:key="`${provider.source}-${provider.id}`"
class="group relative flex flex-col overflow-hidden rounded-2xl bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl cursor-pointer"
@click="openDetail(provider)"
>
<!-- Source badge (top-right corner) -->
<div class="absolute right-3 top-3 z-10">
<span
v-if="provider.source === 'verified_org'"
class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 shadow-sm"
:title="t('serviceFinder.sourceVerifiedTitle')"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
{{ t('serviceFinder.sourceVerified') }}
</span>
<span
v-else-if="provider.source === 'crowd_added'"
class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-semibold text-amber-700 shadow-sm"
:title="t('serviceFinder.sourceCommunityTitle')"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
</svg>
{{ t('serviceFinder.sourceCommunity') }}
</span>
<span
v-else
class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-600 shadow-sm"
:title="t('serviceFinder.sourceRobotTitle')"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{{ t('serviceFinder.sourceRobot') }}
</span>
</div>
<!-- Card body -->
<div class="flex flex-1 flex-col p-5">
<!-- Company name -->
<h3 class="mb-2 text-lg font-bold text-slate-800 group-hover:text-sf-accent transition-colors">
{{ provider.name }}
</h3>
<!-- Address (P1 CRITICAL ALIGN: atomizált címmezőkből építkezünk) -->
<div class="mb-3 flex items-start gap-2 text-sm text-slate-500">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>
{{ formatCardAddress(provider) }}
</span>
</div>
<!-- Specialization chips / tags -->
<div
v-if="provider.specialization && provider.specialization.length > 0"
class="mb-4 flex flex-wrap gap-1.5"
>
<span
v-for="(tag, idx) in provider.specialization.slice(0, 5)"
:key="idx"
class="inline-flex items-center rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
</span>
<span
v-if="provider.specialization.length > 5"
class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-500"
>
+{{ provider.specialization.length - 5 }}
</span>
</div>
<!-- Category badge ( szolgáltatás - mindig megjelenik) -->
<div class="mb-3">
<span
v-if="provider.category"
class="inline-flex items-center rounded-md bg-sf-accent/10 px-2.5 py-1 text-xs font-semibold text-sf-accent"
>
{{ provider.category }}
</span>
<span
v-else
class="inline-flex items-center rounded-md bg-slate-50 px-2.5 py-1 text-xs text-slate-300 border border-dashed border-slate-200"
>
{{ t('serviceFinder.noCategory') }}
</span>
</div>
<!-- Rating -->
<div
v-if="provider.rating"
class="mb-3 flex items-center gap-1"
>
<svg
v-for="star in 5"
:key="star"
class="h-4 w-4"
:class="star <= Math.round(provider.rating!) ? 'text-amber-400' : 'text-slate-200'"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span class="ml-1 text-xs font-medium text-slate-500">{{ provider.rating.toFixed(1) }}</span>
</div>
<!-- Spacer to push footer down -->
<div class="flex-1" />
<!-- Card footer: source indicator -->
<div class="flex items-center gap-2 border-t border-slate-100 pt-3 text-xs text-slate-400">
<span
class="inline-flex h-2 w-2 rounded-full"
:class="{
'bg-emerald-500': provider.source === 'verified_org',
'bg-amber-500': provider.source === 'crowd_added',
'bg-slate-400': provider.source === 'staged_data',
}"
/>
{{ provider.source === 'verified_org' ? t('serviceFinder.sourceVerifiedLabel') : provider.source === 'crowd_added' ? t('serviceFinder.sourceCommunityLabel') : t('serviceFinder.sourceRobotLabel') }}
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div
v-if="totalPages > 1 && !isSearching"
class="mt-8 flex items-center justify-center gap-2"
>
<button
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
class="inline-flex items-center gap-1 rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition-all hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
<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="M15 19l-7-7 7-7" />
</svg>
{{ t('serviceFinder.previous') }}
</button>
<span class="rounded-xl bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">
{{ currentPage }} / {{ totalPages }}
</span>
<button
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
class="inline-flex items-center gap-1 rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition-all hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{{ t('serviceFinder.next') }}
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template><!-- end planned tab -->
<!-- Tab: SOS Rescue (placeholder) -->
<template v-if="activeTab === 'sos'">
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="flex flex-col items-center justify-center rounded-2xl bg-white py-20 shadow-sm">
<span class="mb-4 text-6xl">🚧</span>
<h3 class="mb-2 text-xl font-bold text-slate-700">{{ t('serviceFinder.sosTabTitle') }}</h3>
<p class="mb-6 max-w-md text-center text-slate-500">
{{ t('serviceFinder.sosPlaceholder') }}
</p>
</div>
</div>
</template><!-- end sos tab -->
<!-- Provider Detail Modal -->
<ProviderDetailModal
:is-open="detailModalOpen"
:provider="selectedProvider"
@close="detailModalOpen = false"
@edit-request="handleEditRequest"
/>
<!-- Provider Edit Modal -->
<ProviderEditModal
:is-open="editModalOpen"
:provider="selectedProvider"
@close="editModalOpen = false"
@saved="handleEditSaved"
/>
</div>
</template>
<script setup lang="ts">
/**
* ServiceFinderView.vue — Dedikált, teljes képernyős Szervizkereső Oldal.
*
* Két beviteli mező: szolgáltatás/szolgáltató (searchQuery) + település (searchCity).
* A kártyák kattinthatóak, megnyitják a ProviderDetailModal-t.
* Minden szöveg i18n-ből jön.
*
* @see backend/app/api/v1/endpoints/providers.py — search endpoint
* @see backend/app/schemas/provider.py — ProviderSearchResult schema
* @see backend/app/services/provider_service.py — search_providers() logic
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import api from '@/api/axios'
import ProviderDetailModal from '@/components/provider/ProviderDetailModal.vue'
import ProviderEditModal from '@/components/provider/ProviderEditModal.vue'
const { t } = useI18n()
// ── Types matching backend ProviderSearchResult ──
// @see backend/app/schemas/provider.py:21-33
interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
city: string | null
address: string | null
source: 'verified_org' | 'staged_data' | 'crowd_added'
is_verified: boolean
rating: number | null
}
interface ProviderSearchResponse {
results: ProviderSearchResult[]
total: number
page: number
per_page: number
}
// ── Quick filter chips (labels from i18n, computed for reactivity) ──
const quickFilters = computed(() => [
{ key: 'auto_szerelo', label: t('serviceFinder.filterAutoSzerelo') },
{ key: 'gumiszerviz', label: t('serviceFinder.filterGumiszerviz') },
{ key: 'karosszerialakatos', label: t('serviceFinder.filterKarosszeria') },
{ key: 'autómentő', label: t('serviceFinder.filterAutoMento') },
{ key: 'benzinkút', label: t('serviceFinder.filterBenzinkut') },
{ key: 'autókozmetika', label: t('serviceFinder.filterAutoKozmetika') },
])
const router = useRouter()
const route = useRoute()
// ── Active Tab (from route.query.mode or default 'planned') ──
const activeTab = ref<'planned' | 'sos'>('planned')
// ── Reactive state ──
const searchQuery = ref('')
const searchCity = ref('')
const results = ref<ProviderSearchResult[]>([])
const totalResults = ref(0)
const currentPage = ref(1)
const perPage = ref(20)
const isSearching = ref(false)
const hasSearched = ref(false)
// ── Detail Modal State ──
const detailModalOpen = ref(false)
const editModalOpen = ref(false)
const selectedProvider = ref<ProviderSearchResult | null>(null)
// ── Computed ──
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / perPage.value)))
// ── Initialize tab from route query ──
onMounted(() => {
const mode = route.query.mode
if (mode === 'sos') {
activeTab.value = 'sos'
} else {
activeTab.value = 'planned'
}
})
// ── Search logic (passes both q and city) ──
async function handleSearch() {
isSearching.value = true
hasSearched.value = true
currentPage.value = 1
try {
const offset = 0
const params: Record<string, any> = {
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
results.value = res.data.results || []
totalResults.value = res.data.total || 0
} catch (err: any) {
console.error('[ServiceFinderView] Search error:', err)
results.value = []
totalResults.value = 0
} finally {
isSearching.value = false
}
}
// ── Quick filter click ──
function applyQuickFilter(key: string) {
searchQuery.value = key
// NE töröljük a várost! A chip csak a kategóriát állítsa be.
handleSearch()
}
// ── Pagination ──
async function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
isSearching.value = true
try {
const offset = (page - 1) * perPage.value
const params: Record<string, any> = {
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
results.value = res.data.results || []
totalResults.value = res.data.total || 0
} catch (err: any) {
console.error('[ServiceFinderView] Pagination error:', err)
} finally {
isSearching.value = false
}
}
// ── Detail Modal ──
function openDetail(provider: ProviderSearchResult) {
selectedProvider.value = provider
detailModalOpen.value = true
}
function handleEditRequest(provider: ProviderSearchResult) {
// FEATURE (2026-06-17): Detail modal "Adatok kiegészítése" gomb → Edit modal
detailModalOpen.value = false
selectedProvider.value = provider
editModalOpen.value = true
console.log('[ServiceFinderView] Edit requested for:', provider.name)
}
function handleEditSaved(payload: { id: number }) {
// FEATURE (2026-06-17): Az Edit modal @saved eseménye.
// A backend PUT /providers/{id} már lefutott, itt frissítjük a listát.
console.log('[ServiceFinderView] Provider saved, id:', payload.id)
editModalOpen.value = false
// Re-run search to reflect changes
handleSearch()
}
/**
* P1 CRITICAL ALIGN (2026-06-17): Kártyán megjelenő cím formázása
* az atomizált címmezőkből. Metódus, mert a v-for ciklusban hívjuk
* provider-enként, nem computed property.
*
* Formátum: "irányítószám Város, utca típus házszám"
* Ha nincs atomizált adat, visszaesik a backend által összefűzött address-re.
*/
function formatCardAddress(p: ProviderSearchResult): string {
if (!p) return ''
// Atomizált címmezőkből építkezünk
const parts: string[] = []
if (p.address_zip) parts.push(p.address_zip)
if (p.city) parts.push(p.city)
const streetParts: string[] = []
if (p.address_street_name) streetParts.push(p.address_street_name)
if (p.address_street_type) streetParts.push(p.address_street_type)
if (p.address_house_number) streetParts.push(p.address_house_number)
if (streetParts.length > 0) parts.push(streetParts.join(' '))
if (parts.length > 0) return parts.join(', ')
// Fallback: backend által összefűzött address
return p.address || t('serviceFinder.addressNotAvailable')
}
// ── Navigation ──
function goBack() {
router.push('/dashboard')
}
</script>
<style scoped>
/* Smooth fade-in animation for cards */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.grid > div {
animation: fadeInUp 0.3s ease-out forwards;
}
.grid > div:nth-child(2) { animation-delay: 0.05s; }
.grid > div:nth-child(3) { animation-delay: 0.1s; }
.grid > div:nth-child(4) { animation-delay: 0.15s; }
.grid > div:nth-child(5) { animation-delay: 0.2s; }
.grid > div:nth-child(6) { animation-delay: 0.25s; }
</style>