frontend költség beállítások
This commit is contained in:
7
frontend/admin/Dockerfile.dev
Normal file
7
frontend/admin/Dockerfile.dev
Normal 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
6
frontend/admin/app.vue
Normal 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>
|
||||
3
frontend/admin/nuxt.config.ts
Normal file
3
frontend/admin/nuxt.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: true }
|
||||
})
|
||||
16
frontend/admin/package.json
Normal file
16
frontend/admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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') }}
|
||||
|
||||
834
frontend/src/components/cost/CostEntryWizard.vue
Normal file
834
frontend/src/components/cost/CostEntryWizard.vue
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
@click="retry"
|
||||
class="mt-2 text-xs text-sf-accent hover:underline"
|
||||
>
|
||||
{{ t('finance.retry') }}
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">📅 {{ 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">📅 {{ 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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
278
frontend/src/components/subscription/SubscriptionInfoModal.vue
Normal file
278
frontend/src/components/subscription/SubscriptionInfoModal.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 '—'
|
||||
|
||||
@@ -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>💸</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>🛡</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>📊</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
149
frontend/src/stores/expense.ts
Normal file
149
frontend/src/stores/expense.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
566
frontend/src/views/costs/CostsView.vue
Normal file
566
frontend/src/views/costs/CostsView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user