frontend költség beállítások

This commit is contained in:
Roo
2026-06-23 21:11:21 +00:00
parent 5b437b220d
commit 71ef33bb85
90 changed files with 11850 additions and 1053 deletions

View File

@@ -0,0 +1,7 @@
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8502
CMD ["npm", "run", "dev"]

6
frontend/admin/app.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<div style="font-family: sans-serif; padding: 2rem; background-color: #1a1a1a; color: #fff; min-height: 100vh;">
<h1>ServiceFinder Admin</h1>
<p>A biztonságos adminisztrációs és statisztikai felület konténere sikeresen felállt.</p>
</div>
</template>

View File

@@ -0,0 +1,3 @@
export default defineNuxtConfig({
devtools: { enabled: true }
})

View File

@@ -0,0 +1,16 @@
{
"name": "sf-admin-frontend",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview"
},
"devDependencies": {
"nuxt": "^3.11.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
}
}

View File

@@ -420,7 +420,7 @@
class="px-4 py-2.5 text-sm font-medium text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
@click="$emit('close')"
>
{{ $t('admin.packages.cancel') }}
{{ $t('common.cancel') }}
</button>
<button
class="px-4 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -432,7 +432,7 @@
<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>
{{ $t('admin.packages.saving') }}
{{ $t('common.saving') }}
</span>
<span v-else>
{{ isEditing ? $t('admin.packages.save_changes') : $t('admin.packages.create') }}

View File

@@ -0,0 +1,834 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-white w-full max-w-2xl max-h-[90vh] rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-fade-in-up">
<!-- Header -->
<div class="flex items-center justify-between bg-slate-700 px-6 py-4 shrink-0">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<span>🧾</span>
{{ t('costWizard.title') }}
</h3>
<button
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-colors"
@click="$emit('close')"
>
</button>
</div>
<!-- Stepper Progress -->
<div class="flex items-center justify-center gap-1 px-6 py-4 bg-slate-50 border-b border-slate-200 shrink-0">
<div
v-for="(step, idx) in steps"
:key="idx"
class="flex items-center gap-1"
>
<!-- Step circle + label -->
<div
class="flex flex-col items-center cursor-pointer"
@click="goToStep(idx)"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition-all"
:class="stepClass(idx)"
>
<span v-if="idx < currentStep"></span>
<span v-else>{{ idx + 1 }}</span>
</div>
<span
class="text-[10px] mt-1 whitespace-nowrap transition-colors"
:class="idx <= currentStep ? 'text-sf-accent font-semibold' : 'text-slate-400'"
>
{{ t(step.labelKey) }}
</span>
</div>
<!-- Connector line -->
<div
v-if="idx < steps.length - 1"
class="h-0.5 w-8 mx-1 rounded transition-colors"
:class="idx < currentStep ? 'bg-sf-accent' : 'bg-slate-200'"
/>
</div>
</div>
<!-- Scrollable Form Body -->
<div class="flex-1 overflow-y-auto p-6">
<!--
STEP 1: Alapadatok (Vehicle, Category, Date)
-->
<div v-if="currentStep === 0" class="space-y-5">
<!-- Vehicle (read-only display) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectVehicle') }}</label>
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
</div>
</div>
<!-- Main Category -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectCategory') }}</label>
<select
v-model="form.mainCategory"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@change="onMainCategoryChange"
>
<option value="" disabled>{{ t('costWizard.selectCategory') }}...</option>
<option
v-for="cat in mainCategories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
</select>
</div>
<!-- Sub Category -->
<div v-if="subCategories.length > 0">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectSubCategory') }}</label>
<select
v-model="form.subCategory"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option value="" disabled>{{ t('costWizard.selectSubCategory') }}...</option>
<option
v-for="sub in subCategories"
:key="sub.id"
:value="sub.id"
>
{{ sub.name }}
</option>
</select>
</div>
<!-- Cost Date -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.costDate') }}</label>
<input
v-model="form.date"
type="date"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
</div>
<!--
STEP 2: Pénzügyek (Net, VAT, Gross, Currency)
-->
<div v-if="currentStep === 1" class="space-y-5">
<!-- Net Amount -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.netAmount') }}</label>
<input
v-model.number="form.netAmount"
type="number"
min="0"
step="0.01"
required
placeholder="0.00"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@input="onNetAmountChange"
/>
</div>
<!-- VAT Rate -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.vatRate') }}</label>
<select
v-model.number="form.vatRate"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@change="onVatRateChange"
>
<option :value="0">0%</option>
<option :value="5">5%</option>
<option :value="10">10%</option>
<option :value="20">20%</option>
<option :value="27">27%</option>
</select>
</div>
<!-- Gross Amount (GROSS-FIRST: primary field per Masterbook 2.0.1) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.grossAmount') }} <span class="text-xs text-slate-400 font-normal">({{ t('common.optional') }})</span></label>
<input
v-model.number="form.grossAmount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@input="onGrossAmountChange"
/>
</div>
<!-- Currency -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.currency') }}</label>
<select
v-model="form.currency"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option value="HUF">HUF</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
<option value="CHF">CHF</option>
</select>
</div>
</div>
<!--
STEP 3: Admin & Szállító (Vendor, Invoice, Deadline)
-->
<div v-if="currentStep === 2" class="space-y-5">
<!-- Vendor autocomplete with free-text fallback -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.vendor') }}</label>
<div class="relative">
<input
v-model="vendorSearch"
type="text"
:placeholder="t('costWizard.vendorPlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@input="onVendorSearchInput"
@focus="showVendorDropdown = true"
@blur="onVendorBlur"
/>
<!-- Dropdown suggestions -->
<div
v-if="showVendorDropdown && filteredVendors.length > 0"
class="absolute z-20 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
>
<button
v-for="vendor in filteredVendors"
:key="vendor.organization_id"
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50 hover:text-sf-accent transition-colors border-b border-slate-100 last:border-b-0"
@mousedown.prevent="selectVendor(vendor)"
>
<span class="font-medium">{{ vendor.display_name || vendor.name }}</span>
<span v-if="vendor.tax_number" class="ml-2 text-xs text-slate-400">({{ vendor.tax_number }})</span>
</button>
</div>
</div>
<!-- Selected vendor badge -->
<div
v-if="selectedVendor"
class="mt-2 inline-flex items-center gap-2 rounded-full bg-sf-accent/10 border border-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent"
>
<span>{{ selectedVendor.display_name || selectedVendor.name }}</span>
<button
class="ml-1 text-sf-accent/60 hover:text-sf-accent"
@click="clearVendor"
>
</button>
</div>
<p v-else-if="vendorSearch && !selectedVendor" class="mt-1 text-xs text-slate-400">
{{ t('costWizard.vendorPlaceholder') }}
</p>
</div>
<!-- Invoice Number -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.invoiceNumber') }}</label>
<input
v-model="form.invoiceNumber"
type="text"
placeholder="pl. 2026-00123"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Invoice Date -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.invoiceDate') }}</label>
<input
v-model="form.invoiceDate"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Payment Deadline -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.paymentDeadline') }}</label>
<input
v-model="form.paymentDeadline"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
</div>
<!--
STEP 4: Fájlok (File attachment)
-->
<div v-if="currentStep === 3" class="space-y-5">
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.attachFiles') }}</label>
<!-- Drop zone -->
<div
class="relative flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-50 px-6 py-10 text-center transition-colors hover:border-sf-accent/50 hover:bg-sf-accent/5 cursor-pointer"
@click="triggerFileInput"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="onFileDrop"
:class="{ 'border-sf-accent bg-sf-accent/5': isDragOver }"
>
<svg class="w-10 h-10 text-slate-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-sm text-slate-500">{{ t('costWizard.dragDrop') }}</p>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="onFileSelect"
/>
</div>
<!-- File list -->
<div v-if="attachedFiles.length > 0" class="mt-3 space-y-2">
<div
v-for="(file, idx) in attachedFiles"
:key="idx"
class="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-100 text-base">📄</span>
<div class="flex-1 min-w-0">
<p class="text-slate-700 truncate font-medium">{{ file.name }}</p>
<p class="text-xs text-slate-400">{{ formatFileSize(file.size) }}</p>
</div>
<button
class="flex h-6 w-6 items-center justify-center rounded-full text-slate-400 hover:text-red-500 hover:bg-red-50 transition-colors"
@click="removeFile(idx)"
>
</button>
</div>
</div>
<p v-else class="text-xs text-slate-400 mt-1">{{ t('costWizard.noFiles') }}</p>
</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 mt-4"
>
{{ errorMessage }}
</div>
</div>
<!-- Footer Navigation -->
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4 bg-slate-50 shrink-0">
<button
v-if="currentStep > 0"
type="button"
class="rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-600 transition-all hover:bg-slate-100 hover:shadow-sm"
@click="prevStep"
>
{{ t('common.prev') }}
</button>
<div v-else />
<button
v-if="currentStep < steps.length - 1"
type="button"
class="rounded-xl bg-sf-accent px-6 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
@click="nextStep"
>
{{ t('common.next') }}
</button>
<button
v-else
type="button"
:disabled="isSubmitting"
class="rounded-xl bg-emerald-600 px-6 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-emerald-700 hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center gap-2"
@click="handleSubmit"
>
<svg
v-if="isSubmitting"
class="h-4 w-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{{ isSubmitting ? t('common.saving') : t('common.save') }}</span>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCostStore } from '../../stores/cost'
import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle'
import { getLocalDateString } from '../../services/dateUtils'
const { t } = useI18n()
const costStore = useCostStore()
// ── Props & Emits ──
const props = defineProps<{
isOpen: boolean
vehicle: Vehicle | null
editCost?: any | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
// ── Stepper State ──
const steps = [
{ labelKey: 'costWizard.step1', descKey: 'costWizard.step1Desc' },
{ labelKey: 'costWizard.step2', descKey: 'costWizard.step2Desc' },
{ labelKey: 'costWizard.step3', descKey: 'costWizard.step3Desc' },
{ labelKey: 'costWizard.step4', descKey: 'costWizard.step4Desc' },
]
const currentStep = ref(0)
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
// ── Form State ──
const form = reactive({
date: getLocalDateString(),
mainCategory: '',
subCategory: '',
netAmount: 0,
vatRate: 20,
grossAmount: 0,
currency: 'HUF',
invoiceNumber: '',
invoiceDate: '',
paymentDeadline: '',
})
// ── Vendor State ──
interface VendorOption {
organization_id: number
name: string
display_name: string | null
tax_number: string | null
}
const vendorSearch = ref('')
const vendors = ref<VendorOption[]>([])
const filteredVendors = ref<VendorOption[]>([])
const selectedVendor = ref<VendorOption | null>(null)
const showVendorDropdown = ref(false)
// ── File State ──
const attachedFiles = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const isDragOver = ref(false)
// ── Category State ──
interface CategoryOption {
id: number
name: string
code?: string
parent_id: number | null
}
const mainCategories = ref<CategoryOption[]>([])
const subCategories = ref<CategoryOption[]>([])
// ── Computed ──
const vehicleLabel = computed(() => {
if (!props.vehicle) return t('dashboard.noVehicleSelected')
const plate = props.vehicle.license_plate || ''
const brand = props.vehicle.brand || ''
const model = props.vehicle.model || ''
return `${plate} (${brand} ${model})`.trim()
})
// ── Stepper Methods ──
function stepClass(idx: number): string {
if (idx < currentStep.value) return 'bg-sf-accent text-white'
if (idx === currentStep.value) return 'bg-sf-accent text-white ring-2 ring-sf-accent/30'
return 'bg-slate-200 text-slate-500'
}
function goToStep(idx: number) {
// Only allow going back to completed steps
if (idx <= currentStep.value) {
currentStep.value = idx
}
}
function validateStep(step: number): boolean {
errorMessage.value = null
if (step === 0) {
if (!form.mainCategory) {
errorMessage.value = 'Kérlek válassz kategóriát.'
return false
}
if (!form.date) {
errorMessage.value = 'Kérlek add meg a dátumot.'
return false
}
}
if (step === 1) {
if (!form.netAmount || form.netAmount <= 0) {
errorMessage.value = 'Kérlek add meg a nettó összeget.'
return false
}
}
return true
}
function nextStep() {
if (!validateStep(currentStep.value)) return
if (currentStep.value < steps.length - 1) {
currentStep.value++
}
}
function prevStep() {
if (currentStep.value > 0) {
currentStep.value--
}
}
// ── Category Methods ──
async function fetchCategories() {
try {
const res = await api.get('/dictionaries/cost-categories')
const allCategories = res.data as CategoryOption[]
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
window.__allCostCategories = allCategories
} catch (err) {
console.error('[CostEntryWizard] Failed to load categories:', err)
mainCategories.value = [
{ id: 1, name: 'Üzemanyag / Töltés', parent_id: null },
{ id: 2, name: 'Szerviz & Karbantartás', parent_id: null },
{ id: 3, name: 'Gumiabroncsok', parent_id: null },
{ id: 4, name: 'Biztosítás', parent_id: null },
{ id: 5, name: 'Adók', parent_id: null },
{ id: 6, name: 'Útdíj & Parkolás', parent_id: null },
{ id: 9, name: 'Ápolás & Kozmetika', parent_id: null },
{ id: 10, name: 'Egyéb', parent_id: null },
]
}
}
function onMainCategoryChange() {
form.subCategory = ''
subCategories.value = []
if (!form.mainCategory) return
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
subCategories.value = allCats.filter(
(c) => c.parent_id === Number(form.mainCategory)
)
}
// ── Financial Calculation Methods ──
function onNetAmountChange() {
if (form.netAmount > 0 && form.vatRate > 0) {
form.grossAmount = Math.round(form.netAmount * (1 + form.vatRate / 100) * 100) / 100
}
}
function onVatRateChange() {
if (form.netAmount > 0) {
form.grossAmount = Math.round(form.netAmount * (1 + form.vatRate / 100) * 100) / 100
}
}
function onGrossAmountChange() {
// If user edits gross, recalculate net (reverse VAT)
if (form.grossAmount > 0 && form.vatRate > 0) {
form.netAmount = Math.round(form.grossAmount / (1 + form.vatRate / 100) * 100) / 100
}
}
// ── Vendor Methods ──
async function fetchVendors() {
try {
const res = await api.get('/organizations', {
params: { org_type: 'SERVICE_PROVIDER' },
})
const data = res.data
// Handle both paginated and direct array responses
vendors.value = data.data || data.items || data.results || data || []
} catch (err) {
console.error('[CostEntryWizard] Failed to load vendors:', err)
vendors.value = []
}
}
function onVendorSearchInput() {
showVendorDropdown.value = true
const query = vendorSearch.value.toLowerCase().trim()
if (!query) {
filteredVendors.value = vendors.value
return
}
filteredVendors.value = vendors.value.filter(
(v) =>
(v.display_name || v.name).toLowerCase().includes(query) ||
(v.tax_number && v.tax_number.includes(query))
)
}
function selectVendor(vendor: VendorOption) {
selectedVendor.value = vendor
vendorSearch.value = vendor.display_name || vendor.name
showVendorDropdown.value = false
}
function clearVendor() {
selectedVendor.value = null
vendorSearch.value = ''
filteredVendors.value = vendors.value
}
function onVendorBlur() {
// Delay to allow click on dropdown item
setTimeout(() => {
showVendorDropdown.value = false
}, 200)
}
// ── File Methods ──
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) {
for (const file of Array.from(input.files)) {
attachedFiles.value.push(file)
}
}
// Reset input so same file can be re-selected
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function onFileDrop(event: DragEvent) {
isDragOver.value = false
if (event.dataTransfer?.files) {
for (const file of Array.from(event.dataTransfer.files)) {
attachedFiles.value.push(file)
}
}
}
function removeFile(idx: number) {
attachedFiles.value.splice(idx, 1)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// ── Submit ──
async function handleSubmit() {
if (!props.vehicle) {
errorMessage.value = 'Nincs kiválasztva jármű.'
return
}
if (!validateStep(currentStep.value)) return
isSubmitting.value = true
errorMessage.value = null
try {
// Determine category_id
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
let categoryId = Number(form.subCategory || form.mainCategory) || 10
// Build payload matching AssetCostBase schema
const payload: Record<string, any> = {
asset_id: props.vehicle.id,
category_id: categoryId,
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
amount_gross: form.grossAmount > 0 ? form.grossAmount : form.netAmount,
currency: form.currency,
date: new Date(form.date).toISOString(),
mileage_at_cost: null,
description: `Számla: ${form.invoiceNumber || 'N/A'}`,
data: {
main_category_id: form.mainCategory || null,
sub_category_id: form.subCategory || null,
net_amount: form.netAmount,
vat_rate: form.vatRate,
invoice_number: form.invoiceNumber || null,
invoice_date: form.invoiceDate ? new Date(form.invoiceDate).toISOString() : null,
payment_deadline: form.paymentDeadline ? new Date(form.paymentDeadline).toISOString() : null,
vendor_id: selectedVendor.value?.organization_id || null,
vendor_name: selectedVendor.value
? (selectedVendor.value.display_name || selectedVendor.value.name)
: (vendorSearch.value || null),
file_count: attachedFiles.value.length,
file_names: attachedFiles.value.map((f) => f.name),
},
}
// Add organization_id if vendor selected
if (selectedVendor.value?.organization_id) {
payload.organization_id = selectedVendor.value.organization_id
}
const result = await costStore.addExpense(payload)
if (result) {
resetForm()
emit('saved')
} else {
errorMessage.value = costStore.lastError || t('costWizard.error')
}
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
t('costWizard.error')
errorMessage.value = message
console.error('[CostEntryWizard] Submit error:', err)
} finally {
isSubmitting.value = false
}
}
function resetForm() {
form.date = getLocalDateString()
form.mainCategory = ''
form.subCategory = ''
form.netAmount = 0
form.vatRate = 20
form.grossAmount = 0
form.currency = 'HUF'
form.invoiceNumber = ''
form.invoiceDate = ''
form.paymentDeadline = ''
vendorSearch.value = ''
selectedVendor.value = null
attachedFiles.value = []
currentStep.value = 0
errorMessage.value = null
}
// ── Edit Cost Hydration ──
watch(() => props.editCost, (cost) => {
if (!cost) return
// Populate top-level fields
if (cost.date) {
form.date = getLocalDateString(cost.date)
}
if (cost.category_id) {
form.mainCategory = String(cost.category_id)
}
if (cost.amount_gross) {
form.grossAmount = Number(cost.amount_gross)
}
if (cost.currency) {
form.currency = cost.currency
}
// Populate JSONB data fields
const data = cost.data || {}
if (data.main_category_id) {
form.mainCategory = String(data.main_category_id)
}
if (data.sub_category_id) {
form.subCategory = String(data.sub_category_id)
}
if (data.net_amount) {
form.netAmount = Number(data.net_amount)
}
if (data.vat_rate) {
form.vatRate = Number(data.vat_rate)
}
if (data.invoice_number) {
form.invoiceNumber = data.invoice_number
}
if (data.invoice_date) {
form.invoiceDate = getLocalDateString(data.invoice_date)
}
if (data.payment_deadline) {
form.paymentDeadline = getLocalDateString(data.payment_deadline)
}
if (data.vendor_id) {
// Try to find the vendor in the loaded list
const vendor = vendors.value.find(
(v) => v.organization_id === data.vendor_id
)
if (vendor) {
selectedVendor.value = vendor
vendorSearch.value = vendor.display_name || vendor.name
}
}
if (data.vendor_name && !selectedVendor.value) {
vendorSearch.value = data.vendor_name
}
// Reset step to beginning for editing
currentStep.value = 0
errorMessage.value = null
}, { immediate: true })
// ── Lifecycle ──
watch(() => props.isOpen, (open) => {
if (open) {
currentStep.value = 0
form.date = getLocalDateString()
errorMessage.value = null
fetchCategories()
fetchVendors()
}
})
onMounted(() => {
if (props.isOpen) {
fetchCategories()
fetchVendors()
}
})
</script>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
</style>

View File

@@ -19,6 +19,37 @@
<span class="text-white font-bold text-lg tracking-wide">📊 {{ t('finance.costManager') }}</span>
</div>
<!-- Control Bar: Filter + Add Button -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 shrink-0">
<!-- Left: Vehicle Filter Dropdown -->
<div class="flex items-center gap-3">
<label class="text-sm font-semibold text-slate-600 whitespace-nowrap">{{ t('finance.filterByVehicle') || 'Jármű szűrő' }}:</label>
<select
v-model="selectedVehicleFilter"
class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all min-w-[220px] appearance-none cursor-pointer"
@change="onVehicleFilterChange"
>
<option value="">{{ t('finance.allVehicles') || 'Teljes flotta (Összes jármű)' }}</option>
<option
v-for="v in vehicleStore.sortedVehicles"
:key="v.id"
:value="v.id"
>
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
</option>
</select>
</div>
<!-- Right: Add Cost Button -->
<button
@click="openCostWizard"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
>
<span class="text-base"></span>
{{ t('finance.addCost') || 'Új Költség' }}
</button>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-6 text-slate-800">
<!-- Description -->
@@ -45,7 +76,7 @@
@click="fetchCosts"
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
>
{{ t('finance.retry') }}
{{ t('common.retry') }}
</button>
</div>
@@ -88,22 +119,22 @@
<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)"
:class="costTypeBadge(cost.category_code || cost.cost_type)"
>
{{ cost.cost_type || '—' }}
{{ cost.category_name || 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) }}
{{ formatAmount(cost.amount_gross, 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 || '—' }}
{{ cost.vehicle_name || cost.license_plate || '—' }}
</div>
<!-- Description -->
@@ -146,6 +177,14 @@
</div>
</div>
</div>
<!-- Cost Entry Wizard -->
<CostEntryWizard
:is-open="showWizard"
:vehicle="selectedVehicleForWizard"
@close="closeCostWizard"
@saved="onCostSaved"
/>
</div>
</Teleport>
</template>
@@ -154,8 +193,12 @@
import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '../../api/axios'
import { useVehicleStore } from '../../stores/vehicle'
import type { Vehicle } from '../../stores/vehicle'
import CostEntryWizard from './CostEntryWizard.vue'
const { t } = useI18n()
const vehicleStore = useVehicleStore()
// ── Props ──
const props = defineProps<{
@@ -174,6 +217,11 @@ const currentPage = ref(1)
const totalPages = ref(1)
const pageSize = 20
// ── Vehicle Filter State ──
const selectedVehicleFilter = ref('')
const showWizard = ref(false)
const selectedVehicleForWizard = ref<Vehicle | null>(null)
// ── Helpers ──
function formatDate(dateStr: string | null): string {
@@ -186,9 +234,10 @@ function formatDate(dateStr: string | null): string {
})
}
function formatAmount(amount: number | null, currency?: string): string {
function formatAmount(amount: string | number | null, currency?: string): string {
if (amount == null) return '—'
const formatted = Number(amount).toLocaleString(undefined, {
const num = typeof amount === 'string' ? parseFloat(amount) : amount
const formatted = Number(num).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})
@@ -199,21 +248,27 @@ function costTypeBadge(type: string | null): string {
switch (type) {
case 'fuel':
case 'FUEL':
case 'Üzemanyag':
return 'bg-orange-100 text-orange-700'
case 'service':
case 'SERVICE':
case 'Szerviz':
return 'bg-blue-100 text-blue-700'
case 'insurance':
case 'INSURANCE':
case 'Biztosítás':
return 'bg-purple-100 text-purple-700'
case 'tax':
case 'TAX':
case 'Adó':
return 'bg-red-100 text-red-700'
case 'parking':
case 'PARKING':
case 'Parkolás':
return 'bg-amber-100 text-amber-700'
case 'toll':
case 'TOLL':
case 'Útdíj':
return 'bg-yellow-100 text-yellow-700'
default:
return 'bg-slate-100 text-slate-600'
@@ -227,15 +282,19 @@ async function fetchCosts() {
error.value = null
try {
const res = await api.get('/expenses', {
params: {
page: currentPage.value,
page_size: pageSize,
},
})
const params: Record<string, any> = {
page: currentPage.value,
page_size: pageSize,
}
// Add asset_id filter if a specific vehicle is selected
if (selectedVehicleFilter.value) {
params.asset_id = selectedVehicleFilter.value
}
const res = await api.get('/expenses', { params })
const data = res.data
costs.value = data.data || data.items || data.results || []
totalPages.value = data.pagination?.total_pages || data.total_pages || 1
totalPages.value = data.total_pages || data.pagination?.total_pages || 1
} catch (err: any) {
const message =
err.response?.data?.detail ||
@@ -248,6 +307,11 @@ async function fetchCosts() {
}
}
function onVehicleFilterChange() {
currentPage.value = 1
fetchCosts()
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
@@ -262,16 +326,49 @@ function nextPage() {
}
}
// ── Cost Entry Wizard ──
function openCostWizard() {
// If a vehicle is selected in the filter, pass it to the wizard
if (selectedVehicleFilter.value) {
selectedVehicleForWizard.value =
vehicleStore.vehicles.find(v => v.id === selectedVehicleFilter.value) || null
} else {
selectedVehicleForWizard.value = null
}
showWizard.value = true
}
function closeCostWizard() {
showWizard.value = false
selectedVehicleForWizard.value = null
}
function onCostSaved() {
// Refresh the table data after a cost is saved
closeCostWizard()
currentPage.value = 1
fetchCosts()
}
// ── Lifecycle ──
watch(() => props.isOpen, (open) => {
if (open) {
currentPage.value = 1
selectedVehicleFilter.value = ''
// Ensure vehicles are loaded for the filter dropdown
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
fetchCosts()
}
})
onMounted(() => {
if (props.isOpen) {
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
fetchCosts()
}
})

View File

@@ -97,6 +97,10 @@ const props = withDefaults(defineProps<{
zone: 'dashboard_widget',
})
const emit = defineEmits<{
(e: 'ads-loaded', hasAds: boolean): void
}>()
const isLoading = ref(true)
const error = ref<string | null>(null)
const ads = ref<AdData[]>([])
@@ -108,6 +112,7 @@ async function fetchAds() {
try {
const res = await api.get<PlacementResponse>(`/marketing/placements/${props.zone}`)
ads.value = res.data.ads || []
emit('ads-loaded', ads.value.length > 0)
} catch (err: any) {
// Silently handle 404 (placement not configured yet) — no console noise
if (err.response?.status === 404) {
@@ -117,6 +122,7 @@ async function fetchAds() {
error.value = 'Failed to load ads'
console.error('[AdPlacementWidget] Error:', err)
}
emit('ads-loaded', false)
} finally {
isLoading.value = false
}

View File

@@ -2,9 +2,15 @@
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2">
<!-- Top header bar clickable to navigate to full Costs page -->
<div
class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2 cursor-pointer transition-colors hover:bg-slate-600"
@click="navigateToCostsPage"
>
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span>
<svg class="ml-auto h-4 w-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
@@ -57,16 +63,27 @@
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
<!-- Additional Costs (Medium) -->
<!-- Additional Costs (Medium) navigates to full Costs page -->
<button
v-if="selectedActionVehicle"
@click="openComplexModal"
@click="navigateToCostsPage"
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base"></span>
<span>{{ t('dashboard.additionalCosts') }}</span>
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
<!-- 🧾 Detailed Invoice (Medium) -->
<button
v-if="selectedActionVehicle"
@click="openWizardModal"
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🧾</span>
<span>{{ t('dashboard.detailedInvoice') }}</span>
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
</div>
</div>
@@ -93,19 +110,33 @@
@close="isComplexModalOpen = false"
@saved="onModalSaved"
/>
<CostEntryWizard
:is-open="isWizardModalOpen"
:vehicle="selectedActionVehicle"
@close="isWizardModalOpen = false"
@saved="onModalSaved"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useVehicleStore } from '../../stores/vehicle'
import SimpleFuelModal from './SimpleFuelModal.vue'
import ComplexExpenseModal from './ComplexExpenseModal.vue'
import CostEntryWizard from '../cost/CostEntryWizard.vue'
const { t } = useI18n()
const router = useRouter()
const vehicleStore = useVehicleStore()
/** Navigate to the full Costs page */
function navigateToCostsPage() {
router.push('/dashboard/costs')
}
// ── Emits ──
const emit = defineEmits<{
/**
@@ -120,6 +151,7 @@ const selectedActionVehicleId = ref<string>('')
const isFuelModalOpen = ref(false)
const isFeeModalOpen = ref(false)
const isComplexModalOpen = ref(false)
const isWizardModalOpen = ref(false)
/** The currently selected vehicle object for the action modals */
const selectedActionVehicle = computed(() => {
@@ -143,12 +175,17 @@ function openFeeModal() {
if (!selectedActionVehicle.value) return
isFeeModalOpen.value = true
}
function openComplexModal() {
if (!selectedActionVehicle.value) return
isComplexModalOpen.value = true
}
function openWizardModal() {
if (!selectedActionVehicle.value) return
isWizardModalOpen.value = true
}
/**
* F5 Bug fix (RBAC Phase 3): Modal mentés után bezárja a modalt,
* és értesíti a szülőt, hogy az új költség megjelent.
@@ -157,6 +194,7 @@ function onModalSaved() {
isFuelModalOpen.value = false
isFeeModalOpen.value = false
isComplexModalOpen.value = false
isWizardModalOpen.value = false
// Értesítjük a szülőt, hogy frissítse a VehicleDetailModal költségeit
if (selectedActionVehicleId.value) {

View File

@@ -37,7 +37,7 @@
@click="retry"
class="mt-2 text-xs text-sf-accent hover:underline"
>
{{ t('finance.retry') }}
{{ t('common.retry') }}
</button>
</div>

View File

@@ -5,7 +5,7 @@
>
<!-- HEADER: Profile title -->
<template #header>
<span class="text-white font-bold text-sm tracking-wide">🛡 {{ t('header.profile') }}</span>
<span class="text-white font-bold text-sm tracking-wide">🛡 {{ t('common.profileSettings') }}</span>
</template>
<!-- BODY: User info + Trust Score + Stats -->
@@ -41,7 +41,7 @@
<!-- FOOTER: CTA button -->
<template #footer>
<button class="w-full py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('header.profile') }}
{{ t('common.profileSettings') }}
</button>
</template>
</BaseCard>

View File

@@ -116,7 +116,7 @@
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">
{{ t('vehicle.label_vin') }} <span class="text-slate-400 text-xs">({{ t('common.optional') || 'opcionális' }})</span>
{{ t('vehicle.vin') }} <span class="text-slate-400 text-xs">({{ t('common.optional') || 'opcionális' }})</span>
</label>
<input
v-model="form.vin"
@@ -145,7 +145,7 @@
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">
{{ t('vehicle.label_vin') }} <span class="text-slate-400 text-xs">({{ t('common.optional') || 'opcionális' }})</span>
{{ t('vehicle.vin') }} <span class="text-slate-400 text-xs">({{ t('common.optional') || 'opcionális' }})</span>
</label>
<input
v-model="form.vin"
@@ -408,13 +408,40 @@
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_power_kw') || 'Teljesítmény (kW)' }}</label>
<label class="mb-1.5 block text-sm font-medium text-slate-700">
<span class="flex items-center gap-2">
{{ powerUnit === 'kw'
? (t('vehicle.label_power_kw') || 'Teljesítmény (kW)')
: (t('vehicle.label_power_le') || 'Teljesítmény (LE)')
}}
<!-- LE/KW Toggle -->
<button
type="button"
@click="togglePowerUnit"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-sf-accent/30"
:class="powerUnit === 'kw' ? 'bg-sf-accent' : 'bg-amber-500'"
>
<span
class="inline-flex h-3.5 w-3.5 items-center rounded-full bg-white text-[7px] font-bold shadow-sm transition-all duration-200"
:class="powerUnit === 'kw' ? 'translate-x-0.5 justify-center' : 'translate-x-4.5 justify-end pr-0.5'"
>
{{ powerUnit === 'kw' ? 'kW' : 'LE' }}
</span>
</button>
<span class="text-[10px] font-semibold text-slate-400 uppercase tracking-wider">{{ t('vehicleDetail.powerUnitLabel') }}</span>
</span>
</label>
<input
v-model="form.power_kw"
:value="powerInputValue"
@input="onPowerInput"
type="number"
min="0"
step="0.1"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
:placeholder="t('vehicle.placeholder_power_kw') || '150'"
:placeholder="powerUnit === 'kw'
? (t('vehicle.placeholder_power_kw') || '150')
: (t('vehicle.placeholder_power_le') || '204')
"
/>
<p v-if="hpDisplay" class="text-xs text-slate-500 mt-0.5">{{ hpDisplay }}</p>
</div>
@@ -912,47 +939,6 @@
</div>
</div>
</div>
<!-- Dates Section -->
<div class="rounded-xl border border-slate-200 bg-slate-50/50 p-4 space-y-4">
<h4 class="text-sm font-bold text-slate-700 uppercase tracking-wider">&#128197; {{ t('vehicle.label_dates_section') || 'Dátumok' }}</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_first_registration') || 'Első forgalomba helyezés' }}</label>
<input
v-model="form.first_registration_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_next_mot') || 'Következő műszaki érvényesség' }}</label>
<input
v-model="form.next_mot_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_insurance_expiry') || 'Biztosítás lejárta' }}</label>
<input
v-model="form.insurance_expiry_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_purchase_date') || 'Vásárlás dátuma' }}</label>
<input
v-model="form.purchase_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
</div>
</div>
<!-- -->
@@ -981,6 +967,41 @@
</div>
</div>
<!-- -->
<!-- DÁTUMOK SECTION (áthelyezve a Műszaki adatok alól) -->
<!-- -->
<div class="rounded-xl border border-slate-200 bg-slate-50/50 p-4 space-y-4">
<h4 class="text-sm font-bold text-slate-700 uppercase tracking-wider">&#128197; {{ t('vehicle.label_dates_section') || 'Dátumok' }}</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_first_registration') || 'Első forgalomba helyezés' }}</label>
<input
v-model="form.first_registration_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_insurance_expiry') || 'Biztosítás lejárta' }}</label>
<input
v-model="form.insurance_expiry_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_purchase_date') || 'Vásárlás dátuma' }}</label>
<input
v-model="form.purchase_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
</div>
<!-- -->
<!-- WARRANTY SECTION -->
<!-- -->
@@ -1005,6 +1026,111 @@
</div>
</div>
<!-- -->
<!-- REGISTRATION DOCUMENTS SECTION -->
<!-- -->
<div class="border-t border-slate-200 pt-4">
<h4 class="text-sm font-semibold text-slate-800 mb-3">{{ t('vehicle.registration_documents_title') || 'Forgalmi engedély és Törzskönyv' }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_registration_certificate_number') || 'Forgalmi engedély száma' }}</label>
<input
v-model="form.registration_certificate_number"
type="text"
maxlength="50"
:placeholder="t('vehicle.placeholder_registration_certificate_number') || 'Pl. AB-123456'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_registration_certificate_validity') || 'Forgalmi engedély érvényessége (megfelel a műszaki érvényességnek)' }}</label>
<input
v-model="form.registration_certificate_validity"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_vehicle_registration_document_number') || 'Törzskönyv száma' }}</label>
<input
v-model="form.vehicle_registration_document_number"
type="text"
maxlength="50"
:placeholder="t('vehicle.placeholder_vehicle_registration_document_number') || 'Pl. 1234567890'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
</div>
<!-- -->
<!-- ADMIN / EXTENDED ATTRIBUTES SECTION -->
<!-- -->
<div class="border-t border-slate-200 pt-4">
<h4 class="text-sm font-semibold text-slate-800 mb-3">{{ t('vehicle.admin_section_title') || 'Adminisztratív adatok' }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_registration_country') || 'Regisztráció országa' }}</label>
<input
v-model="form.registration_country"
type="text"
maxlength="100"
:placeholder="t('vehicle.placeholder_registration_country') || 'Pl. Magyarország'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_first_domestic_registration_date') || 'Első belföldi forgalomba helyezés' }}</label>
<input
v-model="form.first_domestic_registration_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_import_country') || 'Import ország' }}</label>
<input
v-model="form.import_country"
type="text"
maxlength="100"
:placeholder="t('vehicle.placeholder_import_country') || 'Pl. Németország'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_title_document_number') || 'Törzskönyv / Tulajdoni lap száma' }}</label>
<input
v-model="form.title_document_number"
type="text"
maxlength="50"
:placeholder="t('vehicle.placeholder_title_document_number') || 'Pl. TL-123456'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_engine_number') || 'Motorszám' }}</label>
<input
v-model="form.engine_number"
type="text"
maxlength="50"
:placeholder="t('vehicle.placeholder_engine_number') || 'Pl. M123456789'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_number_of_previous_owners') || 'Előző tulajdonosok száma' }}</label>
<input
v-model.number="form.number_of_previous_owners"
type="number"
min="0"
max="99"
:placeholder="t('vehicle.placeholder_number_of_previous_owners') || 'Pl. 2'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
</div>
<!-- Notes -->
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_notes') || 'Megjegyzések' }}</label>
@@ -1770,13 +1896,15 @@ const form = reactive({
ac_connector_type: '',
dc_connector_type: '',
first_registration_date: '',
next_mot_date: '',
insurance_expiry_date: '',
purchase_date: '',
condition: '',
color: '',
is_under_warranty: false,
warranty_expiry_date: '',
registration_certificate_number: '',
registration_certificate_validity: '',
vehicle_registration_document_number: '',
notes: '',
features: [] as string[],
// Motorcycle-specific fields (stored in individual_equipment.motorcycle_specs)
@@ -1803,6 +1931,13 @@ const form = reactive({
ground_clearance_mm: null as number | null,
pto_type: '',
has_differential_lock: false,
// ── New Admin Fields ──
registration_country: '',
first_domestic_registration_date: '',
import_country: '',
title_document_number: '',
engine_number: '',
number_of_previous_owners: null as number | null,
})
// ── Archive Form ──
@@ -1811,6 +1946,36 @@ const archiveForm = reactive({
reason: '',
})
// ── Power Unit Toggle (LE / KW) ──
const powerUnit = ref<'kw' | 'le'>('kw')
/** The display value shown in the power input, converted to the selected unit */
const powerInputValue = computed(() => {
if (form.power_kw === null || form.power_kw === undefined) return ''
if (powerUnit.value === 'kw') return form.power_kw
// Convert kW to LE (1 kW = 1.34102 HP/LE)
return Math.round(form.power_kw * 1.34102 * 10) / 10
})
function onPowerInput(e: Event) {
const target = e.target as HTMLInputElement
const raw = parseFloat(target.value)
if (isNaN(raw) || raw <= 0) {
form.power_kw = null
return
}
if (powerUnit.value === 'kw') {
form.power_kw = raw
} else {
// Convert LE back to kW for storage
form.power_kw = Math.round((raw / 1.34102) * 100) / 100
}
}
function togglePowerUnit() {
powerUnit.value = powerUnit.value === 'kw' ? 'le' : 'kw'
}
// ── Computed ──
const isEditMode = computed(() => !!props.vehicle)
@@ -1989,13 +2154,15 @@ function resetForm() {
form.ac_connector_type = ''
form.dc_connector_type = ''
form.first_registration_date = ''
form.next_mot_date = ''
form.insurance_expiry_date = ''
form.purchase_date = ''
form.condition = ''
form.color = ''
form.is_under_warranty = false
form.warranty_expiry_date = ''
form.registration_certificate_number = ''
form.registration_certificate_validity = ''
form.vehicle_registration_document_number = ''
form.notes = ''
form.features = []
form.strokes = ''
@@ -2019,6 +2186,12 @@ function resetForm() {
form.ground_clearance_mm = null
form.pto_type = ''
form.has_differential_lock = false
form.registration_country = ''
form.first_domestic_registration_date = ''
form.import_country = ''
form.title_document_number = ''
form.engine_number = ''
form.number_of_previous_owners = null
activeTab.value = 0
showArchiveConfirm.value = false
archiveForm.final_mileage = 0
@@ -2055,13 +2228,15 @@ function populateForm(vehicle: Vehicle) {
form.ac_connector_type = vehicle.ac_connector_type || ''
form.dc_connector_type = vehicle.dc_connector_type || ''
form.first_registration_date = vehicle.first_registration_date || ''
form.next_mot_date = vehicle.next_mot_date || ''
form.insurance_expiry_date = vehicle.insurance_expiry_date || ''
form.purchase_date = vehicle.purchase_date || ''
form.condition = vehicle.condition || ''
form.color = vehicle.color || ''
form.is_under_warranty = vehicle.is_under_warranty ?? false
form.warranty_expiry_date = vehicle.warranty_expiry_date || ''
form.registration_certificate_number = vehicle.registration_certificate_number || ''
form.registration_certificate_validity = vehicle.registration_certificate_validity || ''
form.vehicle_registration_document_number = vehicle.vehicle_registration_document_number || ''
form.notes = vehicle.notes || ''
// Extract type_designation from individual_equipment JSONB
@@ -2111,6 +2286,14 @@ function populateForm(vehicle: Vehicle) {
form.pto_type = hgvSpecs.pto_type || ''
form.has_differential_lock = hgvSpecs.has_differential_lock || false
}
// ── New Admin Fields ──
form.registration_country = vehicle.registration_country || ''
form.first_domestic_registration_date = vehicle.first_domestic_registration_date || ''
form.import_country = vehicle.import_country || ''
form.title_document_number = vehicle.title_document_number || ''
form.engine_number = vehicle.engine_number || ''
form.number_of_previous_owners = vehicle.number_of_previous_owners ?? null
}
// ── Handle Save ──
@@ -2149,13 +2332,15 @@ async function handleSave() {
ac_connector_type: form.ac_connector_type || null,
dc_connector_type: form.dc_connector_type || null,
first_registration_date: form.first_registration_date || null,
next_mot_date: form.next_mot_date || null,
insurance_expiry_date: form.insurance_expiry_date || null,
purchase_date: form.purchase_date || null,
condition: form.condition || null,
color: form.color || null,
is_under_warranty: form.is_under_warranty,
warranty_expiry_date: form.warranty_expiry_date || null,
registration_certificate_number: form.registration_certificate_number || null,
registration_certificate_validity: form.registration_certificate_validity || null,
vehicle_registration_document_number: form.vehicle_registration_document_number || null,
notes: form.notes || null,
// Store type_designation and features in individual_equipment JSONB
individual_equipment: {
@@ -2196,6 +2381,13 @@ async function handleSave() {
has_differential_lock: form.has_differential_lock || false,
} : undefined,
},
// ── New Admin Fields ──
registration_country: form.registration_country || null,
first_domestic_registration_date: form.first_domestic_registration_date || null,
import_country: form.import_country || null,
title_document_number: form.title_document_number || null,
engine_number: form.engine_number || null,
number_of_previous_owners: form.number_of_previous_owners ?? null,
}
if (props.vehicle) {

View File

@@ -42,7 +42,7 @@
@click="retry"
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
>
{{ t('finance.retry') }}
{{ t('common.retry') }}
</button>
</div>

View File

@@ -135,7 +135,7 @@ onMounted(async () => {
await authStore.fetchMyOrganizations()
}
console.log('🔍 [HeaderCompanySwitcher] All orgs:', authStore.myOrganizations)
console.log('🔍 [HeaderCompanySwitcher] Company orgs (filtered):', companyOrganizations.value)
// console.log('🔍 [HeaderCompanySwitcher] Company orgs (filtered):', companyOrganizations.value)
})
// ── Filter: only real companies (exclude individual/private orgs) ──

View File

@@ -48,7 +48,28 @@
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{{ t('header.profile') }}
{{ t('common.profileSettings') }}
</button>
<!-- Subscription Info -->
<button
@click="openSubscriptionModal"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg
class="h-4 w-4 text-white/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
{{ t('header.subscription') }}
</button>
<!-- Admin Center only for admin/superadmin roles -->
@@ -101,6 +122,12 @@
</div>
</div>
</Transition>
<!-- Subscription Info Modal -->
<SubscriptionInfoModal
:visible="showSubscriptionModal"
@close="showSubscriptionModal = false"
/>
</div>
</template>
@@ -109,6 +136,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import SubscriptionInfoModal from '@/components/subscription/SubscriptionInfoModal.vue'
const router = useRouter()
const { t } = useI18n()
@@ -116,6 +144,7 @@ const authStore = useAuthStore()
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const showSubscriptionModal = ref(false)
// ── Computed display helpers ───────────────────────────────────────
const fullName = computed(() => {
@@ -163,6 +192,12 @@ function goToAdmin() {
router.push('/admin')
}
// ── Open Subscription Info Modal ───────────────────────────────────
function openSubscriptionModal() {
isOpen.value = false
showSubscriptionModal.value = true
}
// ── Logout handler ─────────────────────────────────────────────────
function handleLogout() {
isOpen.value = false

View File

@@ -142,7 +142,7 @@
@click="isEditing ? cancelEdit() : $emit('close')"
class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors"
>
{{ isEditing ? t('profile.cancel') : t('company.close') || t('header.close') }}
{{ isEditing ? t('common.cancel') : t('company.close') || t('header.close') }}
</button>
<!-- Edit button (read-only mode) -->
@@ -168,7 +168,7 @@
</svg>
{{ t('company.submitting') }}
</span>
<span v-else>{{ t('profile.save') }}</span>
<span v-else>{{ t('common.save') }}</span>
</button>
</div>
</div>

View File

@@ -109,7 +109,7 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
<span v-else>{{ t('profile.save') }}</span>
<span v-else>{{ t('common.save') }}</span>
</button>
</div>
<p v-if="org?.subscription_plan" class="text-xs text-white/40 mt-1">
@@ -169,7 +169,7 @@
@click="$emit('close')"
class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors"
>
{{ t('company.cancel') }}
{{ t('common.cancel') }}
</button>
<button
@click="handleSave"
@@ -183,7 +183,7 @@
</svg>
{{ t('company.submitting') }}
</span>
<span v-else>{{ t('profile.save') }}</span>
<span v-else>{{ t('common.save') }}</span>
</button>
</div>
</div>

View File

@@ -86,7 +86,7 @@
<!-- Street Name -->
<div class="mb-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.streetNameLabel') }}
{{ t('common.streetName') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.streetNameOptional') }})</span>
</label>
<input
@@ -188,7 +188,7 @@
<!-- Phone -->
<div class="mb-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.phoneLabel') }}
{{ t('common.phone') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.phoneOptional') }})</span>
</label>
<input
@@ -202,7 +202,7 @@
<!-- Email -->
<div class="mb-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.emailLabel') }}
{{ t('common.email') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.emailOptional') }})</span>
</label>
<input

View File

@@ -89,7 +89,7 @@
<!-- Street Name -->
<div class="mb-3">
<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>
{{ t('common.streetName') }} <span class="text-slate-400 text-xs">({{ t('provider.streetNameOptional') }})</span>
</label>
<input
v-model="form.address_street_name"
@@ -190,7 +190,7 @@
<!-- Phone -->
<div class="mb-3">
<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>
{{ t('common.phone') }} <span class="text-slate-400 text-xs">({{ t('provider.phoneOptional') }})</span>
</label>
<input
v-model="form.phone"
@@ -204,7 +204,7 @@
<!-- Email -->
<div class="mb-3">
<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>
{{ t('common.email') }} <span class="text-slate-400 text-xs">({{ t('provider.emailOptional') }})</span>
</label>
<input
v-model="form.email"

View File

@@ -0,0 +1,278 @@
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="$emit('close')"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<!-- Modal Panel Dark Glassmorphism -->
<div
class="relative w-full max-w-md rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- HEADER -->
<div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
<div class="flex items-center gap-3">
<!-- Crown icon -->
<svg class="w-6 h-6 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<h2 class="text-lg font-bold text-white">
{{ t('subscription.subscriptionInfo') }}
</h2>
</div>
<button
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white/70 hover:text-white"
@click="$emit('close')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- BODY -->
<div class="px-5 py-5 space-y-5">
<!-- Plan Name & Status Badge -->
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-white/50 uppercase tracking-wider">{{ t('subscription.currentPlan') }}</p>
<p class="text-xl font-bold text-white mt-0.5">
{{ planDisplayName }}
</p>
</div>
<span
:class="isExpired
? 'bg-red-500/20 text-red-400 border-red-500/30'
: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'"
class="px-3 py-1 rounded-full text-xs font-semibold border"
>
{{ isExpired ? t('subscription.expired') : t('subscription.active') }}
</span>
</div>
<!-- Expiry Date -->
<div class="rounded-xl bg-white/5 border border-white/10 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span class="text-sm text-white/70">{{ t('subscription.expiresAt') }}</span>
</div>
<span class="text-sm font-semibold text-white">
{{ formattedExpiry }}
</span>
</div>
<!-- Days remaining bar -->
<div v-if="!isExpired && daysRemaining !== null" class="mt-3">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-white/50">{{ t('subscription.daysLeft', { count: daysRemaining }) }}</span>
<span :class="daysRemaining <= 7 ? 'text-red-400' : 'text-emerald-400'">
{{ daysRemaining }} {{ t('subscription.daysLeftShort') }}
</span>
</div>
<div class="w-full h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
:class="daysRemaining <= 7 ? 'bg-red-500' : 'bg-emerald-500'"
class="h-full rounded-full transition-all duration-500"
:style="{ width: daysProgressPercent + '%' }"
/>
</div>
</div>
</div>
<!-- Vehicle Limit Progress -->
<div class="rounded-xl bg-white/5 border border-white/10 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-white/70 flex items-center gap-2">
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
</svg>
{{ t('subscription.vehicleLimit') }}
</span>
<span class="text-sm font-semibold text-white">
{{ vehicleCount }} / {{ maxVehicles }}
</span>
</div>
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="vehiclePercent >= 90 ? 'bg-red-500' : vehiclePercent >= 70 ? 'bg-amber-500' : 'bg-blue-500'"
:style="{ width: vehiclePercent + '%' }"
/>
</div>
</div>
<!-- Garage Limit Progress -->
<div class="rounded-xl bg-white/5 border border-white/10 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-white/70 flex items-center gap-2">
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
{{ t('subscription.garageLimit') }}
</span>
<span class="text-sm font-semibold text-white">
{{ garageCount }} / {{ maxGarages }}
</span>
</div>
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="garagePercent >= 90 ? 'bg-red-500' : garagePercent >= 70 ? 'bg-amber-500' : 'bg-blue-500'"
:style="{ width: garagePercent + '%' }"
/>
</div>
</div>
</div>
<!-- FOOTER -->
<div class="px-5 py-4 border-t border-white/10 flex gap-3">
<button
@click="$emit('close')"
class="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-all duration-150"
>
{{ t('common.close') }}
</button>
<button
@click="goToPlans"
class="flex-1 px-4 py-2.5 rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 text-sm font-semibold text-white hover:from-blue-500 hover:to-indigo-500 transition-all duration-150 shadow-lg shadow-blue-600/20"
>
{{ t('subscription.managePlan') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useVehicleStore } from '@/stores/vehicle'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
// ── Computed: Plan display name ──
const planDisplayName = computed(() => {
const plan = authStore.user?.subscription_plan
if (!plan || plan === 'FREE') return t('subscription.free')
return plan
})
// ── Computed: Expiry date ──
const subscriptionExpiresAt = computed(() => {
return authStore.user?.subscription_expires_at ?? null
})
const formattedExpiry = computed(() => {
const raw = subscriptionExpiresAt.value
// P0: Show "Active" / "Indefinite" instead of em-dash when no expiry
if (!raw) return t('subscription.indefinite')
const d = new Date(raw)
if (isNaN(d.getTime())) return t('subscription.indefinite')
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}.${m}.${day}`
})
const isExpired = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return false
return new Date(raw) < new Date()
})
const daysRemaining = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return null
const now = new Date()
const expiry = new Date(raw)
const diff = expiry.getTime() - now.getTime()
if (diff <= 0) return 0
return Math.ceil(diff / (1000 * 60 * 60 * 24))
})
const daysProgressPercent = computed(() => {
// Assume a 365-day cycle for the progress bar
const days = daysRemaining.value
if (days === null) return 0
return Math.min(100, Math.max(0, (days / 365) * 100))
})
// ── Computed: Vehicle limit progress ──
// P0: Real vehicle count from the vehicle store
const vehicleCount = computed(() => {
return vehicleStore.vehicles.length
})
// P0: Real max vehicles from the backend subscription tier allowances
const maxVehicles = computed(() => {
return authStore.user?.max_vehicles ?? 1
})
const vehiclePercent = computed(() => {
if (maxVehicles.value === 0) return 0
return Math.min(100, Math.round((vehicleCount.value / maxVehicles.value) * 100))
})
// ── Computed: Garage limit progress ──
const garageCount = computed(() => {
// Use the actual organizations count from the auth store
return authStore.myOrganizations?.length ?? 0
})
// P0: Real max garages from the backend subscription tier allowances
const maxGarages = computed(() => {
return authStore.user?.max_garages ?? 1
})
const garagePercent = computed(() => {
if (maxGarages.value === 0) return 0
return Math.min(100, Math.round((garageCount.value / maxGarages.value) * 100))
})
// ── Navigation ──
// P0: Navigate to the correct subscription plans route
function goToPlans() {
emit('close')
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
router.push({
name: 'org-subscription',
params: { id: authStore.user.active_organization_id }
})
} else {
router.push({ name: 'subscription' })
}
}
</script>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>

View File

@@ -6,8 +6,8 @@
>
<!-- Image Area (Top 40%) -->
<div class="h-36 rounded-t-2xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center overflow-hidden relative">
<template v-if="vehicle.image">
<img :src="vehicle.image" :alt="vehicle.brand" class="h-full w-full object-cover" />
<template v-if="vehicle.image_url || vehicle.image">
<img :src="vehicle.image_url || vehicle.image" :alt="vehicle.brand" class="h-full w-full object-cover" />
</template>
<template v-else>
<!-- Car SVG silhouette placeholder -->
@@ -59,7 +59,7 @@
<VehiclePlateBadge
:license-plate="vehicle.license_plate"
:brand="vehicle.brand"
:country-code="vehicle.countryCode || vehicle.country_code"
:country-code="vehicle.registration_country || vehicle.countryCode || vehicle.country_code"
size="md"
/>
@@ -92,7 +92,7 @@
<svg class="h-3.5 w-3.5 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="M14 10h-2m2 4h-2m-4-4h-2m2 4h-2m6-8h2a2 2 0 012 2v4a2 2 0 01-2 2h-2m-4 0H8a2 2 0 01-2-2V8a2 2 0 012-2h4m0 0V4m0 4v4" />
</svg>
<span>{{ vehicle.engine || vehicle.fuel_type || '—' }}</span>
<span>{{ vehicle.power_kw || vehicle.engine_capacity ? (vehicle.power_kw ? vehicle.power_kw + ' kW' : '') + (vehicle.engine_capacity ? ' / ' + vehicle.engine_capacity + ' cm³' : '') : vehicle.engine || vehicle.fuel_type || '—' }}</span>
</div>
<!-- Color -->
@@ -100,7 +100,7 @@
<svg class="h-3.5 w-3.5 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="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
<span>{{ vehicle.individual_equipment?.color || vehicle.color || '—' }}</span>
<span>{{ vehicle.individual_equipment?.color || vehicle.color || t('common.na') }}</span>
</div>
<!-- Next Service (highlighted) -->
@@ -111,7 +111,7 @@
<svg class="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-semibold">{{ t('vehicle.nextService') }}: {{ vehicle.nextService || t('vehicle.noData') }}</span>
<span class="font-semibold">{{ t('common.nextService') }}: {{ vehicle.nextService || t('common.noData') }}</span>
</div>
</div>
</div>
@@ -130,6 +130,14 @@
</svg>
</button>
<!-- Primary Vehicle Badge Text (below star) -->
<div
v-if="vehicle.is_primary"
class="absolute top-11 left-3 z-20 rounded-full bg-amber-400/90 px-2 py-0.5 text-[10px] font-semibold text-white shadow-sm backdrop-blur-sm"
>
{{ t('vehicle.primaryVehicle') }}
</div>
<!-- Edit Button (top-right overlay) -->
<button
class="absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-slate-400 opacity-0 shadow-sm backdrop-blur-sm transition-all hover:bg-sf-accent hover:text-white group-hover:opacity-100 z-20"

View File

@@ -58,14 +58,14 @@
<div class="flex justify-center pt-6 pb-4">
<VehiclePlateBadge
:license-plate="vehicle?.license_plate || ''"
:country-code="vehicle?.countryCode || vehicle?.country_code"
:country-code="vehicle?.registration_country || vehicle?.countryCode || vehicle?.country_code"
size="lg"
/>
</div>
<!--
4-TAB NAVIGATION
-->
5-TAB NAVIGATION
-->
<div class="px-6">
<div class="flex border-b border-slate-200 gap-1">
<button
@@ -93,9 +93,28 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Engine -->
<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">Motor</p>
<div class="flex items-center justify-between mb-1">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Motor</p>
<!-- LE/KW Display Toggle -->
<div v-if="vehicle?.power_kw" class="flex items-center gap-1.5">
<span class="text-[9px] font-semibold text-slate-400 uppercase tracking-wider">{{ t('vehicleDetail.powerUnitLabel') }}</span>
<button
type="button"
@click="togglePowerUnit"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-sf-accent/30"
:class="powerUnit === 'kw' ? 'bg-sf-accent' : 'bg-amber-500'"
>
<span
class="inline-flex h-3.5 w-3.5 items-center rounded-full bg-white text-[7px] font-bold shadow-sm transition-all duration-200"
:class="powerUnit === 'kw' ? 'translate-x-0.5 justify-center' : 'translate-x-4.5 justify-end pr-0.5'"
>
{{ powerUnit === 'kw' ? 'kW' : 'LE' }}
</span>
</button>
</div>
</div>
<p class="text-sm font-bold text-slate-800">{{ engineDisplay || '—' }}</p>
<p v-if="vehicle?.power_kw" class="text-xs text-slate-500 mt-0.5">{{ vehicle.power_kw }} kW ({{ hpDisplay }})</p>
<p v-if="vehicle?.power_kw" class="text-xs text-slate-500 mt-0.5">{{ powerDetailDisplay }}</p>
</div>
<!-- Body / Karosszéria -->
@@ -198,7 +217,7 @@
: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) }}
{{ getFeatureTranslation(feat) }}
</span>
</div>
</div>
@@ -260,17 +279,134 @@
</div>
</div>
<!-- TAB 2: Pénzügyek / TCO -->
<!-- TAB 2: Műszaki adatok (Tech Data) -->
<div v-if="activeTab === 'techdata'" class="space-y-5">
<TechDataTab />
</div>
<!-- TAB 3: Pénzügyek / TCO -->
<div v-if="activeTab === 'finance'" class="space-y-5">
<!-- Loading State -->
<div
v-if="costsLoading"
v-if="financeLoading"
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="!costsLoading">
<template v-if="!financeLoading">
<!-- Financials (Leasing / Purchase) -->
<div v-if="assetFinancials" 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-3">{{ t('finance.purchasing_section_title') }}</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-if="assetFinancials.purchase_price_gross">
<p class="text-xs text-slate-500">{{ t('finance.purchase_price_gross') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatAmount(assetFinancials.purchase_price_gross) }}</p>
</div>
<div v-if="assetFinancials.down_payment">
<p class="text-xs text-slate-500">{{ t('finance.down_payment') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatAmount(assetFinancials.down_payment) }}</p>
</div>
<div v-if="assetFinancials.monthly_installment">
<p class="text-xs text-slate-500">{{ t('finance.monthly_installment') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatAmount(assetFinancials.monthly_installment) }}</p>
</div>
<div v-if="assetFinancials.contract_number">
<p class="text-xs text-slate-500">{{ t('finance.contract_number') }}</p>
<p class="text-sm font-bold text-slate-800 font-mono">{{ assetFinancials.contract_number }}</p>
</div>
<div v-if="assetFinancials.financing_provider">
<p class="text-xs text-slate-500">{{ t('finance.financing_provider') }}</p>
<p class="text-sm font-bold text-slate-800">{{ assetFinancials.financing_provider }}</p>
</div>
<div v-if="assetFinancials.contract_start_date">
<p class="text-xs text-slate-500">{{ t('finance.contract_start_date') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatDate(assetFinancials.contract_start_date) }}</p>
</div>
<div v-if="assetFinancials.contract_end_date">
<p class="text-xs text-slate-500">{{ t('finance.contract_end_date') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatDate(assetFinancials.contract_end_date) }}</p>
</div>
<div v-if="assetFinancials.residual_value">
<p class="text-xs text-slate-500">{{ t('finance.residual_value') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatAmount(assetFinancials.residual_value) }}</p>
</div>
<div v-if="assetFinancials.interest_rate">
<p class="text-xs text-slate-500">{{ t('finance.interest_rate') }}</p>
<p class="text-sm font-bold text-slate-800">{{ assetFinancials.interest_rate }}%</p>
</div>
</div>
</div>
<!-- Insurance Policies -->
<div v-if="insurancePolicies.length > 0" 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-3">{{ t('finance.insurance_section_title') }}</p>
<div class="space-y-3">
<div
v-for="policy in insurancePolicies"
:key="policy.id"
class="rounded-lg border border-slate-200 bg-white p-3"
>
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-bold text-slate-800">{{ policy.insurer_name || '—' }}</p>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 uppercase">{{ policy.policy_type || '—' }}</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-slate-500">
<div v-if="policy.policy_number">
<span class="font-semibold text-slate-600">{{ t('finance.policy_number') }}:</span> {{ policy.policy_number }}
</div>
<div v-if="policy.premium">
<span class="font-semibold text-slate-600">{{ t('finance.premium') }}:</span> {{ formatAmount(policy.premium) }}
</div>
<div v-if="policy.valid_from">
<span class="font-semibold text-slate-600">{{ t('finance.valid_from') }}:</span> {{ formatDate(policy.valid_from) }}
</div>
<div v-if="policy.valid_until">
<span class="font-semibold text-slate-600">{{ t('finance.valid_until') }}:</span> {{ formatDate(policy.valid_until) }}
</div>
</div>
</div>
</div>
</div>
<!-- Tax Obligations -->
<div v-if="taxObligations.length > 0" 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-3">{{ t('finance.tax_section_title') }}</p>
<div class="space-y-3">
<div
v-for="tax in taxObligations"
:key="tax.id"
class="rounded-lg border border-slate-200 bg-white p-3"
>
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-bold text-slate-800">{{ tax.tax_type || '—' }}</p>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="tax.paid ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'"
>
{{ tax.paid ? t('finance.paid') : t('finance.unpaid') }}
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-slate-500">
<div v-if="tax.amount">
<span class="font-semibold text-slate-600">{{ t('finance.amount') }}:</span> {{ formatAmount(tax.amount) }}
</div>
<div v-if="tax.due_date">
<span class="font-semibold text-slate-600">{{ t('finance.due_date') }}:</span> {{ formatDate(tax.due_date) }}
</div>
</div>
</div>
</div>
</div>
<!-- Empty state for financial data -->
<div
v-if="!assetFinancials && insurancePolicies.length === 0 && taxObligations.length === 0"
class="rounded-xl border border-slate-200 bg-slate-50 p-6 text-center"
>
<p class="text-sm text-slate-500">{{ t('finance.noFinancialData') }}</p>
</div>
<!-- Annual Total Cost Hero -->
<div class="rounded-2xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-6 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Éves Összköltség (TCO)</p>
@@ -416,7 +552,7 @@
</template>
</div>
<!-- TAB 3: Figyelmeztetések & Időpontok -->
<!-- TAB 4: Figyelmeztetések & Időpontok -->
<div v-if="activeTab === 'alerts'" class="space-y-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Idővonal Közelgő események</p>
@@ -509,7 +645,7 @@
</div>
</div>
<!-- TAB 4: Szervizkönyv (Service Book) -->
<!-- TAB 5: Szervizkönyv (Service Book) -->
<div v-if="activeTab === 'servicebook'" class="space-y-5">
<!-- Loading State -->
<div
@@ -636,7 +772,7 @@
@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') }}
{{ $t('common.cancel') }}
</button>
<button
@click="submitEvent"
@@ -647,7 +783,7 @@
<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') }}
{{ savingEvent ? $t('common.saving') : t('common.save') }}
</button>
</div>
</div>
@@ -754,7 +890,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import { getLocalDateString } from '../../services/dateUtils'
import type { VehicleData } from '../../types/vehicle'
@@ -827,16 +963,37 @@ const vehicleFeatures = computed(() => {
return Array.isArray(features) ? features : []
})
/**
* i18n feature fallback: tries to translate a feature key with category-specific
* fallback support. If neither `vehicle.features.{feat}` nor
* `vehicle.{category}Features.{feat}` exists, returns the raw key.
*/
function getFeatureTranslation(feat: string): string {
const { te, t } = useI18n()
if (te(`vehicle.features.${feat}`)) return t(`vehicle.features.${feat}`)
// Attempt category-specific fallback (e.g. vehicle.safetyFeatures.ABS)
const category = props.vehicle?.individual_equipment?.car_specs?.body_type || ''
if (category && te(`vehicle.${category}Features.${feat}`)) {
return t(`vehicle.${category}Features.${feat}`)
}
return feat
}
// ── Tab state ──
const activeTab = ref<'basics' | 'finance' | 'alerts' | 'servicebook'>('basics')
const activeTab = ref<'basics' | 'techdata' | 'finance' | 'alerts' | 'servicebook'>('basics')
const tabs = [
{ id: 'basics' as const, label: 'Alapadatok', icon: '📋' },
{ id: 'techdata' as const, label: t('vehicleDetail.tabTechData'), 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: '📖' },
]
// ── Provide vehicle to child components (TechDataTab) ──
const vehicleComputed = computed(() => props.vehicle)
provide('vehicle', vehicleComputed)
// ── Reset state every time modal opens ──
watch(() => props.isOpen, (newVal) => {
if (newVal) {
@@ -847,6 +1004,37 @@ watch(() => props.isOpen, (newVal) => {
}
})
// ── Financials: Asset Financials, Insurance, Tax from API ──
const assetFinancials = ref<AssetFinancials | null>(null)
const insurancePolicies = ref<VehicleInsurancePolicy[]>([])
const taxObligations = ref<VehicleTaxObligation[]>([])
const financeLoading = ref(false)
/**
* Fetch all financial data (financials + insurance + tax) in parallel
* when the finance tab is activated.
*/
async function loadFinanceData(vehicleId: string | number) {
financeLoading.value = true
try {
const [financialsRes, insuranceRes, taxRes] = await Promise.all([
api.get(`/assets/vehicles/${vehicleId}/financials`).catch(() => null),
api.get(`/assets/vehicles/${vehicleId}/insurance`).catch(() => null),
api.get(`/assets/vehicles/${vehicleId}/tax`).catch(() => null),
])
assetFinancials.value = financialsRes?.data || null
insurancePolicies.value = (insuranceRes?.data as VehicleInsurancePolicy[]) || []
taxObligations.value = (taxRes?.data as VehicleTaxObligation[]) || []
} catch (err: any) {
console.error('[VehicleDetailModal] Failed to fetch financial data:', err)
assetFinancials.value = null
insurancePolicies.value = []
taxObligations.value = []
} finally {
financeLoading.value = false
}
}
// ── Financials: Vehicle Costs from API ──
interface CostItem {
id: string | number
@@ -908,6 +1096,9 @@ function mapBackendCost(raw: any): CostItem {
*/
watch(() => activeTab.value, async (tab) => {
if (tab === 'finance' && props.vehicle?.id) {
// Load financials, insurance, tax in parallel
await loadFinanceData(props.vehicle.id)
// Then load costs
costsLoading.value = true
try {
const res = await api.get(`/assets/${props.vehicle.id}/costs`)
@@ -927,14 +1118,20 @@ watch(() => activeTab.value, async (tab) => {
*/
watch(() => props.vehicle, (newVehicle) => {
if (activeTab.value === 'finance' && newVehicle?.id) {
financeLoading.value = true
costsLoading.value = true
api.get(`/assets/${newVehicle.id}/costs`)
.then(res => {
const rawData = (res.data as any[]) || []
vehicleCosts.value = rawData.map(mapBackendCost)
})
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
.finally(() => { costsLoading.value = false })
Promise.all([
loadFinanceData(newVehicle.id),
api.get(`/assets/${newVehicle.id}/costs`)
.then(res => {
const rawData = (res.data as any[]) || []
vehicleCosts.value = rawData.map(mapBackendCost)
})
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
]).finally(() => {
financeLoading.value = false
costsLoading.value = false
})
}
})
@@ -944,14 +1141,20 @@ watch(() => props.vehicle, (newVehicle) => {
*/
watch(() => props.refreshTrigger, (newVal, oldVal) => {
if (newVal !== undefined && newVal !== oldVal && activeTab.value === 'finance' && props.vehicle?.id) {
financeLoading.value = true
costsLoading.value = true
api.get(`/assets/${props.vehicle.id}/costs`)
.then(res => {
const rawData = (res.data as any[]) || []
vehicleCosts.value = rawData.map(mapBackendCost)
})
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
.finally(() => { costsLoading.value = false })
Promise.all([
loadFinanceData(props.vehicle.id),
api.get(`/assets/${props.vehicle.id}/costs`)
.then(res => {
const rawData = (res.data as any[]) || []
vehicleCosts.value = rawData.map(mapBackendCost)
})
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
]).finally(() => {
financeLoading.value = false
costsLoading.value = false
})
}
})
@@ -986,88 +1189,6 @@ function getConnectorLabel(connector: string): string {
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
@@ -1127,6 +1248,22 @@ const engineDisplay = computed(() => {
return parts.length > 0 ? parts.join(' ') : '—'
})
// ── Power Unit Toggle (LE / KW) for display ──
const powerUnit = ref<'kw' | 'le'>('kw')
function togglePowerUnit() {
powerUnit.value = powerUnit.value === 'kw' ? 'le' : 'kw'
}
const powerDetailDisplay = computed(() => {
const kw = props.vehicle?.power_kw
if (!kw) return '—'
if (powerUnit.value === 'kw') {
return `${kw} kW`
}
return `${Math.round(kw * 1.35962)} LE`
})
const hpDisplay = computed(() => {
const kw = props.vehicle?.power_kw
if (!kw) return '—'

View File

@@ -1,16 +1,709 @@
<template>
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabFinancials') }}</h3>
<div class="flex flex-col items-center justify-center py-12 text-white/50">
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-base">{{ t('vehicleDetail.placeholderFinancials') }}</p>
<div class="space-y-6">
<!-- -->
<!-- SECTION 1: Purchasing & Leasing -->
<!-- -->
<div class="rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-white flex items-center gap-2">
<span>&#128184;</span> {{ t('finance.purchasing_section_title') || 'Beszerzés & Lízing' }}
</h3>
<button
@click="openFinancialsModal"
class="rounded-lg bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent hover:bg-sf-accent/30 transition-colors cursor-pointer"
>
{{ t('common.edit') || 'Szerkesztés' }}
</button>
</div>
<!-- Loading State -->
<div v-if="loadingFinancials" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-sf-accent" xmlns="http://www.w3.org/2000/svg" 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>
<!-- Empty State -->
<div v-else-if="!financials" class="flex flex-col items-center justify-center py-8 text-slate-400">
<svg class="h-10 w-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-sm">{{ t('finance.no_financials') || 'Még nincsenek pénzügyi adatok rögzítve.' }}</p>
<button
@click="openFinancialsModal"
class="mt-3 rounded-lg bg-sf-accent px-4 py-2 text-xs font-medium text-white hover:bg-sf-accent/90 transition-colors cursor-pointer"
>
{{ t('finance.add_financials') || 'Pénzügyi adatok hozzáadása' }}
</button>
</div>
<!-- Data Grid -->
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.purchase_price_gross') || 'Bruttó vételár' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.purchase_price_gross, financials.currency) }}
</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.purchase_price_net') || 'Nettó vételár' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.purchase_price_net, financials.currency) }}
</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.down_payment') || 'Önerő' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.down_payment, financials.currency) }}
</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.monthly_installment') || 'Havi törlesztő' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.monthly_installment, financials.currency) }}
</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.contract_number') || 'Szerződésszám' }}</p>
<p class="text-sm font-semibold text-white">{{ financials.contract_number || '—' }}</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.financing_provider') || 'Finanszírozó' }}</p>
<p class="text-sm font-semibold text-white">{{ financials.financing_provider || '—' }}</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.interest_rate') || 'Kamat' }}</p>
<p class="text-sm font-semibold text-white">{{ financials.interest_rate != null ? `${financials.interest_rate}%` : '—' }}</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.residual_value') || 'Maradványérték' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.residual_value, financials.currency) }}
</p>
</div>
</div>
<!-- Contract Dates -->
<div v-if="financials?.contract_start_date || financials?.contract_end_date" class="mt-3 flex flex-wrap gap-4 text-xs text-slate-400">
<span v-if="financials.contract_start_date">
{{ t('finance.contract_start_date') || 'Szerződés kezdete' }}: <span class="text-slate-300">{{ formatDate(financials.contract_start_date) }}</span>
</span>
<span v-if="financials.contract_end_date">
{{ t('finance.contract_end_date') || 'Szerződés vége' }}: <span class="text-slate-300">{{ formatDate(financials.contract_end_date) }}</span>
</span>
</div>
</div>
<!-- -->
<!-- SECTION 2: Insurances & Taxes -->
<!-- -->
<div class="rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-white flex items-center gap-2">
<span>&#128737;</span> {{ t('finance.insurance_section_title') || 'Biztosítások & Adók' }}
</h3>
<div class="flex gap-2">
<button
@click="openInsuranceModal"
class="rounded-lg bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent hover:bg-sf-accent/30 transition-colors cursor-pointer"
>
{{ t('finance.add_insurance') || '+ Biztosítás' }}
</button>
<button
@click="openTaxModal"
class="rounded-lg bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-500/30 transition-colors cursor-pointer"
>
{{ t('finance.add_tax') || '+ Adó' }}
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="loadingInsurance" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-sf-accent" xmlns="http://www.w3.org/2000/svg" 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>
<!-- Empty State -->
<div v-else-if="insurancePolicies.length === 0 && taxObligations.length === 0" class="flex flex-col items-center justify-center py-8 text-slate-400">
<svg class="h-10 w-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-sm">{{ t('finance.no_insurance_taxes') || 'Még nincsenek biztosítások vagy adók rögzítve.' }}</p>
</div>
<div v-else class="space-y-4">
<!-- Insurance Cards -->
<div v-for="policy in insurancePolicies" :key="policy.id" class="rounded-xl border border-slate-700/50 bg-slate-800/30 p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="rounded-full bg-blue-500/20 px-2.5 py-0.5 text-xs font-medium text-blue-400">
{{ policy.policy_type === 'kgfb' ? 'KGFB' : policy.policy_type === 'casco' ? 'CASCO' : policy.policy_type === 'both' ? 'KGFB + CASCO' : (policy.policy_type || t('finance.insurance') || 'Biztosítás') }}
</span>
<span class="text-sm font-semibold text-white">{{ policy.insurer_name || '—' }}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div>
<span class="text-slate-400">{{ t('finance.policy_number') || 'Kötvényszám' }}:</span>
<span class="ml-1 text-slate-200">{{ policy.policy_number || '—' }}</span>
</div>
<div>
<span class="text-slate-400">{{ t('finance.premium') || 'Díj' }}:</span>
<span class="ml-1 text-slate-200">{{ formatCurrency(policy.premium, policy.currency) }}</span>
</div>
<div>
<span class="text-slate-400">{{ t('finance.valid_from') || 'Érvényes' }}:</span>
<span class="ml-1 text-slate-200">{{ formatDate(policy.valid_from) }} {{ formatDate(policy.valid_until) }}</span>
</div>
</div>
</div>
<button
@click="deleteInsurance(policy)"
class="ml-2 rounded-lg p-1.5 text-slate-500 hover:bg-red-500/20 hover:text-red-400 transition-colors cursor-pointer"
:title="t('common.delete') || 'Törlés'"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Tax Cards -->
<div v-for="tax in taxObligations" :key="tax.id" class="rounded-xl border border-slate-700/50 bg-slate-800/30 p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="rounded-full bg-amber-500/20 px-2.5 py-0.5 text-xs font-medium text-amber-400">
{{ tax.tax_type || (t('finance.tax') || 'Adó') }}
</span>
<span class="text-sm font-semibold text-white">{{ formatCurrency(tax.amount, tax.currency) }}</span>
<span v-if="tax.paid" class="rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
{{ t('finance.paid') || 'Fizetve' }}
</span>
<span v-else class="rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
{{ t('finance.unpaid') || 'Fizetendő' }}
</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
<div>
<span class="text-slate-400">{{ t('finance.due_date') || 'Esedékesség' }}:</span>
<span class="ml-1 text-slate-200">{{ formatDate(tax.due_date) }}</span>
</div>
</div>
</div>
<button
@click="deleteTax(tax)"
class="ml-2 rounded-lg p-1.5 text-slate-500 hover:bg-red-500/20 hover:text-red-400 transition-colors cursor-pointer"
:title="t('common.delete') || 'Törlés'"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- -->
<!-- SECTION 3: TCO / Expenses -->
<!-- -->
<div class="rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-white flex items-center gap-2">
<span>&#128202;</span> {{ t('finance.tco_section_title') || 'TCO / Költségek' }}
</h3>
<button
@click="openCostModal"
class="rounded-lg bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent hover:bg-sf-accent/30 transition-colors cursor-pointer"
>
{{ t('finance.add_cost') || '+ Költség' }}
</button>
</div>
<!-- Placeholder for TCO / Expenses -->
<div class="flex flex-col items-center justify-center py-8 text-slate-400">
<svg class="h-10 w-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-sm">{{ t('finance.tco_placeholder') || 'A TCO / költségadatok itt jelennek meg.' }}</p>
</div>
</div>
<!-- -->
<!-- FINANCIALS MODAL (inline edit) -->
<!-- -->
<Teleport to="body">
<div v-if="showFinancialsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeFinancialsModal">
<div class="w-full max-w-lg rounded-2xl border border-slate-700 bg-slate-900 p-6 shadow-2xl">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-bold text-white">{{ t('finance.edit_financials') || 'Beszerzés & Lízing adatok' }}</h3>
<button @click="closeFinancialsModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors 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="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.purchase_price_net') || 'Nettó vételár' }}</label>
<input v-model.number="financialsForm.purchase_price_net" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.purchase_price_gross') || 'Bruttó vételár' }}</label>
<input v-model.number="financialsForm.purchase_price_gross" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.down_payment') || 'Önerő' }}</label>
<input v-model.number="financialsForm.down_payment" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.monthly_installment') || 'Havi törlesztő' }}</label>
<input v-model.number="financialsForm.monthly_installment" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.contract_number') || 'Szerződésszám' }}</label>
<input v-model="financialsForm.contract_number" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.financing_provider') || 'Finanszírozó' }}</label>
<input v-model="financialsForm.financing_provider" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.interest_rate') || 'Kamat (%)' }}</label>
<input v-model.number="financialsForm.interest_rate" type="number" step="0.01" min="0" max="100" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.residual_value') || 'Maradványérték' }}</label>
<input v-model.number="financialsForm.residual_value" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.contract_start_date') || 'Szerződés kezdete' }}</label>
<input v-model="financialsForm.contract_start_date" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.contract_end_date') || 'Szerződés vége' }}</label>
<input v-model="financialsForm.contract_end_date" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button @click="closeFinancialsModal" class="rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800 transition-colors cursor-pointer">
{{ t('common.cancel') || 'Mégse' }}
</button>
<button @click="saveFinancials" :disabled="savingFinancials" class="rounded-xl bg-sf-accent px-4 py-2 text-sm font-medium text-white hover:bg-sf-accent/90 disabled:opacity-50 transition-colors cursor-pointer">
{{ savingFinancials ? (t('common.saving') || 'Mentés...') : (t('common.save') || 'Mentés') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- -->
<!-- INSURANCE MODAL -->
<!-- -->
<Teleport to="body">
<div v-if="showInsuranceModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeInsuranceModal">
<div class="w-full max-w-md rounded-2xl border border-slate-700 bg-slate-900 p-6 shadow-2xl">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-bold text-white">{{ t('finance.add_insurance') || 'Új biztosítás' }}</h3>
<button @click="closeInsuranceModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors 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="space-y-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.insurer_name') || 'Biztosító neve' }}</label>
<input v-model="insuranceForm.insurer_name" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.policy_number') || 'Kötvényszám' }}</label>
<input v-model="insuranceForm.policy_number" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.policy_type') || 'Biztosítás típusa' }}</label>
<select v-model="insuranceForm.policy_type" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
<option value="kgfb">KGFB</option>
<option value="casco">CASCO</option>
<option value="both">KGFB + CASCO</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.premium') || 'Díj' }}</label>
<input v-model.number="insuranceForm.premium" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.currency') || 'Pénznem' }}</label>
<select v-model="insuranceForm.currency" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
<option value="HUF">HUF</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.valid_from') || 'Érvényesség kezdete' }}</label>
<input v-model="insuranceForm.valid_from" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.valid_until') || 'Érvényesség vége' }}</label>
<input v-model="insuranceForm.valid_until" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button @click="closeInsuranceModal" class="rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800 transition-colors cursor-pointer">
{{ t('common.cancel') || 'Mégse' }}
</button>
<button @click="saveInsurance" :disabled="savingInsurance" class="rounded-xl bg-sf-accent px-4 py-2 text-sm font-medium text-white hover:bg-sf-accent/90 disabled:opacity-50 transition-colors cursor-pointer">
{{ savingInsurance ? (t('common.saving') || 'Mentés...') : (t('common.save') || 'Mentés') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- -->
<!-- TAX MODAL -->
<!-- -->
<Teleport to="body">
<div v-if="showTaxModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeTaxModal">
<div class="w-full max-w-md rounded-2xl border border-slate-700 bg-slate-900 p-6 shadow-2xl">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-bold text-white">{{ t('finance.add_tax') || 'Új adó' }}</h3>
<button @click="closeTaxModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors 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="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.tax_type') || 'Adó típusa' }}</label>
<input v-model="taxForm.tax_type" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.amount') || 'Összeg' }}</label>
<input v-model.number="taxForm.amount" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.currency') || 'Pénznem' }}</label>
<select v-model="taxForm.currency" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
<option value="HUF">HUF</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.due_date') || 'Esedékesség' }}</label>
<input v-model="taxForm.due_date" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="flex items-center gap-2">
<input v-model="taxForm.paid" type="checkbox" id="tax-paid" class="rounded border-slate-600 bg-slate-800 text-sf-accent focus:ring-sf-accent/20 cursor-pointer" />
<label for="tax-paid" class="text-sm text-slate-300 cursor-pointer">{{ t('finance.paid') || 'Fizetve' }}</label>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button @click="closeTaxModal" class="rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800 transition-colors cursor-pointer">
{{ t('common.cancel') || 'Mégse' }}
</button>
<button @click="saveTax" :disabled="savingTax" class="rounded-xl bg-sf-accent px-4 py-2 text-sm font-medium text-white hover:bg-sf-accent/90 disabled:opacity-50 transition-colors cursor-pointer">
{{ savingTax ? (t('common.saving') || 'Mentés...') : (t('common.save') || 'Mentés') }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVehicleStore } from '@/stores/vehicle'
import type { AssetFinancials, VehicleInsurancePolicy, VehicleTaxObligation } from '@/types/vehicle'
const { t } = useI18n()
const vehicleStore = useVehicleStore()
const props = defineProps<{ vehicleId: string }>()
// ── Reactive State ────────────────────────────────────────────────────────
const financials = ref<AssetFinancials | null>(null)
const insurancePolicies = ref<VehicleInsurancePolicy[]>([])
const taxObligations = ref<VehicleTaxObligation[]>([])
const loadingFinancials = ref(false)
const loadingInsurance = ref(false)
const savingFinancials = ref(false)
const savingInsurance = ref(false)
const savingTax = ref(false)
const showFinancialsModal = ref(false)
const showInsuranceModal = ref(false)
const showTaxModal = ref(false)
// ── Form Objects ──────────────────────────────────────────────────────────
const financialsForm = reactive<Partial<AssetFinancials>>({
purchase_price_net: null,
purchase_price_gross: null,
down_payment: null,
monthly_installment: null,
contract_number: '',
financing_provider: '',
interest_rate: null,
residual_value: null,
contract_start_date: '',
contract_end_date: '',
})
const insuranceForm = reactive<Partial<VehicleInsurancePolicy>>({
insurer_name: '',
policy_number: '',
policy_type: 'kgfb',
premium: null,
currency: 'HUF',
valid_from: '',
valid_until: '',
})
const taxForm = reactive<Partial<VehicleTaxObligation>>({
tax_type: '',
amount: null,
currency: 'HUF',
due_date: '',
paid: false,
})
// ── Helper Methods ────────────────────────────────────────────────────────
function formatCurrency(value: number | null | undefined, currency?: string): string {
if (value == null) return '—'
const cur = currency || 'HUF'
try {
return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: cur }).format(value)
} catch {
return `${value.toLocaleString('hu-HU')} ${cur}`
}
}
function formatDate(date: string | null | undefined): string {
if (!date) return '—'
try {
return new Intl.DateTimeFormat('hu-HU', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date))
} catch {
return date
}
}
// ── Data Loading ──────────────────────────────────────────────────────────
async function loadData() {
if (!props.vehicleId) return
loadingFinancials.value = true
loadingInsurance.value = true
try {
const [finResult, insResult, taxResult] = await Promise.all([
vehicleStore.fetchAssetFinancials(props.vehicleId),
vehicleStore.fetchInsurancePolicies(props.vehicleId),
vehicleStore.fetchTaxObligations(props.vehicleId),
])
financials.value = finResult
insurancePolicies.value = insResult
taxObligations.value = taxResult
} catch (err) {
console.error('[FinancialsTab] Failed to load data:', err)
} finally {
loadingFinancials.value = false
loadingInsurance.value = false
}
}
// ── Financials Modal ──────────────────────────────────────────────────────
function openFinancialsModal() {
if (financials.value) {
Object.assign(financialsForm, {
purchase_price_net: financials.value.purchase_price_net ?? null,
purchase_price_gross: financials.value.purchase_price_gross ?? null,
down_payment: financials.value.down_payment ?? null,
monthly_installment: financials.value.monthly_installment ?? null,
contract_number: financials.value.contract_number || '',
financing_provider: financials.value.financing_provider || '',
interest_rate: financials.value.interest_rate ?? null,
residual_value: financials.value.residual_value ?? null,
contract_start_date: financials.value.contract_start_date || '',
contract_end_date: financials.value.contract_end_date || '',
})
} else {
Object.assign(financialsForm, {
purchase_price_net: null,
purchase_price_gross: null,
down_payment: null,
monthly_installment: null,
contract_number: '',
financing_provider: '',
interest_rate: null,
residual_value: null,
contract_start_date: '',
contract_end_date: '',
})
}
showFinancialsModal.value = true
}
function closeFinancialsModal() {
showFinancialsModal.value = false
}
async function saveFinancials() {
if (!props.vehicleId) return
savingFinancials.value = true
try {
const result = await vehicleStore.saveAssetFinancials(props.vehicleId, { ...financialsForm })
if (result) {
financials.value = result
closeFinancialsModal()
}
} catch (err) {
console.error('[FinancialsTab] Failed to save financials:', err)
} finally {
savingFinancials.value = false
}
}
// ── Insurance Modal ───────────────────────────────────────────────────────
function openInsuranceModal() {
Object.assign(insuranceForm, {
insurer_name: '',
policy_number: '',
policy_type: 'kgfb',
premium: null,
currency: 'HUF',
valid_from: '',
valid_until: '',
})
showInsuranceModal.value = true
}
function closeInsuranceModal() {
showInsuranceModal.value = false
}
async function saveInsurance() {
if (!props.vehicleId) return
savingInsurance.value = true
try {
const result = await vehicleStore.createInsurancePolicy(props.vehicleId, { ...insuranceForm })
if (result) {
insurancePolicies.value.push(result)
closeInsuranceModal()
}
} catch (err) {
console.error('[FinancialsTab] Failed to save insurance:', err)
} finally {
savingInsurance.value = false
}
}
async function deleteInsurance(policy: VehicleInsurancePolicy) {
if (!props.vehicleId || !policy.id) return
if (!confirm(t('common.confirm_delete') || 'Biztosan törlöd?')) return
try {
const success = await vehicleStore.deleteInsurancePolicy(props.vehicleId, policy.id)
if (success) {
insurancePolicies.value = insurancePolicies.value.filter(p => p.id !== policy.id)
}
} catch (err) {
console.error('[FinancialsTab] Failed to delete insurance:', err)
}
}
// ── Tax Modal ─────────────────────────────────────────────────────────────
function openTaxModal() {
Object.assign(taxForm, {
tax_type: '',
amount: null,
currency: 'HUF',
due_date: '',
paid: false,
})
showTaxModal.value = true
}
function closeTaxModal() {
showTaxModal.value = false
}
async function saveTax() {
if (!props.vehicleId) return
savingTax.value = true
try {
const result = await vehicleStore.createTaxObligation(props.vehicleId, { ...taxForm })
if (result) {
taxObligations.value.push(result)
closeTaxModal()
}
} catch (err) {
console.error('[FinancialsTab] Failed to save tax:', err)
} finally {
savingTax.value = false
}
}
async function deleteTax(tax: VehicleTaxObligation) {
if (!props.vehicleId || !tax.id) return
if (!confirm(t('common.confirm_delete') || 'Biztosan törlöd?')) return
try {
const success = await vehicleStore.deleteTaxObligation(props.vehicleId, tax.id)
if (success) {
taxObligations.value = taxObligations.value.filter(t => t.id !== tax.id)
}
} catch (err) {
console.error('[FinancialsTab] Failed to delete tax:', err)
}
}
// ── TCO / Cost Modal (placeholder) ────────────────────────────────────────
function openCostModal() {
// TODO: Implement cost modal when TCO module is ready
console.warn('[FinancialsTab] TCO cost modal not yet implemented')
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
onMounted(() => {
loadData()
})
</script>
<style scoped>
/* All styling is handled by Tailwind utility classes */
</style>

View File

@@ -1,100 +1,171 @@
<template>
<div class="space-y-6">
<!-- Row 1: Photo + Basic Info -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
<!-- LEFT: Photo -->
<div class="lg:col-span-5">
<!-- Vehicle Photo -->
<div class="flex aspect-[16/9] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 backdrop-blur-sm lg:aspect-square">
<div
v-if="vehicle?.photo_url && !imageError"
class="flex aspect-[16/9] items-center justify-center overflow-hidden rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md shadow-xl lg:aspect-square"
>
<img
src="https://images.unsplash.com/photo-1503376712341-a67b5e40e2d1?auto=format&fit=crop&w=800&q=80"
:src="vehicle.photo_url"
alt="Vehicle photo"
class="h-full w-full object-cover rounded-2xl"
class="h-full w-full rounded-2xl object-cover"
@error="imageError = true"
/>
</div>
<!-- Basic Info -->
<div class="flex flex-col justify-center rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm lg:col-span-2">
<!-- License Plate (large, prominent) -->
<h2 class="text-4xl font-bold tracking-wider text-white">
{{ vehicle?.license_plate || t('vehicleDetail.noPlate') }}
</h2>
<!-- Brand / Model -->
<p class="mt-2 text-2xl text-white/80">
{{ vehicle?.brand || '—' }} {{ vehicle?.model || '—' }}
</p>
<!-- Year of Manufacture -->
<p class="mt-1 text-lg text-white/50">
{{ vehicle?.year_of_manufacture || '—' }}
</p>
<!-- Divider -->
<div class="my-4 border-t border-white/10" />
<!-- Quick specs row -->
<div class="flex flex-wrap gap-x-8 gap-y-2">
<div>
<span class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.vin') }}</span>
<p class="text-sm text-white/70">{{ vehicle?.vin || '—' }}</p>
</div>
<div>
<span class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.fuelType') }}</span>
<p class="text-sm text-white/70">{{ fuelLabel }}</p>
</div>
<div>
<span class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.mileage') }}</span>
<p class="text-sm text-white/70">{{ formattedMileage }}</p>
</div>
</div>
<!-- Photo Fallback (SVG Placeholder) -->
<div
v-else
class="flex aspect-[16/9] flex-col items-center justify-center gap-3 rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md shadow-xl lg:aspect-square"
>
<svg class="h-16 w-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-sm text-white/40">{{ t('vehicleDetail.noPhoto') }}</p>
</div>
</div>
<!-- Row 2: Vital Signs (3 cards) -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<!-- MOT Expiry -->
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
<div class="flex items-center gap-3">
<!-- RIGHT: Master Data Card -->
<div class="flex flex-col rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl lg:col-span-7">
<!-- SECTION 1: Main Data (Top) -->
<!-- License Plate (large, prominent) -->
<h2 class="text-4xl font-bold tracking-wider text-white">
{{ vehicle?.license_plate || t('common.noPlate') }}
</h2>
<!-- Brand / Model / Year -->
<p class="mt-2 text-2xl text-white/80">
{{ vehicle?.brand || '—' }} {{ vehicle?.model || '—' }}
</p>
<p class="mt-1 text-lg text-white/60">
{{ vehicle?.year_of_manufacture || '—' }}
</p>
<!-- Quick stats grid: Fuel, Mileage, Monthly Cost, Monthly Mileage -->
<div class="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
<!-- Fuel -->
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.fuelType') }}</span>
<p class="text-base font-semibold text-white">{{ fuelLabel }}</p>
</div>
<!-- Current Mileage (only here) -->
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('common.mileage') }}</span>
<p class="text-base font-semibold text-white">{{ formattedMileage }}</p>
</div>
<!-- Monthly Cost (dynamic from Expense Store) -->
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.monthlyCost') }}</span>
<p class="flex items-center gap-1 text-base font-semibold text-white">
<svg class="h-4 w-4 text-rose-400" 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>
{{ monthlyCostLabel }}
</p>
</div>
<!-- Monthly Mileage -->
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.monthlyMileage') }}</span>
<p class="flex items-center gap-1 text-base font-semibold text-white">
<svg class="h-4 w-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
{{ monthlyMileageLabel }}
</p>
</div>
</div>
<!-- Divider -->
<hr class="my-5 border-slate-700" />
<!-- SECTION 2: Recent Expenses (Middle) -->
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/60">{{ t('vehicleDetail.recentExpenses') }}</h3>
<!-- Loading state -->
<div v-if="expenseStore.isLoading" class="flex items-center justify-center py-6">
<svg class="h-6 w-6 animate-spin text-white/40" 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>
<!-- Expense list -->
<div v-else-if="recentExpenses.length > 0" class="flex flex-col">
<div
v-for="(expense, index) in recentExpenses"
:key="expense.id || index"
class="flex items-center justify-between py-2"
>
<div class="flex items-center gap-3">
<!-- Dynamic icon based on category -->
<svg
v-if="expense.icon === 'fuel'"
class="h-4 w-4 text-amber-400" 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>
<svg
v-else-if="expense.icon === 'service'"
class="h-4 w-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<svg
v-else
class="h-4 w-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<span class="text-sm text-white/80">{{ expense.label }}</span>
</div>
<span class="text-sm font-semibold text-white">{{ formatCurrency(expense.amount) }}</span>
</div>
</div>
<!-- Empty state: no expenses -->
<div v-else class="flex flex-col items-center justify-center py-6 text-white/40">
<svg class="mb-2 h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p class="text-sm">{{ t('vehicleDetail.noExpenses') }}</p>
</div>
<!-- Divider -->
<hr class="my-5 border-slate-700" />
<!-- SECTION 3: Deadlines (Bottom) -->
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/60">{{ t('vehicleDetail.deadlines') }}</h3>
<div class="grid grid-cols-2 gap-4">
<!-- MOT Expiry -->
<div class="flex items-center gap-3 rounded-xl border border-slate-700 bg-slate-900/70 p-4">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/20">
<svg class="h-5 w-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div>
<p class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.motExpiry') }}</p>
<p class="text-base font-semibold text-white" :class="motStatusClass">
<p class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.motExpiry') }}</p>
<p class="text-base font-semibold" :class="motStatusClass">
{{ motExpiryLabel }}
</p>
</div>
</div>
</div>
<!-- Current Mileage -->
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/20">
<svg class="h-5 w-5 text-blue-400" 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 uppercase tracking-wider text-white/40">{{ t('vehicleDetail.mileage') }}</p>
<p class="text-base font-semibold text-white">
{{ formattedMileage }}
</p>
</div>
</div>
</div>
<!-- Next Service -->
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
<div class="flex items-center gap-3">
<!-- Next Service -->
<div class="flex items-center gap-3 rounded-xl border border-slate-700 bg-slate-900/70 p-4">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/20">
<svg class="h-5 w-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</div>
<div>
<p class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.nextService') }}</p>
<p class="text-xs uppercase tracking-wider text-white/50">{{ t('common.nextService') }}</p>
<p class="text-base font-semibold text-white">
{{ nextServiceLabel }}
</p>
@@ -106,15 +177,20 @@
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { computed, inject, ref, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useExpenseStore, type ExpenseItem } from '../../../stores/expense'
import type { Vehicle } from '../../../stores/vehicle'
const { t } = useI18n()
const expenseStore = useExpenseStore()
// ── Inject vehicle from parent (provided by VehicleDetailsView) ──
const vehicle = inject<computed<Vehicle | null>>('vehicle')
// ── Image error state ──
const imageError = ref(false)
// ── Fuel type label ──
const fuelLabel = computed(() => {
if (!vehicle?.value?.fuel_type) return '—'
@@ -129,10 +205,74 @@ const formattedMileage = computed(() => {
return `${Number(vehicle.value.current_mileage).toLocaleString()} km`
})
// ── Monthly Cost (dynamic from Expense Store - current month aggregate) ──
const monthlyCostLabel = computed(() => {
if (!vehicle?.value?.id) return t('common.noData')
const monthlyCost = expenseStore.getMonthlyCost(vehicle.value.id)
if (monthlyCost > 0) {
return formatCurrency(monthlyCost)
}
return t('common.noData')
})
// ── Monthly Mileage (dynamic from individual_equipment or fallback) ──
const monthlyMileageLabel = computed(() => {
const ie = vehicle?.value?.individual_equipment
const monthlyMileage = ie?.monthly_mileage ?? ie?.monthlyMileage
if (monthlyMileage != null && !isNaN(Number(monthlyMileage))) {
return `${Number(monthlyMileage).toLocaleString()} km`
}
return t('common.noData')
})
// ── Recent Expenses (from Expense Store, sorted by date desc, latest 3) ──
interface DisplayExpense {
id: string
label: string
amount: number
icon: 'fuel' | 'service' | 'other'
}
const recentExpenses = computed<DisplayExpense[]>(() => {
if (!vehicle?.value?.id) return []
const expenses = expenseStore.getExpensesForAsset(vehicle.value.id)
if (!expenses.length) return []
// Sort by date descending (newest first)
const sorted = [...expenses].sort((a, b) => {
if (!a.date && !b.date) return 0
if (!a.date) return 1
if (!b.date) return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
// Take the latest 3 entries
return sorted.slice(0, 3).map((e: ExpenseItem) => ({
id: e.id,
label: e.description || e.category_name || e.category_code || t('common.noData'),
amount: e.amount_gross ? parseFloat(e.amount_gross) : 0,
icon: resolveIcon(e.category_code || e.category_name || ''),
}))
})
function resolveIcon(category: string): 'fuel' | 'service' | 'other' {
const cat = (category || '').toLowerCase()
if (cat.includes('fuel') || cat.includes('tankol') || cat.includes('üzemanyag') || cat.includes('benzin')) return 'fuel'
if (cat.includes('service') || cat.includes('szerviz') || cat.includes('javítás') || cat.includes('repair') || cat.includes('maintenance') || cat.includes('karbantartás')) return 'service'
return 'other'
}
// ── Currency formatter ──
function formatCurrency(amount: number): string {
if (amount == null || isNaN(amount)) return t('common.noData')
return `${amount.toLocaleString()} Ft`
}
// ── MOT Expiry ──
const motExpiryLabel = computed(() => {
const dates = vehicle?.value?.individual_equipment?.dates
if (!dates?.mot_expiry) return t('vehicleDetail.noData')
if (!dates?.mot_expiry) return t('common.noData')
return formatDate(dates.mot_expiry)
})
@@ -162,4 +302,25 @@ function formatDate(dateStr: string): string {
return dateStr
}
}
// ── Fetch expenses when vehicle is available ──
async function loadExpenses() {
if (vehicle?.value?.id) {
await expenseStore.fetchExpensesByAsset(vehicle.value.id)
}
}
onMounted(() => {
loadExpenses()
})
// Watch for vehicle ID changes (e.g., when navigating between vehicles)
watch(
() => vehicle?.value?.id,
(newId) => {
if (newId) {
loadExpenses()
}
}
)
</script>

View File

@@ -1,17 +1,278 @@
<template>
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabTechData') }}</h3>
<div class="flex flex-col items-center justify-center py-12 text-white/50">
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p class="text-base">{{ t('vehicleDetail.placeholderTechData') }}</p>
<div class="space-y-6">
<!-- Section 1: Basic Specs -->
<div
v-if="basicSpecs.length > 0"
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">{{ t('vehicleDetail.sectionBasicSpecs') }}</h3>
</div>
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="spec in basicSpecs" :key="spec.key" class="flex flex-col">
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
<span class="text-sm text-white/90">{{ spec.value }}</span>
</div>
</div>
</div>
<!-- Section 2: Engine & Drivetrain -->
<div
v-if="engineDrivetrainSpecs.length > 0"
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">{{ t('vehicleDetail.sectionEngineDrivetrain') }}</h3>
<!-- LE/KW Display Toggle -->
<div class="flex items-center gap-2">
<span class="text-[10px] font-semibold text-white/50 uppercase tracking-wider">{{ t('vehicleDetail.powerUnitLabel') }}</span>
<button
type="button"
@click="togglePowerUnit"
class="relative inline-flex h-6 w-10 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-sf-accent/30"
:class="powerUnit === 'kw' ? 'bg-sf-accent' : 'bg-amber-500'"
>
<span
class="inline-flex h-4 w-4 items-center rounded-full bg-white text-[8px] font-bold shadow-sm transition-all duration-200"
:class="powerUnit === 'kw' ? 'translate-x-0.5 justify-center' : 'translate-x-5 justify-end pr-0.5'"
>
{{ powerUnit === 'kw' ? 'kW' : 'LE' }}
</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="spec in engineDrivetrainSpecs" :key="spec.key" class="flex flex-col">
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
<span class="text-sm text-white/90">{{ spec.value }}</span>
</div>
</div>
</div>
<!-- Section 3: Body & Dimensions -->
<div
v-if="bodyDimensionSpecs.length > 0"
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
>
<h3 class="mb-4 text-lg font-semibold text-white">{{ t('vehicleDetail.sectionBodyDimensions') }}</h3>
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="spec in bodyDimensionSpecs" :key="spec.key" class="flex flex-col">
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
<span class="text-sm text-white/90">{{ spec.value }}</span>
</div>
</div>
</div>
<!-- Section 4: EV Specs (conditional) -->
<div
v-if="evSpecs.length > 0"
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
>
<h3 class="mb-4 text-lg font-semibold text-white">{{ t('vehicleDetail.sectionEvSpecs') }}</h3>
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="spec in evSpecs" :key="spec.key" class="flex flex-col">
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
<span class="text-sm text-white/90">{{ spec.value }}</span>
</div>
</div>
</div>
<!-- Section 5: Features / Extras (conditional) -->
<div
v-if="featuresList.length > 0"
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
>
<h3 class="mb-4 text-lg font-semibold text-white">{{ t('vehicleDetail.sectionFeatures') }}</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="feat in featuresList"
:key="feat"
class="px-3 py-1 text-sm bg-slate-800 border border-slate-600 rounded-full text-slate-300"
>
{{ getFeatureTranslation(feat) }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
import type { Vehicle } from '../../../stores/vehicle'
const { t, te } = useI18n()
// ── Inject vehicle from parent ──
const vehicle = inject<computed<Vehicle | null>>('vehicle')
// ── Power Unit Toggle (LE / KW) for display ──
const powerUnit = ref<'kw' | 'le'>('kw')
function togglePowerUnit() {
powerUnit.value = powerUnit.value === 'kw' ? 'le' : 'kw'
}
/** Convert kW to LE (1 kW = 1.34102 HP/LE) */
function kwToLe(kw: number): number {
return Math.round(kw * 1.34102)
}
/** Display power value based on selected unit */
function powerDisplay(kw: number | null | undefined): string {
if (kw === null || kw === undefined) return '—'
if (powerUnit.value === 'kw') return `${kw} kW`
return `${kwToLe(kw)} LE`
}
// ── Helper: display value or dash ──
function val(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
return String(value)
}
// ── Helper: translate fuel type ──
function fuelLabel(fuel: string | null | undefined): string {
if (!fuel) return '—'
const key = `vehicle.fuelTypes.${fuel}`
const translated = t(key)
return translated !== key ? translated : fuel
}
// ── Helper: translate body type ──
function bodyTypeLabel(bodyType: string | null | undefined): string {
if (!bodyType) return '—'
const key = `vehicle.bodyTypes.${bodyType}`
const translated = t(key)
return translated !== key ? translated : bodyType
}
// ── Helper: translate transmission type ──
function transmissionLabel(transmission: string | null | undefined): string {
if (!transmission) return '—'
const key = `vehicle.transmissionTypes.${transmission}`
const translated = t(key)
return translated !== key ? translated : transmission
}
// ── Helper: translate drive type ──
function driveLabel(drive: string | null | undefined): string {
if (!drive) return '—'
const key = `vehicle.driveTypes.${drive}`
const translated = t(key)
return translated !== key ? translated : drive
}
// ── Helper: translate cylinder layout ──
function cylinderLabel(layout: string | null | undefined): string {
if (!layout) return '—'
const key = `vehicle.cylinderLayouts.${layout}`
const translated = t(key)
return translated !== key ? translated : layout
}
// ── Helper: translate AC type ──
function acLabel(acType: string | null | undefined): string {
if (!acType) return '—'
const key = `vehicle.acTypes.${acType}`
const translated = t(key)
return translated !== key ? translated : acType
}
/**
* i18n feature fallback: tries to translate a feature key with category-specific
* fallback support. If neither `vehicle.features.{feat}` nor
* `vehicle.{category}Features.{feat}` exists, returns the raw key.
*/
function getFeatureTranslation(feat: string): string {
if (te(`vehicle.features.${feat}`)) return t(`vehicle.features.${feat}`)
// Attempt category-specific fallback (e.g. vehicle.safetyFeatures.ABS)
const category = vehicle?.value?.individual_equipment?.car_specs?.body_type || ''
if (category && te(`vehicle.${category}Features.${feat}`)) {
return t(`vehicle.${category}Features.${feat}`)
}
return feat
}
// ── Section 1: Basic Specs ──
interface SpecItem {
key: string
label: string
value: string
}
const basicSpecs = computed<SpecItem[]>(() => {
const v = vehicle?.value
return [
{ key: 'brand', label: t('vehicle.label_brand'), value: val(v?.brand) },
{ key: 'model', label: t('vehicle.label_model'), value: val(v?.model) },
{ key: 'year', label: t('vehicle.label_year'), value: val(v?.year_of_manufacture) },
{ key: 'vin', label: t('common.vin'), value: val(v?.vin) },
{ key: 'class', label: t('vehicleDetail.vehicleClass'), value: val(v?.vehicle_class) },
{ key: 'trim', label: t('vehicleDetail.trimLevel'), value: val(v?.trim_level) },
].filter(item => item.value && item.value !== '—' && item.value !== '-')
})
// ── Section 2: Engine & Drivetrain ──
const engineDrivetrainSpecs = computed<SpecItem[]>(() => {
const v = vehicle?.value
return [
{ key: 'fuel', label: t('vehicleDetail.fuelType'), value: fuelLabel(v?.fuel_type) },
{ key: 'capacity', label: t('vehicleDetail.engineCapacity'), value: val(v?.engine_capacity) },
{
key: 'power',
label: powerUnit.value === 'kw' ? t('vehicleDetail.powerKw') : t('vehicleDetail.powerLe'),
value: powerDisplay(v?.power_kw),
},
{ key: 'torque', label: t('vehicleDetail.torqueNm'), value: val(v?.torque_nm) },
{ key: 'transmission', label: t('vehicleDetail.transmission'), value: transmissionLabel(v?.transmission_type) },
{ key: 'drive', label: t('vehicleDetail.driveType'), value: driveLabel(v?.drive_type) },
{ key: 'euroClass', label: t('vehicleDetail.euroClass'), value: val(v?.euro_classification) },
].filter(item => item.value && item.value !== '—' && item.value !== '-')
})
// ── Section 3: Body & Dimensions ──
const bodyDimensionSpecs = computed<SpecItem[]>(() => {
const v = vehicle?.value
const carSpecs = v?.individual_equipment?.car_specs
// Prefer top-level columns, fall back to individual_equipment.car_specs
const bodyType = carSpecs?.body_type || v?.vehicle_class
const doorCount = v?.door_count ?? carSpecs?.door_count
const seatCount = v?.seat_count ?? carSpecs?.seat_count
const cylinderLayout = v?.cylinder_layout ?? carSpecs?.cylinder_layout
return [
{ key: 'bodyType', label: t('vehicleDetail.bodyType'), value: bodyTypeLabel(bodyType) },
{ key: 'doors', label: t('vehicleDetail.doors'), value: val(doorCount) },
{ key: 'seats', label: t('vehicleDetail.seats'), value: val(seatCount) },
{ key: 'cylinderLayout', label: t('vehicleDetail.cylinderLayout'), value: cylinderLabel(cylinderLayout) },
{ key: 'acType', label: t('vehicleDetail.acType'), value: acLabel(carSpecs?.ac_type) },
{ key: 'curbWeight', label: t('vehicleDetail.curbWeight'), value: val(v?.curb_weight) },
{ key: 'maxWeight', label: t('vehicleDetail.maxWeight'), value: val(v?.max_weight) },
{ key: 'mileage', label: t('vehicleDetail.mileage'), value: val(v?.current_mileage) },
{ key: 'condition', label: t('vehicleDetail.conditionScore'), value: val(v?.condition_score) },
].filter(item => item.value && item.value !== '—' && item.value !== '-')
})
// ── Section 4: EV Specs (conditional) ──
const evSpecs = computed<SpecItem[]>(() => {
const ev = vehicle?.value?.individual_equipment?.ev_specs
if (!ev) return []
return [
{ key: 'battery', label: t('vehicleDetail.batteryCapacity'), value: val(ev.battery_capacity_kwh) },
{ key: 'range', label: t('vehicleDetail.range'), value: val(ev.range_wltp) },
{ key: 'acConnector', label: t('vehicleDetail.acConnector'), value: val(ev.ac_connector) },
{ key: 'dcConnector', label: t('vehicleDetail.dcConnector'), value: val(ev.dc_connector) },
].filter(item => item.value && item.value !== '—' && item.value !== '-')
})
// ── Section 5: Features / Extras ──
const featuresList = computed<string[]>(() => {
const v = vehicle?.value
// Features can be at individual_equipment.features (top-level array)
// or nested under individual_equipment.car_specs.features
const ie = v?.individual_equipment
const features = ie?.features || ie?.car_specs?.features
if (!features || !Array.isArray(features) || features.length === 0) return []
return features
})
</script>

View File

@@ -13,6 +13,33 @@ export default {
fuel: 'Fuel',
mileage: 'Mileage',
year: 'Year',
noPlate: 'No Plate',
nextService: 'Next Service',
noData: 'No data',
backToGarage: 'Back to Garage',
statusActive: 'Active',
country: 'Country',
zipCode: 'ZIP Code',
city: 'City',
houseNumber: 'House Number',
stairwell: 'Stairwell',
floor: 'Floor',
door: 'Door',
parcelId: 'Parcel ID',
streetName: 'Street Name',
dashboardSubtitle: 'Your dashboard and vehicle overview',
profileSettings: 'Profile Settings',
streetType: 'Street Type',
email: 'Email',
phone: 'Phone',
na: 'N/A',
edit: 'Edit',
delete: 'Delete',
close: 'Close',
next: 'Next →',
prev: '← Prev',
actions: 'Actions',
items: 'items',
},
subscription: {
title: 'Subscription Plans',
@@ -45,6 +72,15 @@ export default {
prioritySupport: 'Priority support',
adFree: 'Ad-free',
},
// P0: Subscription Info Modal
subscriptionInfo: 'Subscription Info',
active: 'Active',
expiresAt: 'Expires at',
daysLeftShort: 'days',
vehicleLimit: 'Vehicle Limit',
garageLimit: 'Garage Limit',
managePlan: 'Manage Plan',
indefinite: 'Indefinite',
},
landing: {
openGarage: 'Open Garage',
@@ -75,22 +111,17 @@ export default {
title: 'My Garage',
subtitle: 'Managing {count} vehicles',
backToDashboard: 'Back to Dashboard',
backToGarage: 'Back to Garage',
noPlate: 'No Plate',
primary: 'Primary',
brandModel: 'Brand / Model',
year: 'Year',
nextService: 'Next Service',
mileage: 'Mileage',
emptyTitle: 'Your garage is empty',
emptySubtitle: 'Add your first vehicle to get started!',
addVehicle: 'Add Vehicle',
},
vehicleDetail: {
title: 'Vehicle Details',
placeholderTitle: 'Deep Dive Coming Soon',
placeholderSubtitle: 'This page will soon feature a full tabbed layout with detailed vehicle information, service history, costs, and more.',
notFound: 'Vehicle not found.',
noPlate: 'No Plate',
// Tab labels
tabOverview: 'Overview',
tabTechData: 'Technical Data',
@@ -104,21 +135,52 @@ export default {
placeholderFinancials: 'Financial records will appear here.',
placeholderDocuments: 'Documents will appear here.',
// OverviewTab keys
noPhoto: 'No photo',
photoPlaceholder: 'Photo upload coming soon',
vin: 'VIN',
fuelType: 'Fuel',
mileage: 'Mileage',
motExpiry: 'MOT Expiry',
nextService: 'Next Service',
noData: 'No data',
comingSoon: 'Coming soon',
monthlyCost: 'Monthly Cost',
monthlyMileage: 'Monthly Mileage',
recentExpenses: 'Recent Expenses',
noExpenses: 'No recorded expenses yet',
deadlines: 'Deadlines',
// TechDataTab section titles
sectionBasicSpecs: 'Basic Specs',
sectionEngineDrivetrain: 'Engine & Drivetrain',
sectionBodyDimensions: 'Body & Dimensions',
sectionEvSpecs: 'Electric Drive',
sectionFeatures: 'Features / Equipment',
// TechDataTab field labels
vehicleClass: 'Class',
trimLevel: 'Trim Level',
transmission: 'Transmission',
driveType: 'Drive Type',
bodyType: 'Body Type',
doors: 'Doors',
seats: 'Seats',
cylinderLayout: 'Cylinder Layout',
acType: 'A/C Type',
batteryCapacity: 'Battery (kWh)',
range: 'Range (km)',
acConnector: 'AC Connector',
dcConnector: 'DC Connector',
engineCapacity: 'Engine Capacity (cm³)',
powerKw: 'Power (kW)',
powerUnitLabel: 'HP/KW',
powerLe: 'Power (HP)',
torqueNm: 'Torque (Nm)',
euroClass: 'Euro Class',
curbWeight: 'Curb Weight (kg)',
maxWeight: 'Max Gross Weight (kg)',
mileage: 'Mileage (km)',
conditionScore: 'Condition Score',
},
header: {
home: 'Home',
welcome: 'Welcome',
userMenu: 'User menu',
profile: 'Profile Settings',
logout: 'Logout',
subtitle: 'Your dashboard and vehicle overview',
switchLanguage: 'Switch language',
close: 'Close',
noCompanies: 'No companies yet',
@@ -145,16 +207,14 @@ export default {
regionDescription: 'Date, time & currency format',
dashboard: 'Dashboard',
menu: 'Menu',
subscription: '💎 Subscription',
adminCenter: '🔐 Admin Center',
},
dashboard: {
loading: 'Loading...',
errorRetry: 'Retry',
statusActive: 'Active',
statusUnknown: 'Unknown',
condition: 'Condition',
mileage: 'km',
subtitle: 'Your dashboard and vehicle overview',
guest: 'Guest',
// Bento Box layout
myVehicles: 'My Vehicles',
@@ -183,6 +243,7 @@ export default {
recordFueling: 'Record Fueling',
parkingToll: 'Parking / Toll',
additionalCosts: 'Additional Costs',
detailedInvoice: '🧾 Detailed Invoice',
findServicePartners: 'Find reliable service partners',
allVehicles: 'All Vehicles',
// ── Dashboard Card 5: Profile & Trust ──
@@ -191,6 +252,9 @@ export default {
notifications: 'Notifications',
settings: 'Settings',
},
costs: {
backToDashboard: 'Back to Dashboard',
},
finance: {
wallet: 'Wallet',
totalCredits: 'Total Credits',
@@ -200,7 +264,6 @@ export default {
voucher: 'Voucher',
topUp: 'Top Up',
live: 'LIVE',
retry: 'Retry',
// WalletDetailsModal keys
walletDetails: 'Wallet Details',
walletBreakdown: 'Balance Breakdown',
@@ -224,6 +287,88 @@ export default {
noCosts: 'No costs found',
costManagerDescription: 'Overview of all costs across your vehicles',
payoutComingSoon: 'Payout feature coming soon!',
// ── Finance Tab (FinancialsTab.vue) ─────────────────────────────────
purchasing_section_title: 'Purchasing & Leasing',
insurance_section_title: 'Insurances & Taxes',
tco_section_title: 'TCO / Expenses',
no_financials: 'No financial data recorded yet.',
add_financials: 'Add Financial Data',
purchase_price_gross: 'Gross Purchase Price',
purchase_price_net: 'Net Purchase Price',
down_payment: 'Down Payment',
monthly_installment: 'Monthly Installment',
contract_number: 'Contract Number',
financing_provider: 'Financing Provider',
interest_rate: 'Interest Rate',
residual_value: 'Residual Value',
contract_start_date: 'Contract Start',
contract_end_date: 'Contract End',
edit_financials: 'Edit Purchasing & Leasing',
no_insurance_taxes: 'No insurances or taxes recorded yet.',
add_insurance: '+ Insurance',
add_tax: '+ Tax',
insurance: 'Insurance',
policy_number: 'Policy Number',
premium: 'Premium',
valid_from: 'Valid From',
valid_until: 'Valid Until',
insurer_name: 'Insurer Name',
policy_type: 'Policy Type',
currency: 'Currency',
tax_type: 'Tax Type',
amount: 'Amount',
due_date: 'Due Date',
paid: 'Paid',
unpaid: 'Unpaid',
tco_placeholder: 'TCO / expense data will appear here.',
add_cost: '+ Cost',
tax_section_title: 'Tax Obligations',
noFinancialData: 'No financial data available',
// ── CostsView Tabbed Dashboard ──
analyticsTab: '📊 Overview',
transactionsTab: '📋 Transactions',
filterByVehicle: 'Filter by Vehicle',
allVehicles: 'All Vehicles (Entire Fleet)',
periodFilter: 'Period',
periodThisYear: 'This Year',
periodLast30Days: 'Last 30 Days',
periodAll: 'All Time',
addCost: '+ Add Cost',
totalExpenses: 'Total Expenses',
totalFiltered: 'Total (Filtered)',
avgPerVehicle: 'Avg. per Vehicle',
monthlyChartPlaceholder: 'Monthly Summary Chart',
categoryChartPlaceholder: 'Category Pie Chart',
},
costWizard: {
title: '🧾 Detailed Invoice',
step1: 'Basic Data',
step2: 'Financials',
step3: 'Admin & Supplier',
step4: 'Files',
step1Desc: 'Vehicle, category & date',
step2Desc: 'Amount, VAT & currency',
step3Desc: 'Supplier, invoice & deadline',
step4Desc: 'Attach documents',
selectVehicle: 'Select Vehicle',
selectCategory: 'Select Category',
selectSubCategory: 'Select Subcategory',
costDate: 'Cost Date',
netAmount: 'Net Amount',
vatRate: 'VAT Rate',
grossAmount: 'Gross Amount',
currency: 'Currency',
vendor: 'Supplier',
vendorPlaceholder: 'Search or type supplier name...',
invoiceNumber: 'Invoice Number',
invoiceDate: 'Invoice Date',
paymentDeadline: 'Payment Deadline',
attachFiles: 'Attach Files',
dragDrop: 'Drag & drop files here, or click to browse',
noFiles: 'No files attached',
fileAttached: 'file(s) attached',
success: 'Invoice saved successfully!',
error: 'Failed to save invoice.',
},
zen: {
hide: 'Hide UI (Zen mode)',
@@ -287,15 +432,12 @@ export default {
restoreCodeSent: 'The restoration code has been sent to your email!',
},
profile: {
title: 'Profile Settings',
loading: 'Loading profile data...',
completion: 'Profile Completion',
accountInfo: 'Account Info',
email: 'Email',
phone: 'Phone Number',
edit: 'Edit',
cancel: 'Cancel',
save: 'Save',
changePassword: 'Change Password',
// Tabs
tabPersonal: 'Personal & Address',
@@ -310,15 +452,7 @@ export default {
mothersFirstName: 'Mother\'s First Name',
// Address
addressTitle: 'Address Data',
zipCode: 'ZIP Code',
city: 'City',
street: 'Street',
streetType: 'Street Type',
houseNumber: 'House Number',
stairwell: 'Stairwell',
floor: 'Floor',
door: 'Door',
parcelId: 'Parcel ID',
hrsz: 'Parcel No.',
selectPlaceholder: 'Select...',
// Identity docs
@@ -376,8 +510,6 @@ export default {
// Last Admin Warning
lastAdminWarningTitle: '⚠️ Last Admin Warning',
lastAdminWarning: 'Warning! You are the last active administrator of your company. Deleting your account will put company data and fleet into passive (orphan) state!',
// Back
backToGarage: 'Back to Garage',
// Diagnostic
xray: '🩻 X-RAY: person (address_* fields)',
},
@@ -389,13 +521,8 @@ export default {
addressTitle: '📍 Address Data',
addressDescription: 'ZIP code and city are required to activate your account.',
softKyc: '(Soft KYC)',
country: 'Country',
zipCode: 'ZIP Code',
city: 'City',
optionalAddress: '📝 Additional Address Details (optional)',
streetName: 'Street Name',
streetType: 'Type',
houseNumber: 'House Number',
// Step 2
extrasTitle: '🔧 Additional Data',
extrasDescription: 'You can provide these details later in your profile.',
@@ -409,7 +536,6 @@ export default {
language: '🌐 Language',
currency: '💰 Currency',
// Navigation
back: 'Back',
next: 'Next',
submitting: 'Processing...',
submit: 'Activate Garage 🚀',
@@ -489,18 +615,8 @@ export default {
// Step 2
addressTitle: '📍 Address & Finance',
addressDescription: 'Company headquarters address and default financial settings.',
country: 'Country',
language: 'Language',
currency: 'Currency',
zipCode: 'ZIP Code',
city: 'City',
streetName: 'Street Name',
streetType: 'Type',
houseNumber: 'House Number',
stairwell: 'Stairwell',
floor: 'Floor',
door: 'Door',
parcelId: 'Parcel ID',
// Step 3
contactTitle: '📞 Contact',
contactDescription: 'Primary contact details (the logged-in user) cannot be modified.',
@@ -508,8 +624,6 @@ export default {
contactEmail: 'Email Address',
contactPhone: 'Phone Number',
// Navigation
cancel: 'Cancel',
back: 'Back',
next: 'Next',
submitting: 'Creating...',
submit: 'Create Company 🚀',
@@ -564,8 +678,6 @@ export default {
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',
@@ -591,13 +703,12 @@ export default {
// ── Vehicle ──────────────────────────────────────────────────────
vehicle: {
add: 'Add New Vehicle',
edit_title: 'Edit Vehicle',
add_title: 'Add New Vehicle',
features_hint: 'Select the vehicle equipment and extra options.',
// ── Vehicle Card ──
noPhoto: 'No photo uploaded',
nextService: 'Next Service',
noData: 'No data',
primaryVehicle: 'Primary vehicle',
setPrimary: 'Set as primary',
// Fuel types
@@ -651,6 +762,7 @@ export default {
placeholder_custom_class: 'e.g. Quad, Segway, Golf Cart',
placeholder_engine_capacity: '1995',
placeholder_power_kw: '150',
placeholder_power_le: '204',
placeholder_torque_nm: '320',
placeholder_type_designation: 'e.g. GTI, M Sport, AMG',
placeholder_curb_weight: '1500',
@@ -664,7 +776,6 @@ export default {
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',
@@ -725,7 +836,7 @@ export default {
label_transmission: 'Transmission',
label_body_type: 'Body Type',
label_engine: 'Engine',
label_vin: 'VIN (Chassis Number)',
vin: 'VIN (Chassis Number)',
label_mileage: 'Current Mileage',
label_fuel_type: 'Fuel Type',
label_trim_level: 'Trim Level',
@@ -740,6 +851,7 @@ export default {
label_ev_section: 'Electric Drive / Battery',
label_engine_capacity: 'Engine Capacity (cm³)',
label_power_kw: 'Power (kW)',
label_power_le: 'Power (HP)',
label_torque_nm: 'Torque (Nm)',
label_curb_weight: 'Curb Weight (kg)',
label_max_weight: 'Max Gross Weight (kg)',
@@ -760,6 +872,13 @@ export default {
warranty_section_title: 'Warranty / Guarantee',
label_is_under_warranty: 'Valid Warranty',
label_warranty_expiry_date: 'Warranty Expiry Date',
// Registration Documents
registration_documents_title: 'Registration Certificate & Vehicle Document',
label_registration_certificate_number: 'Registration Certificate Number',
label_registration_certificate_validity: 'Registration Certificate Validity',
label_vehicle_registration_document_number: 'Vehicle Registration Document Number',
placeholder_registration_certificate_number: 'e.g. AB-123456',
placeholder_vehicle_registration_document_number: 'e.g. 1234567890',
// Tab labels
tab_basic: 'Basic Data',
tab_technical: 'Technical',
@@ -774,7 +893,6 @@ export default {
// Save button
save_changes: 'Save Changes',
add_vehicle: 'Add Vehicle',
saving: 'Saving...',
// Validation
required_fields_hint: 'Please fill in the required fields',
// Condition options
@@ -792,7 +910,7 @@ export default {
},
// Features (extras)
features: {
abs: 'ABS',
abs: 'ABS (Anti-lock Braking System)',
esp: 'ESP (Electronic Stability)',
traction_control: 'Traction Control (TCS)',
hill_assist: 'Hill Start Assist',
@@ -884,7 +1002,7 @@ export default {
airbag_passenger: 'Passenger Airbag',
airbag_side: 'Side Airbag',
airbag_curtain: 'Curtain Airbag',
isofix: 'ISOFIX',
isofix: 'ISOFIX Child Seat Anchors',
tpms: 'Tire Pressure Monitoring System',
electric_seat_adjust: 'Electric Seat Adjustment',
heated_steering_wheel: 'Heated Steering Wheel',
@@ -915,7 +1033,7 @@ export default {
smoking_package: 'Smoking Package',
pet_friendly: 'Pet Friendly',
child_lock: 'Child Lock',
anti_theft: 'Anti-Theft',
alarm: 'Alarm System',
gps_tracker: 'GPS Tracker',
service_history: 'Service History',
},
@@ -952,7 +1070,7 @@ export default {
multimedia: 'Multimedia',
other: 'Other',
},
// Motorcycle features (extras)
// Motorcycle features (extras) — only strictly unique items remain
motorcycleFeatures: {
// Technical
dual_front_disc: 'Dual Front Disc Brake',
@@ -967,23 +1085,15 @@ export default {
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',
@@ -995,7 +1105,6 @@ export default {
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)',
@@ -1018,15 +1127,12 @@ export default {
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 ──
@@ -1058,46 +1164,21 @@ export default {
exterior: 'Exterior',
multimedia: 'Multimedia',
},
// LCV features (extras)
// LCV features (extras) — only strictly unique items remain
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',
@@ -1207,7 +1288,7 @@ export default {
cabin: 'Cabin',
multimedia: 'Multimedia',
},
// HGV features (extras)
// HGV features (extras) — only strictly unique items remain
hgvFeatures: {
// Technical / Work
diff_lock_front: 'Front Differential Lock',
@@ -1220,23 +1301,16 @@ export default {
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',
@@ -1244,7 +1318,6 @@ export default {
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',
@@ -1281,24 +1354,28 @@ export default {
armrest: 'Armrest Seat',
storage_box: 'Storage Box',
wardrobe: 'Wardrobe',
central_locking: 'Central Locking',
adjustable_steering: 'Adjustable Steering',
// 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',
},
// ── Admin Fields (VehicleFormModal) ────────────────────────────────
admin_section_title: 'Administrative Data',
label_registration_country: 'Registration Country',
placeholder_registration_country: 'e.g. Hungary',
label_first_domestic_registration_date: 'First Domestic Registration',
label_import_country: 'Import Country',
placeholder_import_country: 'e.g. Germany',
label_title_document_number: 'Title Document Number',
placeholder_title_document_number: 'e.g. TL-123456',
label_engine_number: 'Engine Number',
placeholder_engine_number: 'e.g. M123456789',
label_number_of_previous_owners: 'Number of Previous Owners',
placeholder_number_of_previous_owners: 'e.g. 2',
},
// ── Service Finder ────────────────────────────────────────────────
@@ -1411,7 +1488,6 @@ export default {
tabServices: 'Services',
addressSection: 'Address Details',
contactSection: 'Contact Details',
streetNameLabel: 'Street Name',
streetNameOptional: 'optional',
streetNamePlaceholder: 'e.g. Egressy',
streetTypeLabel: 'Street Type',
@@ -1490,8 +1566,6 @@ export default {
field_description: 'Description',
field_description_placeholder: 'Describe what this service does...',
field_credit_cost: 'Credit Cost',
cancel: 'Cancel',
saving: 'Saving...',
save_changes: 'Save Changes',
create: 'Create Service',
},
@@ -1538,8 +1612,6 @@ export default {
date_until_label: 'Date Until',
field_entitlements: 'Services (Entitlements)',
entitlement_placeholder: 'Select a service to add...',
cancel: 'Cancel',
saving: 'Saving...',
save_changes: 'Save Changes',
create: 'Create Package',
},
@@ -1556,7 +1628,6 @@ export default {
clear_search: 'Clear search',
filter_all_roles: 'All Roles',
filter_all_status: 'All Status',
status_active: 'Active',
status_inactive: 'Inactive',
status_deleted: 'Deleted',
refresh: 'Refresh',

View File

@@ -13,6 +13,33 @@ export default {
fuel: 'Üzemanyag',
mileage: 'Futásteljesítmény',
year: 'Évjárat',
noPlate: 'Nincs rendszám',
nextService: 'Következő Szerviz',
noData: 'Nincs adat',
backToGarage: 'Vissza a Garázsba',
statusActive: 'Aktív',
country: 'Ország',
zipCode: 'Irányítószám',
city: 'Város',
houseNumber: 'Házszám',
stairwell: 'Lépcsőház',
floor: 'Emelet',
door: 'Ajtó',
parcelId: 'Helyrajzi szám',
streetName: 'Utca neve',
dashboardSubtitle: 'Irányítópultod és járműveid áttekintése',
profileSettings: 'Profil beállítások',
streetType: 'Közterület jellege',
email: 'E-mail cím',
phone: 'Telefonszám',
na: 'N/A',
edit: 'Szerkesztés',
delete: 'Törlés',
close: 'Bezárás',
next: 'Következő →',
prev: '← Előző',
actions: 'Műveletek',
items: 'db',
},
subscription: {
title: 'Előfizetési Csomagok',
@@ -45,6 +72,15 @@ export default {
prioritySupport: 'Prioritásos támogatás',
adFree: 'Reklámmentes',
},
// P0: Subscription Info Modal
subscriptionInfo: 'Előfizetés Információ',
active: 'Aktív',
expiresAt: 'Lejárat dátuma',
daysLeftShort: 'nap',
vehicleLimit: 'Jármű Limit',
garageLimit: 'Garázs Limit',
managePlan: 'Csomag Kezelése',
indefinite: 'Határozatlan',
},
landing: {
openGarage: 'Garázs Nyitása',
@@ -75,22 +111,17 @@ export default {
title: 'Garázsom',
subtitle: '{count} jármű kezelése',
backToDashboard: 'Vissza az irányítópultra',
backToGarage: 'Vissza a Garázsba',
noPlate: 'Nincs rendszám',
primary: 'Elsődleges',
brandModel: 'Márka / Modell',
year: 'Évjárat',
nextService: 'Következő Szerviz',
mileage: 'Futásteljesítmény',
emptyTitle: 'A garázsod üres',
emptySubtitle: 'Add hozzá az első járművedet!',
addVehicle: 'Jármű hozzáadása',
},
vehicleDetail: {
title: 'Jármű Részletek',
placeholderTitle: 'Részletes nézet hamarosan',
placeholderSubtitle: 'Ez az oldal hamarosan teljes tabokkal ellátott részletes járműinformációkat, szerviztörténetet, költségeket és egyebeket tartalmaz.',
notFound: 'A jármű nem található.',
noPlate: 'Nincs rendszám',
tabOverview: 'Áttekintés',
tabTechData: 'Műszaki adatok',
tabServiceBook: 'Szervizkönyv',
@@ -101,21 +132,52 @@ export default {
placeholderServiceBook: 'A szerviztörténet itt jelenik meg.',
placeholderFinancials: 'A pénzügyi adatok itt jelennek meg.',
placeholderDocuments: 'A dokumentumok itt jelennek meg.',
noPhoto: 'Nincs fotó',
photoPlaceholder: 'Fotó feltöltés hamarosan',
vin: 'Alvázszám (VIN)',
fuelType: 'Üzemanyag',
mileage: 'Futásteljesítmény',
motExpiry: 'Műszaki érvényesség',
nextService: 'Következő Szerviz',
noData: 'Nincs adat',
comingSoon: 'Hamarosan',
monthlyCost: 'Havi költség',
monthlyMileage: 'Havi futásteljesítmény',
recentExpenses: 'Legutóbbi kiadások',
noExpenses: 'Nincs még rögzített kiadás',
deadlines: 'Határidők',
// TechDataTab section titles
sectionBasicSpecs: 'Alapadatok',
sectionEngineDrivetrain: 'Motor és hajtás',
sectionBodyDimensions: 'Karosszéria és méretek',
sectionEvSpecs: 'Elektromos hajtás',
sectionFeatures: 'Felszereltség / Extrák',
// TechDataTab field labels
vehicleClass: 'Kategória',
trimLevel: 'Felszereltség',
transmission: 'Váltó',
driveType: 'Meghajtás',
bodyType: 'Karosszéria',
doors: 'Ajtók',
seats: 'Ülések',
cylinderLayout: 'Hengerelrendezés',
acType: 'Légkondícionáló',
batteryCapacity: 'Akkumulátor (kWh)',
range: 'Hatótáv (km)',
acConnector: 'AC csatlakozó',
dcConnector: 'DC csatlakozó',
engineCapacity: 'Lökettérfogat (cm³)',
powerKw: 'Teljesítmény (kW)',
powerUnitLabel: 'LE/KW',
powerLe: 'Teljesítmény (LE)',
torqueNm: 'Nyomaték (Nm)',
euroClass: 'Euro norma',
curbWeight: 'Saját tömeg (kg)',
maxWeight: 'Össztömeg (kg)',
mileage: 'Futásteljesítmény (km)',
conditionScore: 'Állapot pontszám',
},
header: {
home: 'Kezdőlap',
welcome: 'Üdvözlünk',
userMenu: 'Felhasználói menü',
profile: 'Profil beállítások',
logout: 'Kilépés',
subtitle: 'Irányítópultod és járműveid áttekintése',
switchLanguage: 'Nyelv váltása',
close: 'Bezárás',
noCompanies: 'Még nincs céged',
@@ -142,16 +204,14 @@ export default {
regionDescription: 'Dátum, idő és pénznem formátum',
dashboard: 'Irányítópult',
menu: 'Menü',
subscription: '💎 Előfizetés',
adminCenter: '🔐 Adminisztrációs Központ',
},
dashboard: {
loading: 'Betöltés...',
errorRetry: 'Újrapróbálkozás',
statusActive: 'Aktív',
statusUnknown: 'Ismeretlen',
condition: 'Állapot',
mileage: 'km',
subtitle: 'Irányítópultod és járműveid áttekintése',
guest: 'Vendég',
// Bento Box layout
myVehicles: 'Saját Járművek',
@@ -180,6 +240,7 @@ export default {
recordFueling: 'Tankolás rögzítése',
parkingToll: 'Parkolás / Útdíj',
additionalCosts: 'További költségek',
detailedInvoice: '🧾 Részletes Számla',
findServicePartners: 'Keress megbízható szervizpartnereket',
allVehicles: 'Összes jármű',
// ── Dashboard Card 5: Profile & Trust ──
@@ -188,6 +249,9 @@ export default {
notifications: 'Értesítések',
settings: 'Beállítások',
},
costs: {
backToDashboard: 'Vissza az irányítópultra',
},
finance: {
wallet: 'Pénztárca',
totalCredits: 'Összes Kredit',
@@ -197,7 +261,6 @@ export default {
voucher: 'Voucher',
topUp: 'Egyenleg Feltöltése',
live: 'ÉLŐ',
retry: 'Újra',
// WalletDetailsModal keys
walletDetails: 'Pénztárca Részletek',
walletBreakdown: 'Egyenleg Bontás',
@@ -221,6 +284,88 @@ export default {
noCosts: 'Nincsenek költségek',
costManagerDescription: 'Az összes járműhöz tartozó költségek áttekintése',
payoutComingSoon: 'Kifizetési funkció hamarosan!',
// ── Finance Tab (FinancialsTab.vue) ─────────────────────────────────
purchasing_section_title: 'Beszerzés & Lízing',
insurance_section_title: 'Biztosítások & Adók',
tco_section_title: 'TCO / Költségek',
no_financials: 'Még nincsenek pénzügyi adatok rögzítve.',
add_financials: 'Pénzügyi adatok hozzáadása',
purchase_price_gross: 'Bruttó vételár',
purchase_price_net: 'Nettó vételár',
down_payment: 'Önerő',
monthly_installment: 'Havi törlesztő',
contract_number: 'Szerződésszám',
financing_provider: 'Finanszírozó',
interest_rate: 'Kamat',
residual_value: 'Maradványérték',
contract_start_date: 'Szerződés kezdete',
contract_end_date: 'Szerződés vége',
edit_financials: 'Beszerzés & Lízing adatok',
no_insurance_taxes: 'Még nincsenek biztosítások vagy adók rögzítve.',
add_insurance: '+ Biztosítás',
add_tax: '+ Adó',
insurance: 'Biztosítás',
policy_number: 'Kötvényszám',
premium: 'Díj',
valid_from: 'Érvényesség kezdete',
valid_until: 'Érvényesség vége',
insurer_name: 'Biztosító neve',
policy_type: 'Biztosítás típusa',
currency: 'Pénznem',
tax_type: 'Adó típusa',
amount: 'Összeg',
due_date: 'Esedékesség',
paid: 'Fizetve',
unpaid: 'Fizetendő',
tco_placeholder: 'A TCO / költségadatok itt jelennek meg.',
add_cost: '+ Költség',
tax_section_title: 'Adókötelezettségek',
noFinancialData: 'Nincsenek pénzügyi adatok',
// ── CostsView Tabbed Dashboard ──
analyticsTab: '📊 Áttekintés',
transactionsTab: '📋 Tranzakciók',
filterByVehicle: 'Jármű szűrő',
allVehicles: 'Teljes Flotta (Összes jármű)',
periodFilter: 'Időszak',
periodThisYear: 'Idei Év',
periodLast30Days: 'Elmúlt 30 nap',
periodAll: 'Összes',
addCost: ' Új Költség',
totalExpenses: 'Összes Költség',
totalFiltered: 'Összesen (Szűrt)',
avgPerVehicle: 'Átlag / Jármű',
monthlyChartPlaceholder: 'Havi Összesítő Chart',
categoryChartPlaceholder: 'Kategória Kördiagram',
},
costWizard: {
title: '🧾 Részletes Számla',
step1: 'Alapadatok',
step2: 'Pénzügyek',
step3: 'Admin & Szállító',
step4: 'Fájlok',
step1Desc: 'Jármű, kategória & dátum',
step2Desc: 'Összeg, ÁFA & pénznem',
step3Desc: 'Szállító, számla & határidő',
step4Desc: 'Dokumentumok csatolása',
selectVehicle: 'Jármű kiválasztása',
selectCategory: 'Kategória kiválasztása',
selectSubCategory: 'Alkategória kiválasztása',
costDate: 'Költség dátuma',
netAmount: 'Nettó összeg',
vatRate: 'ÁFA kulcs',
grossAmount: 'Bruttó összeg',
currency: 'Pénznem',
vendor: 'Szállító',
vendorPlaceholder: 'Keresés vagy szállító nevének beírása...',
invoiceNumber: 'Számlaszám',
invoiceDate: 'Számla dátuma',
paymentDeadline: 'Fizetési határidő',
attachFiles: 'Fájlok csatolása',
dragDrop: 'Húzd ide a fájlokat, vagy kattints a tallózáshoz',
noFiles: 'Nincs fájl csatolva',
fileAttached: 'fájl csatolva',
success: 'Számla sikeresen elmentve!',
error: 'Nem sikerült a számla mentése.',
},
zen: {
hide: 'UI elrejtése (Zen mód)',
@@ -284,15 +429,12 @@ export default {
restoreCodeSent: 'A visszaállítási kódot elküldtük az e-mail címedre!',
},
profile: {
title: 'Profil beállítások',
loading: 'Profil adatok betöltése...',
completion: 'Profil kitöltöttsége',
accountInfo: 'Fiók adatok',
email: 'Email',
phone: 'Telefonszám',
edit: 'Szerkesztés',
cancel: 'Mégse',
save: 'Mentés',
changePassword: 'Jelszó módosítása',
// Tabs
tabPersonal: 'Személyes és Lakcím',
@@ -307,15 +449,7 @@ export default {
mothersFirstName: 'Anyja keresztneve',
// Address
addressTitle: 'Lakcím adatok',
zipCode: 'Irányítószám',
city: 'Város',
street: 'Utca',
streetType: 'Közterület jellege',
houseNumber: 'Házszám',
stairwell: 'Lépcsőház',
floor: 'Emelet',
door: 'Ajtó',
parcelId: 'Helyrajzi szám',
hrsz: 'Hrsz.',
selectPlaceholder: 'Válassz...',
// Identity docs
@@ -373,8 +507,6 @@ export default {
// Last Admin Warning
lastAdminWarningTitle: '⚠️ Utolsó adminisztrátor figyelmeztetés',
lastAdminWarning: 'Figyelem! Te vagy a céged utolsó aktív adminisztrátora. A fiókod törlésével a cég adatai és flottája passzív (árva) állapotba kerül!',
// Back
backToGarage: 'Vissza a Garázsba',
// Diagnostic
xray: '🩻 RÖNTGEN: person (address_* fields)',
},
@@ -386,13 +518,8 @@ export default {
addressTitle: '📍 Lakcím adatok',
addressDescription: 'Az irányítószám és város kötelező a fiókod aktiválásához.',
softKyc: '(Soft KYC)',
country: 'Ország',
zipCode: 'Irányítószám',
city: 'Város',
optionalAddress: '📝 További címrészletek (opcionális)',
streetName: 'Utca neve',
streetType: 'Típus',
houseNumber: 'Házszám',
// Step 2
extrasTitle: '🔧 Kiegészítő adatok',
extrasDescription: 'Ezeket az adatokat később is megadhatod a profilodban.',
@@ -406,7 +533,6 @@ export default {
language: '🌐 Nyelv',
currency: '💰 Pénznem',
// Navigation
back: 'Vissza',
next: 'Tovább',
submitting: 'Feldolgozás...',
submit: 'Garázs Aktiválása 🚀',
@@ -444,7 +570,6 @@ export default {
// Dashboard cards
fleetTitle: 'Flotta áttekintés',
fleetVehicles: 'Kezelt járművek: {count}',
fleetActive: 'Aktív',
fleetInactive: 'Inaktív',
gamificationTitle: 'Gamifikáció',
gamificationScore: 'Havi Pontszám: {score}',
@@ -486,18 +611,8 @@ export default {
// Step 2
addressTitle: '📍 Székhely és pénzügy',
addressDescription: 'A cég székhelyének címe és az alapértelmezett pénzügyi beállítások.',
country: 'Ország',
language: 'Nyelv',
currency: 'Pénznem',
zipCode: 'Irányítószám',
city: 'Város',
streetName: 'Utca neve',
streetType: 'Típus',
houseNumber: 'Házszám',
stairwell: 'Lépcsőház',
floor: 'Emelet',
door: 'Ajtó',
parcelId: 'Helyrajzi szám',
// Step 3
contactTitle: '📞 Kapcsolat',
contactDescription: 'Az elsődleges kapcsolattartó adatai (a bejelentkezett felhasználó) nem módosíthatók.',
@@ -505,8 +620,6 @@ export default {
contactEmail: 'E-mail cím',
contactPhone: 'Telefonszám',
// Navigation
cancel: 'Mégsem',
back: 'Vissza',
next: 'Tovább',
submitting: 'Létrehozás...',
submit: 'Cég Létrehozása 🚀',
@@ -561,8 +674,6 @@ export default {
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',
@@ -588,13 +699,12 @@ export default {
// ── Vehicle / Jármű ─────────────────────────────────────────────
vehicle: {
add: 'Új jármű hozzáadása',
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.',
// ── Vehicle Card ──
noPhoto: 'Nincs feltöltött fotó',
nextService: 'Következő Szerviz',
noData: 'Nincs adat',
primaryVehicle: 'Elsődleges jármű',
setPrimary: 'Beállítás elsődlegessé',
// Fuel types
@@ -648,6 +758,7 @@ export default {
placeholder_custom_class: 'Pl. Quad, Segway, Golfkocsi',
placeholder_engine_capacity: '1995',
placeholder_power_kw: '150',
placeholder_power_le: '204',
placeholder_torque_nm: '320',
placeholder_type_designation: 'Pl. GTI, M Sport, AMG',
placeholder_curb_weight: '1500',
@@ -661,7 +772,6 @@ export default {
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',
@@ -722,7 +832,7 @@ export default {
label_transmission: 'Váltó',
label_body_type: 'Karosszéria típus',
label_engine: 'Motor',
label_vin: 'Alvázszám (VIN)',
vin: 'Alvázszám (VIN)',
label_mileage: 'Jelenlegi km óra állás',
label_fuel_type: 'Üzemanyag',
label_trim_level: 'Felszereltség',
@@ -737,6 +847,7 @@ export default {
label_ev_section: 'Elektromos Hajtás / Akkumulátor',
label_engine_capacity: 'Hengerűrtartalom (cm³)',
label_power_kw: 'Teljesítmény (kW)',
label_power_le: 'Teljesítmény (LE)',
label_torque_nm: 'Nyomaték (Nm)',
label_curb_weight: 'Saját tömeg (kg)',
label_max_weight: 'Össztömeg (kg)',
@@ -757,6 +868,13 @@ export default {
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',
// Registration Documents
registration_documents_title: 'Forgalmi engedély és Törzskönyv',
label_registration_certificate_number: 'Forgalmi engedély száma',
label_registration_certificate_validity: 'Forgalmi engedély érvényessége',
label_vehicle_registration_document_number: 'Törzskönyv száma',
placeholder_registration_certificate_number: 'Pl. AB-123456',
placeholder_vehicle_registration_document_number: 'Pl. 1234567890',
// Tab labels
tab_basic: 'Alapadatok',
tab_technical: 'Műszaki',
@@ -771,7 +889,6 @@ export default {
// 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
@@ -789,7 +906,7 @@ export default {
},
// Features (extras)
features: {
abs: 'ABS',
abs: 'Blokkolásgátló (ABS)',
esp: 'ESP (Menetstabilizáló)',
traction_control: 'Kipörgésgátló (TCS)',
hill_assist: 'Hegymeneti tartó',
@@ -829,7 +946,7 @@ export default {
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)',
tow_bar: 'Vontatóhorog',
tinted_windows: 'Sötétített üvegek',
privacy_glass: 'Privacy Glass',
led_headlights: 'LED fényszóró',
@@ -881,7 +998,7 @@ export default {
airbag_passenger: 'Utas légzsák',
airbag_side: 'Oldallégzsák',
airbag_curtain: 'Függönylégzsák',
isofix: 'Isofix',
isofix: 'Isofix gyerekülés rögzítés',
tpms: 'Guminyomás monitor',
electric_seat_adjust: 'Elektromos ülésállítás',
heated_steering_wheel: 'Fűtött kormány',
@@ -892,9 +1009,9 @@ export default {
garage_kept: 'Garázsban tartott',
smoke_free: 'Nem dohányzó',
pet_free: 'Háziállat mentes',
service_book: 'Szervizkönyvvel',
service_book: 'Szervizkönyv',
original_mileage: 'Igazolt futásteljesítmény',
first_owner: 'Első tulajdonos',
first_owner: 'Első tulajdonostól',
fleet_vehicle: 'Flottás jármű',
imported: 'Importált',
accident_free: 'Balesetmentes',
@@ -912,8 +1029,8 @@ export default {
smoking_package: 'Dohányzó csomag',
pet_friendly: 'Kisállat barát',
child_lock: 'Gyerekzár',
anti_theft: 'Riasztó',
gps_tracker: 'GPS követő',
alarm: 'Riasztó',
gps_tracker: 'GPS nyomkövető',
service_history: 'Szerviztörténet',
},
// ── Motorcycle-specific labels ──
@@ -949,7 +1066,7 @@ export default {
multimedia: 'Multimédia',
other: 'Egyéb',
},
// Motorcycle features (extras)
// Motorcycle features (extras) — only strictly unique items remain
motorcycleFeatures: {
// Technical
dual_front_disc: 'Dupla tárcsafék elöl',
@@ -964,23 +1081,15 @@ export default {
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ó',
@@ -992,7 +1101,6 @@ export default {
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',
@@ -1015,15 +1123,12 @@ export default {
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 ──
@@ -1055,46 +1160,21 @@ export default {
exterior: 'Külső',
multimedia: 'Multimédia',
},
// LCV features (extras)
// LCV features (extras) — only strictly unique items remain
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ó',
@@ -1204,7 +1284,7 @@ export default {
cabin: 'Fülke',
multimedia: 'Multimédia',
},
// HGV features (extras)
// HGV features (extras) — only strictly unique items remain
hgvFeatures: {
// Technical / Work
diff_lock_front: 'Első differenciálzár',
@@ -1217,23 +1297,16 @@ export default {
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',
@@ -1241,7 +1314,6 @@ export default {
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',
@@ -1278,24 +1350,28 @@ export default {
armrest: 'Karfás ülés',
storage_box: 'Tároló rekesz',
wardrobe: 'Gardrób',
central_locking: 'Központi zár',
adjustable_steering: 'Állítható kormány',
// 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',
},
// ── Admin Fields (VehicleFormModal) ────────────────────────────────
admin_section_title: 'Adminisztratív adatok',
label_registration_country: 'Regisztráció országa',
placeholder_registration_country: 'Pl. Magyarország',
label_first_domestic_registration_date: 'Első belföldi forgalomba helyezés',
label_import_country: 'Import ország',
placeholder_import_country: 'Pl. Németország',
label_title_document_number: 'Törzskönyv / Tulajdoni lap száma',
placeholder_title_document_number: 'Pl. TL-123456',
label_engine_number: 'Motorszám',
placeholder_engine_number: 'Pl. M123456789',
label_number_of_previous_owners: 'Előző tulajdonosok száma',
placeholder_number_of_previous_owners: 'Pl. 2',
},
// ── Service Finder ────────────────────────────────────────────────
@@ -1408,7 +1484,6 @@ export default {
tabServices: 'Szolgáltatások',
addressSection: 'Cím adatok',
contactSection: 'Elérhetőségek',
streetNameLabel: 'Utca neve',
streetNameOptional: 'opcionális',
streetNamePlaceholder: 'Pl. Egressy',
streetTypeLabel: 'Közterület jellege',
@@ -1488,7 +1563,6 @@ export default {
field_description_placeholder: 'Írd le, mit nyújt ez a szolgáltatás...',
field_credit_cost: 'Kredit Ár',
cancel: 'Mégsem',
saving: 'Mentés...',
save_changes: 'Változások Mentése',
create: 'Szolgáltatás Létrehozása',
},
@@ -1536,7 +1610,6 @@ export default {
field_entitlements: 'Szolgáltatások (Jogosultságok)',
entitlement_placeholder: 'Válassz egy szolgáltatást...',
cancel: 'Mégsem',
saving: 'Mentés...',
save_changes: 'Változások Mentése',
create: 'Csomag Létrehozása',
},
@@ -1553,7 +1626,6 @@ export default {
clear_search: 'Keresés törlése',
filter_all_roles: 'Összes szerepkör',
filter_all_status: 'Összes állapot',
status_active: 'Aktív',
status_inactive: 'Inaktív',
status_deleted: 'Törölt',
refresh: 'Frissítés',

View File

@@ -147,10 +147,13 @@ const isHamburgerOpen = ref(false)
function navigateToCard(cardId: string) {
isHamburgerOpen.value = false
// 'vehicles' navigates to the full FleetView page
// 'costs' and 'service' navigate to dashboard where their cards are already visible inline
// 'costs' navigates to the full CostsView page
// 'service' navigates to dashboard where its card is already visible inline
if (cardId === 'vehicles') {
router.push('/dashboard/vehicles')
} else if (cardId === 'costs' || cardId === 'service') {
} else if (cardId === 'costs') {
router.push('/dashboard/costs')
} else if (cardId === 'service') {
router.push('/dashboard')
} else {
router.push({ path: '/dashboard', query: { card: cardId } })

View File

@@ -34,6 +34,11 @@ const router = createRouter({
name: 'FleetView',
component: () => import('../views/vehicles/FleetView.vue')
},
{
path: 'costs',
name: 'CostsView',
component: () => import('../views/costs/CostsView.vue')
},
{
path: 'vehicles/:id',
name: 'VehicleDetails',
@@ -42,7 +47,7 @@ const router = createRouter({
]
},
{
path: '/organization/:id',
path: '/organization/:org_id',
component: () => import('../layouts/OrganizationLayout.vue'),
meta: { requiresAuth: true },
children: [
@@ -62,7 +67,7 @@ const router = createRouter({
component: () => import('../views/vehicles/FleetView.vue')
},
{
path: 'vehicles/:id',
path: 'vehicles/:vehicle_id',
name: 'OrgVehicleDetails',
component: () => import('../views/vehicles/VehicleDetailsView.vue')
}

View File

@@ -60,6 +60,10 @@ export interface UserProfile {
role: string
region_code: string
subscription_plan: string
subscription_expires_at?: string | null
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
max_vehicles?: number
max_garages?: number
scope_level: string
scope_id: string | null
ui_mode: string

View File

@@ -0,0 +1,149 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api/axios'
/**
* Expense item shape returned by GET /expenses/{asset_id}
*/
export interface ExpenseItem {
id: string
asset_id: string
organization_id: number
category_id: number
category_code: string | null
category_name: string | null
amount_gross: string | null
amount_net: string | null
vat_rate: string | null
currency: string
date: string | null
status: string
description: string | null
mileage_at_cost: number | null
invoice_number: string | null
linked_asset_event_id: string | null
}
/**
* Paginated response from GET /expenses/{asset_id}
*/
interface ExpenseListResponse {
data: ExpenseItem[]
total: number
limit: number
offset: number
}
export const useExpenseStore = defineStore('expense', () => {
// ── State ──────────────────────────────────────────────────────────
/** Map of asset_id → ExpenseItem[] for quick lookup */
const expensesByAsset = ref<Record<string, ExpenseItem[]>>({})
const isLoading = ref(false)
const error = ref<string | null>(null)
// ── Getters ────────────────────────────────────────────────────────
/**
* Get expenses for a specific asset, sorted by date descending.
*/
function getExpensesForAsset(assetId: string): ExpenseItem[] {
return expensesByAsset.value[assetId] || []
}
/**
* Get the latest N expenses for a specific asset.
*/
function getRecentExpenses(assetId: string, count: number = 3): ExpenseItem[] {
const expenses = getExpensesForAsset(assetId)
return expenses.slice(0, count)
}
/**
* Calculate the total gross amount for the current month for a specific asset.
*/
function getMonthlyCost(assetId: string): number {
const expenses = getExpensesForAsset(assetId)
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth()
return expenses
.filter(e => {
if (!e.date) return false
const d = new Date(e.date)
return d.getFullYear() === currentYear && d.getMonth() === currentMonth
})
.reduce((sum, e) => {
const gross = e.amount_gross ? parseFloat(e.amount_gross) : 0
return sum + gross
}, 0)
}
// ── Actions ────────────────────────────────────────────────────────
/**
* Fetch expenses for a specific asset from the backend.
* GET /expenses/{asset_id}
*/
async function fetchExpensesByAsset(assetId: string, limit: number = 50): Promise<ExpenseItem[]> {
// If we already have data, return cached
if (expensesByAsset.value[assetId]?.length) {
return expensesByAsset.value[assetId]
}
isLoading.value = true
error.value = null
try {
const res = await api.get<ExpenseListResponse>(`/expenses/${assetId}`, {
params: { limit },
})
const data = res.data
expensesByAsset.value[assetId] = data.data || []
return expensesByAsset.value[assetId]
} 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('[ExpenseStore] fetchExpensesByAsset error:', err)
return []
} finally {
isLoading.value = false
}
}
/**
* Force-refresh expenses for a specific asset (bypass cache).
*/
async function refreshExpensesByAsset(assetId: string, limit: number = 50): Promise<ExpenseItem[]> {
// Clear cache first
delete expensesByAsset.value[assetId]
return fetchExpensesByAsset(assetId, limit)
}
/**
* Clear all cached expense data.
*/
function clearCache() {
expensesByAsset.value = {}
}
return {
// State
expensesByAsset,
isLoading,
error,
// Getters
getExpensesForAsset,
getRecentExpenses,
getMonthlyCost,
// Actions
fetchExpensesByAsset,
refreshExpensesByAsset,
clearCache,
}
})

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api/axios'
import type { AssetFinancials, VehicleInsurancePolicy, VehicleTaxObligation } from '../types/vehicle'
/**
* Vehicle data shape returned by GET /assets/vehicles
@@ -23,8 +24,15 @@ export interface Vehicle {
fuel_type: string | null
engine_capacity: number | null
power_kw: number | null
torque_nm: number | null
cylinder_layout: string | null
transmission_type: string | null
drive_type: string | null
euro_classification: string | null
curb_weight: number | null
max_weight: number | null
door_count: number | null
seat_count: number | null
// Status
current_mileage: number
@@ -41,6 +49,19 @@ export interface Vehicle {
is_under_warranty: boolean
warranty_expiry_date: string | null
// Registration Documents
registration_certificate_number?: string | null
registration_certificate_validity?: string | null
vehicle_registration_document_number?: string | null
// ── New Admin Fields ────────────────────────────────────────────────
registration_country?: string | null
first_domestic_registration_date?: string | null
import_country?: string | null
title_document_number?: string | null
engine_number?: string | null
number_of_previous_owners?: number | null
// Sales
is_for_sale: boolean
price: number | null
@@ -53,6 +74,11 @@ export interface Vehicle {
current_organization_id: number | null
owner_organization_id: number | null
// ── Finance / Leasing ───────────────────────────────────────────────
asset_financials?: AssetFinancials | null
insurance_policies?: VehicleInsurancePolicy[] | null
tax_obligations?: VehicleTaxObligation[] | null
// JSONB individual_equipment from backend (car_specs, ev_specs, dates, etc.)
individual_equipment?: {
car_specs?: {
@@ -283,6 +309,120 @@ export const useVehicleStore = defineStore('vehicle', () => {
}
}
// ── Finance Query Methods ──────────────────────────────────────────
/**
* Fetch asset financials for a vehicle.
* GET /assets/vehicles/{id}/financials
*/
async function fetchAssetFinancials(assetId: string): Promise<AssetFinancials | null> {
try {
const res = await api.get(`/assets/vehicles/${assetId}/financials`)
return res.data as AssetFinancials
} catch (err: any) {
console.error('[VehicleStore] fetchAssetFinancials error:', err)
return null
}
}
/**
* Save asset financials for a vehicle (create or update).
* PUT /assets/vehicles/{id}/financials
*/
async function saveAssetFinancials(assetId: string, data: Partial<AssetFinancials>): Promise<AssetFinancials | null> {
try {
const res = await api.put(`/assets/vehicles/${assetId}/financials`, data)
return res.data as AssetFinancials
} catch (err: any) {
console.error('[VehicleStore] saveAssetFinancials error:', err)
throw err
}
}
/**
* Fetch insurance policies for a vehicle.
* GET /assets/vehicles/{id}/insurance
*/
async function fetchInsurancePolicies(assetId: string): Promise<VehicleInsurancePolicy[]> {
try {
const res = await api.get(`/assets/vehicles/${assetId}/insurance`)
return res.data as VehicleInsurancePolicy[]
} catch (err: any) {
console.error('[VehicleStore] fetchInsurancePolicies error:', err)
return []
}
}
/**
* Create a new insurance policy for a vehicle.
* POST /assets/vehicles/{id}/insurance
*/
async function createInsurancePolicy(assetId: string, data: Partial<VehicleInsurancePolicy>): Promise<VehicleInsurancePolicy | null> {
try {
const res = await api.post(`/assets/vehicles/${assetId}/insurance`, data)
return res.data as VehicleInsurancePolicy
} catch (err: any) {
console.error('[VehicleStore] createInsurancePolicy error:', err)
throw err
}
}
/**
* Delete an insurance policy.
* DELETE /assets/vehicles/{assetId}/insurance/{policyId}
*/
async function deleteInsurancePolicy(assetId: string, policyId: number): Promise<boolean> {
try {
await api.delete(`/assets/vehicles/${assetId}/insurance/${policyId}`)
return true
} catch (err: any) {
console.error('[VehicleStore] deleteInsurancePolicy error:', err)
return false
}
}
/**
* Fetch tax obligations for a vehicle.
* GET /assets/vehicles/{id}/taxes
*/
async function fetchTaxObligations(assetId: string): Promise<VehicleTaxObligation[]> {
try {
const res = await api.get(`/assets/vehicles/${assetId}/taxes`)
return res.data as VehicleTaxObligation[]
} catch (err: any) {
console.error('[VehicleStore] fetchTaxObligations error:', err)
return []
}
}
/**
* Create a new tax obligation for a vehicle.
* POST /assets/vehicles/{id}/taxes
*/
async function createTaxObligation(assetId: string, data: Partial<VehicleTaxObligation>): Promise<VehicleTaxObligation | null> {
try {
const res = await api.post(`/assets/vehicles/${assetId}/taxes`, data)
return res.data as VehicleTaxObligation
} catch (err: any) {
console.error('[VehicleStore] createTaxObligation error:', err)
throw err
}
}
/**
* Delete a tax obligation.
* DELETE /assets/vehicles/{assetId}/taxes/{taxId}
*/
async function deleteTaxObligation(assetId: string, taxId: number): Promise<boolean> {
try {
await api.delete(`/assets/vehicles/${assetId}/taxes/${taxId}`)
return true
} catch (err: any) {
console.error('[VehicleStore] deleteTaxObligation error:', err)
return false
}
}
return {
// State
vehicles,
@@ -299,5 +439,15 @@ export const useVehicleStore = defineStore('vehicle', () => {
updateVehicle,
setPrimaryVehicle,
archiveVehicle,
// Finance Actions
fetchAssetFinancials,
saveAssetFinancials,
fetchInsurancePolicies,
createInsurancePolicy,
deleteInsurancePolicy,
fetchTaxObligations,
createTaxObligation,
deleteTaxObligation,
}
})

View File

@@ -13,8 +13,13 @@ export interface OrganizationItem {
is_active: boolean
is_deleted: boolean
subscription_plan: string
/** ISO datetime string of when the subscription expires */
subscription_expires_at?: string | null
/** P0 Feature: FK to system.subscription_tiers — the assigned subscription package */
subscription_tier_id?: number | null
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
max_vehicles?: number
max_garages?: number
/** org_type is not in the current /my response but we keep it for future use */
org_type?: string
/**

View File

@@ -23,6 +23,7 @@ export interface VehicleData {
color: string
nextService?: string
image: string | null
image_url?: string | null
nickname?: string
countryCode?: string
@@ -45,6 +46,19 @@ export interface VehicleData {
price?: number | null
currency?: string
// ── New Admin Fields (Asset extended attributes) ──────────────────────
registration_country?: string | null
first_domestic_registration_date?: string | null
import_country?: string | null
title_document_number?: string | null
engine_number?: string | null
number_of_previous_owners?: number | null
// ── Finance / Leasing ────────────────────────────────────────────────
asset_financials?: AssetFinancials | null
insurance_policies?: VehicleInsurancePolicy[] | null
tax_obligations?: VehicleTaxObligation[] | null
// JSONB individual_equipment from backend (color, notes, engine_code, etc.)
individual_equipment?: {
color?: string
@@ -53,3 +67,56 @@ export interface VehicleData {
[key: string]: any
}
}
/**
* AssetFinancials — leasing / purchasing financial data for a vehicle asset.
* Maps to the fleet_finance.asset_financials table.
*/
export interface AssetFinancials {
id?: number
asset_id?: string
purchase_price_net?: number | null
purchase_price_gross?: number | null
currency?: string
down_payment?: number | null
monthly_installment?: number | null
contract_number?: string | null
financing_provider?: string | null
contract_start_date?: string | null
contract_end_date?: string | null
residual_value?: number | null
interest_rate?: number | null
[key: string]: any
}
/**
* VehicleInsurancePolicy — insurance policies linked to a vehicle asset.
* Maps to the fleet_finance.vehicle_insurance_policies table.
*/
export interface VehicleInsurancePolicy {
id?: number
asset_id?: string
insurer_name?: string | null
policy_number?: string | null
policy_type?: string | null // 'kgfb' | 'casco' | 'both'
premium?: number | null
currency?: string
valid_from?: string | null
valid_until?: string | null
[key: string]: any
}
/**
* VehicleTaxObligation — tax obligations linked to a vehicle asset.
* Maps to the fleet_finance.vehicle_tax_obligations table.
*/
export interface VehicleTaxObligation {
id?: number
asset_id?: string
tax_type?: string | null
amount?: number | null
currency?: string
due_date?: string | null
paid?: boolean
[key: string]: any
}

View File

@@ -65,7 +65,7 @@
<div class="space-y-5">
<!-- Country selector (EU-ready) -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.country') }}</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.country') }}</label>
<select
v-model="kycForm.region_code"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
@@ -82,7 +82,7 @@
<div class="grid grid-cols-3 gap-4">
<div class="col-span-1">
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('kyc.zipCode') }} <span class="text-red-400">*</span>
{{ t('common.zipCode') }} <span class="text-red-400">*</span>
</label>
<input
v-model="kycForm.address_zip"
@@ -93,7 +93,7 @@
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('kyc.city') }} <span class="text-red-400">*</span>
{{ t('common.city') }} <span class="text-red-400">*</span>
</label>
<div class="relative">
<input
@@ -144,7 +144,7 @@
<div v-if="showOptionalAddress" class="mt-4 space-y-4">
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.streetName') }}</label>
<label class="block text-xs text-white/60 mb-1">{{ t('common.streetName') }}</label>
<input
v-model="kycForm.address_street_name"
type="text"
@@ -170,7 +170,7 @@
</div>
<div>
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.houseNumber') }}</label>
<label class="block text-xs text-white/60 mb-1">{{ t('common.houseNumber') }}</label>
<input
v-model="kycForm.address_house_number"
type="text"
@@ -319,7 +319,7 @@
<svg class="w-4 h-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('kyc.back') }}
{{ t('common.back') }}
</button>
<div v-else />
@@ -474,7 +474,7 @@ function validateStep(): boolean {
return false
}
if (!kycForm.address_city) {
errorMessage.value = t('kyc.cityRequired')
errorMessage.value = t('common.cityRequired')
return false
}
}

View File

@@ -81,9 +81,15 @@
<SubscriptionStatusWidget />
</div>
<!-- Ad Placement Widget -->
<div class="rounded-xl border border-white/10 bg-white/5 backdrop-blur-sm p-3">
<AdPlacementWidget zone="dashboard_widget" />
<!-- Ad Placement Widget (only visible when ads exist) -->
<div
v-if="adWidgetHasAds"
class="rounded-xl border border-white/10 bg-white/5 backdrop-blur-sm p-3"
>
<AdPlacementWidget
zone="dashboard_widget"
@ads-loaded="(hasAds: boolean) => adWidgetHasAds = hasAds"
/>
</div>
</div>
</div>
@@ -203,6 +209,9 @@ const vehicleStore = useVehicleStore()
// ── Zen Mode ──
const isUiVisible = ref(true)
// ── Ad Widget State ──
const adWidgetHasAds = ref(false)
// ── Flip Modal State ──
const activeCard = ref<string | null>(null)
const selectedTargetId = ref<string | null>(null)

View File

@@ -12,7 +12,7 @@
</template>
<template #center>
<span class="text-sm font-semibold text-white/70 tracking-wide">
{{ t('profile.title') }}
{{ t('common.profileSettings') }}
</span>
</template>
<template #right>
@@ -139,7 +139,7 @@
<svg class="w-4 h-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>
{{ t('profile.cancel') }}
{{ t('common.cancel') }}
</button>
<button
@click="savePerson"
@@ -153,7 +153,7 @@
<svg v-else class="w-4 h-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>
{{ t('profile.save') }}
{{ t('common.save') }}
</button>
</template>
@@ -307,19 +307,19 @@
<div v-if="isEditing" class="space-y-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.zipCode') }}</label>
<label class="mb-1 block text-xs text-white/50">{{ t('common.zipCode') }}</label>
<input
v-model="editForm.address_zip"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.zipCode')"
:placeholder="t('common.zipCode')"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.city') }}</label>
<label class="mb-1 block text-xs text-white/50">{{ t('common.city') }}</label>
<input
v-model="editForm.address_city"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.city')"
:placeholder="t('common.city')"
/>
</div>
</div>
@@ -333,7 +333,7 @@
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.streetType') }}</label>
<label class="mb-1 block text-xs text-white/50">{{ t('common.streetType') }}</label>
<select
v-model="editForm.address_street_type"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none appearance-none cursor-pointer"
@@ -348,41 +348,41 @@
</select>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.houseNumber') }}</label>
<label class="mb-1 block text-xs text-white/50">{{ t('common.houseNumber') }}</label>
<input
v-model="editForm.address_house_number"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.houseNumber')"
:placeholder="t('common.houseNumber')"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.stairwell') }}</label>
<label class="mb-1 block text-xs text-white/50">{{ t('common.stairwell') }}</label>
<input
v-model="editForm.address_stairwell"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.stairwell')"
:placeholder="t('common.stairwell')"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.floor') }}</label>
<label class="mb-1 block text-xs text-white/50">{{ t('common.floor') }}</label>
<input
v-model="editForm.address_floor"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.floor')"
:placeholder="t('common.floor')"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.door') }}</label>
<label class="mb-1 block text-xs text-white/50">{{ t('common.door') }}</label>
<input
v-model="editForm.address_door"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.door')"
:placeholder="t('common.door')"
/>
</div>
<div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.parcelId') }}</label>
<label class="mb-1 block text-xs text-white/50">{{ t('common.parcelId') }}</label>
<input
v-model="editForm.address_hrsz"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
@@ -652,7 +652,7 @@
<svg class="w-4 h-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>
{{ t('profile.cancel') }}
{{ t('common.cancel') }}
</button>
<button
@click="savePerson"
@@ -666,7 +666,7 @@
<svg v-else class="w-4 h-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>
{{ t('profile.save') }}
{{ t('common.save') }}
</button>
</template>
</div>
@@ -691,7 +691,7 @@
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
{{ t('profile.backToGarage') }}
{{ t('common.backToGarage') }}
</button>
</div>
</template>
@@ -781,7 +781,7 @@
@click="closePasswordModal"
class="rounded-xl border border-white/20 bg-white/5 px-4 py-2 text-sm text-white/70 transition-all duration-200 hover:bg-white/10 cursor-pointer"
>
{{ t('profile.cancel') }}
{{ t('common.cancel') }}
</button>
<button
@click="submitPasswordChange"
@@ -792,7 +792,7 @@
<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>
{{ t('profile.save') }}
{{ t('common.save') }}
</button>
</div>
</div>

View File

@@ -190,14 +190,14 @@
class="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
@click="closeModal"
>
{{ $t('admin.services.cancel') }}
{{ $t('common.cancel') }}
</button>
<button
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="store.isLoading"
@click="saveService"
>
<span v-if="store.isLoading">{{ $t('admin.services.saving') }}</span>
<span v-if="store.isLoading">{{ $t('common.saving') }}</span>
<span v-else>{{ editingService ? $t('admin.services.save_changes') : $t('admin.services.create') }}</span>
</button>
</div>

View File

@@ -73,7 +73,7 @@
@change="applyFilters"
>
<option value="">{{ $t('admin.users.filter_all_status') }}</option>
<option value="active">{{ $t('admin.users.status_active') }}</option>
<option value="active">{{ $t('common.statusActive') }}</option>
<option value="inactive">{{ $t('admin.users.status_inactive') }}</option>
<option value="deleted">{{ $t('admin.users.status_deleted') }}</option>
</select>

View File

@@ -0,0 +1,566 @@
<template>
<div class="relative min-h-screen">
<!-- Garage Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Dark overlay for readability -->
<div class="fixed inset-0 z-0 bg-slate-900/65 pointer-events-none"></div>
<!-- Content -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Page header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">{{ t('finance.costManager') }}</h1>
<p class="mt-1 text-sm text-white/60">
{{ t('finance.costManagerDescription') }}
</p>
</div>
<div class="flex items-center gap-3">
<!-- Back to Dashboard button -->
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('costs.backToDashboard') }}
</button>
</div>
</div>
<!-- Cost Manager Content -->
<div class="rounded-2xl bg-white/95 shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden">
<!--
GLOBAL CONTROL BAR
-->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 px-6 py-4 border-b border-slate-100">
<!-- Left: Filters -->
<div class="flex flex-wrap items-center gap-3">
<!-- Vehicle Filter -->
<div class="flex items-center gap-2">
<label class="text-sm font-semibold text-slate-600 whitespace-nowrap">{{ t('finance.filterByVehicle') }}:</label>
<select
v-model="selectedVehicleFilter"
class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all min-w-[180px] appearance-none cursor-pointer"
@change="onFilterChange"
>
<option value="">{{ t('finance.allVehicles') }}</option>
<option
v-for="v in vehicleStore.sortedVehicles"
:key="v.id"
:value="v.id"
>
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
</option>
</select>
</div>
<!-- Period Filter -->
<div class="flex items-center gap-2">
<label class="text-sm font-semibold text-slate-600 whitespace-nowrap">{{ t('finance.periodFilter') }}:</label>
<select
v-model="selectedPeriodFilter"
class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all min-w-[140px] appearance-none cursor-pointer"
@change="onFilterChange"
>
<option value="thisYear">{{ t('finance.periodThisYear') }}</option>
<option value="last30">{{ t('finance.periodLast30Days') }}</option>
<option value="all">{{ t('finance.periodAll') }}</option>
</select>
</div>
</div>
<!-- Right: Add Cost Button -->
<button
@click="openCostWizard"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
>
<span class="text-base"></span>
{{ t('finance.addCost') }}
</button>
</div>
<!--
TABBED LAYOUT
-->
<div class="px-6 pt-4">
<!-- Tab Headers -->
<div class="flex gap-1 border-b border-slate-200">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex items-center gap-2 px-5 py-3 text-sm font-semibold rounded-t-xl transition-all duration-200"
:class="activeTab === tab.key
? 'bg-white text-sf-accent border-b-2 border-sf-accent shadow-sm -mb-px'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-50'"
>
<span>{{ tab.icon }}</span>
<span>{{ tab.label }}</span>
</button>
</div>
</div>
<!-- Tab Content -->
<div class="p-6">
<!-- Loading State (shared) -->
<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 (shared) -->
<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('common.retry') }}
</button>
</div>
<!--
TAB 1: ANALYTICS (Áttekintés)
-->
<div v-else-if="activeTab === 'analytics'">
<!-- KPI Cards Row -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Total Expenses -->
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('finance.totalExpenses') }}</p>
<p class="mt-2 text-2xl font-bold text-slate-800">{{ formatAmount(totalSum, 'Ft') }}</p>
</div>
<!-- Total (Filtered) -->
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('finance.totalFiltered') }}</p>
<p class="mt-2 text-2xl font-bold text-slate-800">{{ formatAmount(filteredSum, 'Ft') }}</p>
</div>
<!-- Avg per Vehicle -->
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('finance.avgPerVehicle') }}</p>
<p class="mt-2 text-2xl font-bold text-slate-800">{{ formatAmount(avgPerVehicle, 'Ft') }}</p>
</div>
<!-- Cost Count -->
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('finance.costType') }}</p>
<p class="mt-2 text-2xl font-bold text-slate-800">{{ costs.length }} {{ t('common.items') || 'db' }}</p>
</div>
</div>
<!-- Chart Placeholders Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Monthly Summary Chart Placeholder -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-6 flex flex-col items-center justify-center min-h-[200px]">
<svg class="w-10 h-10 text-slate-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-sm font-medium text-slate-500">{{ t('finance.monthlyChartPlaceholder') }}</p>
</div>
<!-- Category Pie Chart Placeholder -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-6 flex flex-col items-center justify-center min-h-[200px]">
<svg class="w-10 h-10 text-slate-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
</svg>
<p class="text-sm font-medium text-slate-500">{{ t('finance.categoryChartPlaceholder') }}</p>
</div>
</div>
</div>
<!--
TAB 2: TRANSACTIONS (Tranzakciók)
-->
<div v-else-if="activeTab === 'transactions'">
<!-- 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-2">{{ t('finance.costDescription') }}</div>
<div class="col-span-1 text-center">{{ t('common.actions') }}</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.category_code || cost.cost_type)"
>
{{ cost.category_name || 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_gross, 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_name || cost.license_plate || '—' }}
</div>
<!-- Description -->
<div class="col-span-2 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>
<!-- Actions -->
<div class="col-span-1 flex justify-center">
<button
@click="openEditWizard(cost)"
class="inline-flex items-center gap-1 rounded-lg bg-sf-accent/10 px-3 py-1.5 text-xs font-semibold text-sf-accent hover:bg-sf-accent/20 transition-colors"
:title="t('common.edit')"
>
<svg class="w-3.5 h-3.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('common.edit') }}
</button>
</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('common.prev') }}
</button>
<span class="text-xs text-slate-400">
{{ currentPage }} / {{ totalPages }}
</span>
<button
@click="nextPage"
:disabled="currentPage >= totalPages"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="currentPage < totalPages
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
>
{{ t('common.next') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Cost Entry Wizard (Create Mode) -->
<CostEntryWizard
:is-open="showWizard"
:vehicle="selectedVehicleForWizard"
@close="closeCostWizard"
@saved="onCostSaved"
/>
<!-- Cost Entry Wizard (Edit Mode) -->
<CostEntryWizard
:is-open="showEditWizard"
:vehicle="editVehicle"
:edit-cost="editCostData"
@close="closeEditWizard"
@saved="onCostSaved"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import api from '../../api/axios'
import { useVehicleStore } from '../../stores/vehicle'
import { useAuthStore } from '../../stores/auth'
import type { Vehicle } from '../../stores/vehicle'
import CostEntryWizard from '../../components/cost/CostEntryWizard.vue'
const router = useRouter()
const { t } = useI18n()
const vehicleStore = useVehicleStore()
const authStore = useAuthStore()
// ── Tab Definitions ──
const tabs = [
{ key: 'analytics', icon: '📊', label: t('finance.analyticsTab') },
{ key: 'transactions', icon: '📋', label: t('finance.transactionsTab') },
]
const activeTab = ref('analytics')
// ── 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
// ── Filters ──
const selectedVehicleFilter = ref('')
const selectedPeriodFilter = ref('thisYear')
// ── Wizard State ──
const showWizard = ref(false)
const selectedVehicleForWizard = ref<Vehicle | null>(null)
const showEditWizard = ref(false)
const editVehicle = ref<Vehicle | null>(null)
const editCostData = ref<any>(null)
// ── Computed Analytics ──
const totalSum = computed(() => {
return costs.value.reduce((sum, c) => {
const amt = parseFloat(c.amount_gross) || 0
return sum + amt
}, 0)
})
const filteredSum = computed(() => totalSum.value)
const avgPerVehicle = computed(() => {
if (costs.value.length === 0) return 0
const uniqueVehicles = new Set(costs.value.map((c) => c.asset_id || c.vehicle_id)).size
if (uniqueVehicles === 0) return totalSum.value
return totalSum.value / uniqueVehicles
})
// ── 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: string | number | null, currency?: string): string {
if (amount == null) return '—'
const num = typeof amount === 'string' ? parseFloat(amount) : amount
const formatted = Number(num).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})
return currency ? `${formatted} ${currency}` : `${formatted} Ft`
}
function costTypeBadge(type: string | null): string {
switch (type) {
case 'fuel':
case 'FUEL':
case 'Üzemanyag':
return 'bg-orange-100 text-orange-700'
case 'service':
case 'SERVICE':
case 'Szerviz':
return 'bg-blue-100 text-blue-700'
case 'insurance':
case 'INSURANCE':
case 'Biztosítás':
return 'bg-purple-100 text-purple-700'
case 'tax':
case 'TAX':
case 'Adó':
return 'bg-red-100 text-red-700'
case 'parking':
case 'PARKING':
case 'Parkolás':
return 'bg-amber-100 text-amber-700'
case 'toll':
case 'TOLL':
case 'Útdíj':
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 params: Record<string, any> = {
page: currentPage.value,
page_size: pageSize,
}
// Add asset_id filter if a specific vehicle is selected
if (selectedVehicleFilter.value) {
params.asset_id = selectedVehicleFilter.value
}
// Add period filter
if (selectedPeriodFilter.value === 'thisYear') {
const now = new Date()
params.date_from = new Date(now.getFullYear(), 0, 1).toISOString()
params.date_to = now.toISOString()
} else if (selectedPeriodFilter.value === 'last30') {
const now = new Date()
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
params.date_from = thirtyDaysAgo.toISOString()
params.date_to = now.toISOString()
}
// 'all' => no date filter
// Pass the active organization_id so the backend returns the correct org's expenses
let orgId = authStore.user?.active_organization_id
if (orgId == null && authStore.myOrganizations.length > 0) {
orgId = authStore.myOrganizations[0].organization_id
}
if (orgId != null) {
params.organization_id = orgId
}
const res = await api.get('/expenses', { params })
const data = res.data
costs.value = data.data || data.items || data.results || []
totalPages.value = data.total_pages || data.pagination?.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('[CostsView] fetchCosts error:', err)
console.error('[CostsView] error response:', err.response?.status, err.response?.data)
} finally {
isLoading.value = false
}
}
function onFilterChange() {
currentPage.value = 1
fetchCosts()
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
fetchCosts()
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
fetchCosts()
}
}
// ── Cost Entry Wizard (Create) ──
function openCostWizard() {
// If a vehicle is selected in the filter, pass it to the wizard
if (selectedVehicleFilter.value) {
selectedVehicleForWizard.value =
vehicleStore.vehicles.find(v => v.id === selectedVehicleFilter.value) || null
} else {
selectedVehicleForWizard.value = null
}
showWizard.value = true
}
function closeCostWizard() {
showWizard.value = false
selectedVehicleForWizard.value = null
}
// ── Cost Entry Wizard (Edit) ──
function openEditWizard(cost: any) {
// Find the vehicle for this cost
const vehicleId = cost.asset_id || cost.vehicle_id
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
editCostData.value = cost
showEditWizard.value = true
}
function closeEditWizard() {
showEditWizard.value = false
editVehicle.value = null
editCostData.value = null
}
function onCostSaved() {
// Refresh the table data after a cost is saved
closeCostWizard()
closeEditWizard()
currentPage.value = 1
fetchCosts()
}
// ── Watch: Re-fetch when active organization changes ──
watch(
() => authStore.user?.active_organization_id,
() => {
currentPage.value = 1
fetchCosts()
}
)
// ── Lifecycle ──
onMounted(() => {
// Ensure vehicles are loaded for the filter dropdown
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
fetchCosts()
})
</script>

View File

@@ -34,7 +34,7 @@
<div class="flex items-center gap-4 text-sm">
<span class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-green-400"></span>
<span class="text-green-700">10 {{ t('company.fleetActive') }}</span>
<span class="text-green-700">10 {{ t('common.statusActive') }}</span>
</span>
<span class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-red-400"></span>

View File

@@ -292,7 +292,7 @@
<!-- Country, Language, Currency -->
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.country') }}</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.country') }}</label>
<select
v-model="form.country_code"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
@@ -335,7 +335,7 @@
<div class="grid grid-cols-3 gap-4">
<div class="col-span-1">
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.zipCode') }} <span class="text-red-400">*</span>
{{ t('common.zipCode') }} <span class="text-red-400">*</span>
</label>
<input
v-model="form.address_zip"
@@ -346,7 +346,7 @@
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.city') }} <span class="text-red-400">*</span>
{{ t('common.city') }} <span class="text-red-400">*</span>
</label>
<div class="relative">
<input
@@ -378,7 +378,7 @@
<!-- Street details -->
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.streetName') }}</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.streetName') }}</label>
<input
v-model="form.address_street_name"
type="text"
@@ -387,12 +387,12 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.streetType') }}</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.streetType') }}</label>
<select
v-model="form.address_street_type"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
>
<option value="" disabled class="bg-[#04151F]">{{ t('company.streetType') }}</option>
<option value="" disabled class="bg-[#04151F]">{{ t('common.streetType') }}</option>
<option value="utca" class="bg-[#04151F]">utca</option>
<option value="út" class="bg-[#04151F]">út</option>
<option value="tér" class="bg-[#04151F]">tér</option>
@@ -406,7 +406,7 @@
<!-- House number + additional address fields -->
<div class="grid grid-cols-4 gap-3">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.houseNumber') }}</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.houseNumber') }}</label>
<input
v-model="form.address_house_number"
type="text"
@@ -415,7 +415,7 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.stairwell') }}</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.stairwell') }}</label>
<input
v-model="form.address_stairwell"
type="text"
@@ -424,7 +424,7 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.floor') }}</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.floor') }}</label>
<input
v-model="form.address_floor"
type="text"
@@ -433,7 +433,7 @@
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.door') }}</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.door') }}</label>
<input
v-model="form.address_door"
type="text"
@@ -445,7 +445,7 @@
<!-- HRsz (parcel ID) -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.parcelId') }}</label>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.parcelId') }}</label>
<input
v-model="form.address_hrsz"
type="text"
@@ -515,7 +515,7 @@
<svg class="w-4 h-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>
{{ t('company.cancel') }}
{{ t('common.cancel') }}
</button>
<!-- Back button -->
@@ -527,7 +527,7 @@
<svg class="w-4 h-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('company.back') }}
{{ t('common.back') }}
</button>
<div v-else class="flex-1" />
@@ -733,7 +733,7 @@ function validateStep(): boolean {
return false
}
if (!form.address_city) {
errorMessage.value = t('company.cityRequired')
errorMessage.value = t('common.cityRequired')
return false
}
}

View File

@@ -1,10 +1,13 @@
<template>
<div class="relative min-h-screen">
<!-- Garage Background Image (no dark overlay) -->
<!-- Garage Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Dark overlay for readability -->
<div class="fixed inset-0 z-0 bg-slate-900/65 pointer-events-none"></div>
<!-- Content -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Page header -->
@@ -15,16 +18,42 @@
{{ t('garage.subtitle', { count: vehicleStore.vehicles.length }) }}
</p>
</div>
<!-- Back to Dashboard button -->
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
<div class="flex items-center gap-3">
<!-- P0: Vehicle limit counter -->
<div
class="hidden sm:flex items-center gap-1.5 rounded-xl bg-white/5 border border-white/10 px-3 py-2 text-xs text-white/60"
:title="t('subscription.vehicleLimit')"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
</svg>
<span>{{ vehicleStore.vehicles.length }} / {{ maxVehicles }}</span>
</div>
<!-- Add Vehicle button -->
<button
@click="handleAddNew"
:disabled="isVehicleLimitReached"
class="flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold text-white shadow-lg transition-all duration-200 active:scale-95"
:class="isVehicleLimitReached
? 'bg-slate-600 cursor-not-allowed opacity-50'
: 'bg-[#70BC84] hover:bg-[#5da870] hover:shadow-xl'"
>
<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('garage.addVehicle') }}
</button>
<!-- Back to Dashboard button -->
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
</div>
<!-- Loading state -->
@@ -65,7 +94,7 @@
<template #header>
<div class="flex w-full items-center justify-between">
<span class="text-white font-bold text-sm tracking-wide truncate">
{{ vehicle.license_plate || t('garage.noPlate') }}
{{ vehicle.license_plate || t('common.noPlate') }}
</span>
<span v-if="vehicle.is_primary" class="ml-2 rounded-full bg-yellow-400/20 px-2 py-0.5 text-[10px] font-semibold text-yellow-300">
{{ t('garage.primary') }}
@@ -91,19 +120,19 @@
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.year') }}</span>
<span class="text-slate-500">{{ t('common.year') }}</span>
<span class="font-medium text-slate-800">
{{ vehicle.year_of_manufacture || vehicle.year || '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.nextService') }}</span>
<span class="text-slate-500">{{ t('common.nextService') }}</span>
<span class="font-medium text-slate-800">
{{ vehicle.individual_equipment?.dates?.mot_expiry || '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.mileage') }}</span>
<span class="text-slate-500">{{ t('common.mileage') }}</span>
<span class="font-medium text-slate-800">
{{ vehicle.current_mileage ? `${vehicle.current_mileage.toLocaleString()} km` : '—' }}
</span>
@@ -113,22 +142,63 @@
</BaseCard>
</div>
</div>
<!-- Vehicle Form Modal (Add) -->
<VehicleFormModal
:is-open="isAddFormOpen"
@close="isAddFormOpen = false"
@saved="onAddVehicleSaved"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle'
import BaseCard from '../../components/ui/BaseCard.vue'
import VehicleFormModal from '../../components/dashboard/VehicleFormModal.vue'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
// ── Add Vehicle Modal State ──
const isAddFormOpen = ref(false)
// ── P0: Vehicle limit from subscription tier allowances ──
const maxVehicles = computed(() => {
return authStore.user?.max_vehicles ?? 1
})
const isVehicleLimitReached = computed(() => {
return vehicleStore.vehicles.length >= maxVehicles.value
})
/**
* Open the Add Vehicle modal.
* Checks vehicle limit before opening.
*/
function handleAddNew() {
if (isVehicleLimitReached.value) {
// Limit reached — button is disabled, but guard just in case
return
}
isAddFormOpen.value = true
}
/**
* Called when the VehicleFormModal emits 'saved'.
* Closes the modal and refreshes the vehicle list.
*/
function onAddVehicleSaved() {
isAddFormOpen.value = false
vehicleStore.fetchVehicles()
}
/**
* Navigate to the Vehicle Details page.
* Respects corporate vs private context.

View File

@@ -3,6 +3,9 @@
<!-- Garage background image -->
<div class="fixed inset-0 z-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
<!-- Dark overlay for readability -->
<div class="fixed inset-0 z-0 bg-slate-900/65 pointer-events-none"></div>
<!-- Content -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Back button -->
@@ -35,15 +38,29 @@
<!-- Vehicle Loaded -->
<template v-if="vehicle">
<!-- Vehicle header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-white">
{{ vehicle.license_plate || t('vehicleDetail.title') }}
</h1>
<p class="mt-1 text-sm text-white/60">
{{ vehicle.brand }} {{ vehicle.model }}
<span v-if="vehicle.year_of_manufacture"> · {{ vehicle.year_of_manufacture }}</span>
</p>
<!-- Vehicle header with Edit button -->
<div class="mb-6 flex items-start justify-between">
<div>
<h1 class="text-3xl font-bold text-white">
{{ vehicle.license_plate || t('vehicleDetail.title') }}
</h1>
<p class="mt-1 text-sm text-white/60">
{{ vehicle.brand }} {{ vehicle.model }}
<span v-if="vehicle.year_of_manufacture"> · {{ vehicle.year_of_manufacture }}</span>
</p>
</div>
<!-- Edit Vehicle Button -->
<button
@click="isEditModalOpen = true"
class="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-white transition-colors hover:bg-emerald-500"
>
<!-- Pencil/Edit icon -->
<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="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('vehicle.edit_title') || t('profile.edit') || 'Edit' }}
</button>
</div>
<!-- Tab Navigation -->
@@ -72,6 +89,14 @@
</Transition>
</template>
</div>
<!-- Vehicle Edit Modal -->
<VehicleFormModal
:is-open="isEditModalOpen"
:vehicle="vehicle"
@close="isEditModalOpen = false"
@saved="onVehicleSaved"
/>
</div>
</template>
@@ -89,6 +114,9 @@ import ServiceBookTab from '../../components/vehicles/tabs/ServiceBookTab.vue'
import FinancialsTab from '../../components/vehicles/tabs/FinancialsTab.vue'
import DocumentsTab from '../../components/vehicles/tabs/DocumentsTab.vue'
// ── Edit Modal ──
import VehicleFormModal from '../../components/dashboard/VehicleFormModal.vue'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
@@ -97,7 +125,7 @@ const vehicleStore = useVehicleStore()
// ── Provide vehicle data to child tab components ──
const vehicle = computed(() => {
const id = route.params.id as string
const id = (route.params.vehicle_id || route.params.id) as string
return vehicleStore.vehicles.find(v => v.id === id) || null
})
provide('vehicle', vehicle)
@@ -107,6 +135,22 @@ const isLoading = computed(() => {
return vehicleStore.isLoading || (vehicleStore.vehicles.length === 0 && !vehicle.value)
})
// ── Edit Modal State ──
const isEditModalOpen = ref(false)
/**
* Called when the VehicleFormModal emits 'saved'.
* Closes the modal and refreshes vehicle data.
*/
function onVehicleSaved() {
isEditModalOpen.value = false
// Refresh the vehicle from the backend to get updated data
const id = (route.params.vehicle_id || route.params.id) as string
if (id) {
vehicleStore.fetchVehicleById(id)
}
}
// ── Tab definitions (fully reactive via computed) ──
interface TabDefinition {
key: string