szolgáltatók beálltásai, szerkesztése , létrehozása
This commit is contained in:
294
frontend/src/components/cost/CostManagerModal.vue
Normal file
294
frontend/src/components/cost/CostManagerModal.vue
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
180
frontend/src/components/dashboard/FinancialCard.vue
Normal file
180
frontend/src/components/dashboard/FinancialCard.vue
Normal 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>
|
||||
@@ -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
475
frontend/src/components/dashboard/WalletDetailsModal.vue
Normal file
475
frontend/src/components/dashboard/WalletDetailsModal.vue
Normal 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>
|
||||
@@ -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 ─────────────────────────────────────────────── -->
|
||||
|
||||
299
frontend/src/components/provider/ProviderAutocomplete.vue
Normal file
299
frontend/src/components/provider/ProviderAutocomplete.vue
Normal 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>
|
||||
277
frontend/src/components/provider/ProviderDetailModal.vue
Normal file
277
frontend/src/components/provider/ProviderDetailModal.vue
Normal 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>
|
||||
413
frontend/src/components/provider/ProviderEditModal.vue
Normal file
413
frontend/src/components/provider/ProviderEditModal.vue
Normal 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"
|
||||
>×</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>
|
||||
579
frontend/src/components/provider/ProviderQuickAddModal.vue
Normal file
579
frontend/src/components/provider/ProviderQuickAddModal.vue
Normal 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"
|
||||
>×</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
18
frontend/src/services/dateUtils.ts
Normal file
18
frontend/src/services/dateUtils.ts
Normal 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]
|
||||
}
|
||||
147
frontend/src/stores/catalog.ts
Normal file
147
frontend/src/stores/catalog.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
92
frontend/src/stores/finance.ts
Normal file
92
frontend/src/stores/finance.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
603
frontend/src/views/ServiceFinderView.vue
Normal file
603
frontend/src/views/ServiceFinderView.vue
Normal 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 (fő 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>
|
||||
Reference in New Issue
Block a user