egyedi jármű szerkesztés előtti mentés
This commit is contained in:
311
frontend/src/components/dashboard/ComplexExpenseModal.vue
Normal file
311
frontend/src/components/dashboard/ComplexExpenseModal.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<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-lg rounded-2xl shadow-2xl overflow-hidden animate-fade-in-up">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between bg-slate-700 px-6 py-4">
|
||||
<h3 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span>{{ isFeeMode ? '🅿️' : '➕' }}</span>
|
||||
{{ isFeeMode ? 'Parkolás / Útdíj rögzítése' : 'További költség rögzítése' }}
|
||||
</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>
|
||||
|
||||
<!-- Form Body -->
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||
<!-- Vehicle selector (read-only display) -->
|
||||
<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>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Dátum</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>
|
||||
|
||||
<!-- Amount (HUF) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg (HUF)</label>
|
||||
<input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
placeholder="pl. 5000"
|
||||
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>
|
||||
|
||||
<!-- Cascade Category: Main Category -->
|
||||
<div v-if="!isFeeMode">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Főkategória</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>Válassz kategóriát...</option>
|
||||
<option
|
||||
v-for="cat in mainCategories"
|
||||
:key="cat.id"
|
||||
:value="cat.id"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Cascade Category: Sub Category -->
|
||||
<div v-if="!isFeeMode && subCategories.length > 0">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Alkategória</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>Válassz alkategóriát...</option>
|
||||
<option
|
||||
v-for="sub in subCategories"
|
||||
:key="sub.id"
|
||||
:value="sub.id"
|
||||
>
|
||||
{{ sub.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description / Notes -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Megjegyzés</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
placeholder="Rövid leírás a költségről..."
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Recurring Toggle -->
|
||||
<div v-if="!isFeeMode" class="flex items-center gap-3">
|
||||
<input
|
||||
id="is-recurring"
|
||||
v-model="form.isRecurring"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20"
|
||||
/>
|
||||
<label for="is-recurring" class="text-sm text-slate-700">Rendszeres költség (havi ismétlődő)</label>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Submit button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="costStore.isSubmitting"
|
||||
class="w-full rounded-xl bg-sf-accent py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
v-if="costStore.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 v-else>✅ Költség rögzítése</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useCostStore } from '../../stores/cost'
|
||||
import api from '../../api/axios'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
vehicle: Vehicle | null
|
||||
/** If true, pre-fills as FEES category (parking/toll) */
|
||||
isFeeMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const costStore = useCostStore()
|
||||
|
||||
// ── Form State ──
|
||||
const form = reactive({
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
amount: 0,
|
||||
mainCategory: '',
|
||||
subCategory: '',
|
||||
description: '',
|
||||
isRecurring: false,
|
||||
})
|
||||
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
// ── Category Cascade State ──
|
||||
interface CategoryOption {
|
||||
id: number
|
||||
name: string
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
const mainCategories = ref<CategoryOption[]>([])
|
||||
const subCategories = ref<CategoryOption[]>([])
|
||||
|
||||
const vehicleLabel = computed(() => {
|
||||
if (!props.vehicle) return 'Nincs kiválasztva jármű'
|
||||
const plate = props.vehicle.license_plate || ''
|
||||
const brand = props.vehicle.brand || ''
|
||||
const model = props.vehicle.model || ''
|
||||
return `${plate} (${brand} ${model})`.trim()
|
||||
})
|
||||
|
||||
/**
|
||||
* Fetch hierarchical categories from the dictionaries API.
|
||||
*/
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await api.get('/dictionaries/cost-categories')
|
||||
const allCategories = res.data as CategoryOption[]
|
||||
|
||||
// Split into main (parent_id === null) and sub categories
|
||||
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
|
||||
// Store all for sub-category filtering
|
||||
window.__allCostCategories = allCategories
|
||||
} catch (err) {
|
||||
console.error('[ComplexExpenseModal] Failed to load categories:', err)
|
||||
// Fallback: provide basic categories
|
||||
mainCategories.value = [
|
||||
{ id: 1, name: 'Üzemanyag', parent_id: null },
|
||||
{ id: 2, name: 'Szerviz / Karbantartás', parent_id: null },
|
||||
{ id: 3, name: 'Parkolás / Útdíj', parent_id: null },
|
||||
{ id: 4, name: 'Biztosítás', parent_id: null },
|
||||
{ id: 5, name: 'Adók / Illetékek', parent_id: null },
|
||||
{ id: 6, name: 'Egyéb', parent_id: null },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function onMainCategoryChange() {
|
||||
// Reset sub-category when main changes
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.vehicle) {
|
||||
errorMessage.value = 'Nincs kiválasztva jármű.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = null
|
||||
|
||||
// Determine cost_type and category_id
|
||||
let costType: string
|
||||
let categoryId: number
|
||||
|
||||
if (props.isFeeMode) {
|
||||
// Fee mode: pre-filled as FEES
|
||||
costType = 'fee'
|
||||
categoryId = 3 // Parkolás / Útdíj
|
||||
} else {
|
||||
// Complex mode: use selected sub-category or main category
|
||||
costType = 'other'
|
||||
categoryId = Number(form.subCategory || form.mainCategory) || 6
|
||||
}
|
||||
|
||||
const payload = {
|
||||
asset_id: props.vehicle.id,
|
||||
// organization_id is omitted — backend auto-resolves from asset
|
||||
category_id: categoryId,
|
||||
cost_type: costType,
|
||||
amount_local: form.amount,
|
||||
currency_local: 'HUF' as const,
|
||||
date: new Date(form.date).toISOString(),
|
||||
mileage_at_cost: null,
|
||||
description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'),
|
||||
data: {
|
||||
is_recurring: form.isRecurring,
|
||||
category_id: categoryId,
|
||||
main_category_id: form.mainCategory || null,
|
||||
sub_category_id: form.subCategory || null,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await costStore.addExpense(payload)
|
||||
|
||||
if (result) {
|
||||
// Reset form
|
||||
form.date = new Date().toISOString().slice(0, 10)
|
||||
form.amount = 0
|
||||
form.mainCategory = ''
|
||||
form.subCategory = ''
|
||||
form.description = ''
|
||||
form.isRecurring = false
|
||||
emit('saved')
|
||||
} else {
|
||||
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -13,13 +13,23 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="isAddFormOpen = true"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
|
||||
@click="handleAddNewClick"
|
||||
:disabled="isCheckingQuota"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
v-if="isCheckingQuota"
|
||||
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>
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Új jármű hozzáadása
|
||||
{{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,13 +86,23 @@
|
||||
<h3 class="text-lg font-semibold text-slate-500">Még nincsenek járművek</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">Kattints az "Új jármű hozzáadása" gombra az első jármű felvételéhez.</p>
|
||||
<button
|
||||
@click="isAddFormOpen = true"
|
||||
class="mt-6 inline-flex items-center gap-2 rounded-xl bg-sf-accent px-6 py-3 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
|
||||
@click="handleAddNewClick"
|
||||
:disabled="isCheckingQuota"
|
||||
class="mt-6 inline-flex items-center gap-2 rounded-xl bg-sf-accent px-6 py-3 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
v-if="isCheckingQuota"
|
||||
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>
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Új jármű hozzáadása
|
||||
{{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -109,6 +129,8 @@
|
||||
@edit="openEditFromDetail"
|
||||
@set-primary="setPrimaryVehicle"
|
||||
/>
|
||||
<!-- ── Task 3: Hidden trigger for direct edit from DashboardView ── -->
|
||||
<div style="display: none">{{ triggerEditFromParent }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -116,30 +138,30 @@
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import type { VehicleData } from '../../types/vehicle'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import { mockVehicles } from '../../data/mockVehicles'
|
||||
import api from '../../api/axios'
|
||||
import VehicleFormModal from './VehicleFormModal.vue'
|
||||
import VehicleDetailModal from '../vehicle/VehicleDetailModal.vue'
|
||||
import VehicleCardStandard from '../vehicle/VehicleCardStandard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
targetVehicleId?: string | null
|
||||
editTargetVehicle?: VehicleData | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'clear-edit-target': []
|
||||
}>()
|
||||
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
/**
|
||||
* Smart vehicle list: real backend vehicles first, then mock data as fallback.
|
||||
* Deduplicates by license_plate to avoid showing the same vehicle twice.
|
||||
* Vehicle list sourced exclusively from the backend API.
|
||||
* Sorted by is_primary flag (favorite first), then alphabetically.
|
||||
* No mock/fallback data is injected — if the API returns an empty list,
|
||||
* the UI displays an empty garage.
|
||||
*/
|
||||
const vehicles = computed<VehicleData[]>(() => {
|
||||
const real = vehicleStore.vehicles as unknown as VehicleData[]
|
||||
if (real.length >= 3) return real
|
||||
|
||||
const needed = 3 - real.length
|
||||
const mocksToUse = mockVehicles
|
||||
.filter(mv => !real.some(rv => (rv.license_plate || rv.licensePlate) === (mv.license_plate || mv.licensePlate)))
|
||||
.slice(0, needed)
|
||||
return [...real, ...mocksToUse]
|
||||
return vehicleStore.sortedVehicles as unknown as VehicleData[]
|
||||
})
|
||||
|
||||
// ── Carousel ref & scroll functions ──
|
||||
@@ -183,6 +205,40 @@ async function openEditFromDetail(vehicle: VehicleData) {
|
||||
|
||||
// ── Modal state (Add) ──
|
||||
const isAddFormOpen = ref(false)
|
||||
const isCheckingQuota = ref(false)
|
||||
|
||||
/**
|
||||
* Preemptive quota check before opening the add-vehicle modal.
|
||||
* Calls GET /api/v1/assets/vehicles/quota-status.
|
||||
* If the user has reached their limit, shows a blocking toast/alert instead of opening the form.
|
||||
*/
|
||||
async function handleAddNewClick() {
|
||||
if (isCheckingQuota.value) return
|
||||
isCheckingQuota.value = true
|
||||
|
||||
try {
|
||||
const res = await api.get('/assets/vehicles/quota-status')
|
||||
const { can_add, current_count, limit } = res.data
|
||||
|
||||
if (!can_add) {
|
||||
// Block — show a prominent warning instead of opening the modal
|
||||
alert(
|
||||
`Elérted a csomagodhoz tartozó járműlimitet (${current_count}/${limit})! ` +
|
||||
'Kérjük, válts magasabb csomagra az új jármű rögzítéséhez.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Quota OK — open the add form
|
||||
isAddFormOpen.value = true
|
||||
} catch (err: any) {
|
||||
console.error('[PrivateVehicleManager] Quota check failed:', err)
|
||||
// Fail open — if the API is unreachable, let the user proceed
|
||||
isAddFormOpen.value = true
|
||||
} finally {
|
||||
isCheckingQuota.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onVehicleSaved() {
|
||||
isAddFormOpen.value = false
|
||||
@@ -220,6 +276,18 @@ watch(() => props.targetVehicleId, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Task 3: Watch for editTargetVehicle from parent (DashboardView) ──
|
||||
const triggerEditFromParent = computed(() => {
|
||||
if (props.editTargetVehicle) {
|
||||
// Open the edit form directly with the vehicle data
|
||||
editingVehicle.value = { ...props.editTargetVehicle } as VehicleData
|
||||
isEditFormOpen.value = true
|
||||
// Clear the prop so it doesn't re-trigger
|
||||
emit('clear-edit-target')
|
||||
}
|
||||
return props.editTargetVehicle?.id || null
|
||||
})
|
||||
|
||||
// ── Set Primary Vehicle ──
|
||||
async function setPrimaryVehicle(vehicle: VehicleData) {
|
||||
console.log('[PrivateVehicleManager] setPrimaryVehicle called for:', vehicle.id, vehicle.license_plate)
|
||||
|
||||
200
frontend/src/components/dashboard/SimpleFuelModal.vue
Normal file
200
frontend/src/components/dashboard/SimpleFuelModal.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<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-md rounded-2xl shadow-2xl overflow-hidden animate-fade-in-up">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between bg-slate-700 px-6 py-4">
|
||||
<h3 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span>⛽</span> Tankolás rögzítése
|
||||
</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>
|
||||
|
||||
<!-- Form Body -->
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||
<!-- Vehicle selector (read-only display of selected vehicle) -->
|
||||
<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>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Dátum</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>
|
||||
|
||||
<!-- Amount (HUF) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg (HUF)</label>
|
||||
<input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
placeholder="pl. 15000"
|
||||
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>
|
||||
|
||||
<!-- Quantity (Liters/kWh) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Mennyiség (Liter / kWh)</label>
|
||||
<input
|
||||
v-model.number="form.quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
placeholder="pl. 45.5"
|
||||
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>
|
||||
|
||||
<!-- Odometer -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Km óra állás</label>
|
||||
<input
|
||||
v-model.number="form.odometer"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="pl. 123456"
|
||||
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>
|
||||
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Submit button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="costStore.isSubmitting"
|
||||
class="w-full rounded-xl bg-sf-accent py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
v-if="costStore.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 v-else>✅ Tankolás rögzítése</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useCostStore } from '../../stores/cost'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
vehicle: Vehicle | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const costStore = useCostStore()
|
||||
|
||||
const form = reactive({
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
amount: 0,
|
||||
quantity: 0,
|
||||
odometer: 0,
|
||||
})
|
||||
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
const vehicleLabel = computed(() => {
|
||||
if (!props.vehicle) return 'Nincs kiválasztva jármű'
|
||||
const plate = props.vehicle.license_plate || ''
|
||||
const brand = props.vehicle.brand || ''
|
||||
const model = props.vehicle.model || ''
|
||||
return `${plate} (${brand} ${model})`.trim()
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.vehicle) {
|
||||
errorMessage.value = 'Nincs kiválasztva jármű.'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = null
|
||||
|
||||
// Build payload: cost_type = 'fuel', category auto-set to FUEL
|
||||
// organization_id is omitted — backend auto-resolves from asset
|
||||
const payload = {
|
||||
asset_id: props.vehicle.id,
|
||||
category_id: 1, // FUEL category (default)
|
||||
cost_type: 'fuel',
|
||||
amount_local: form.amount,
|
||||
currency_local: 'HUF' as const,
|
||||
date: new Date(form.date).toISOString(),
|
||||
mileage_at_cost: form.odometer > 0 ? form.odometer : null,
|
||||
description: `Tankolás: ${form.quantity} L/kWh`,
|
||||
data: {
|
||||
quantity: form.quantity,
|
||||
fuel_type: props.vehicle.fuel_type || null,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await costStore.addExpense(payload)
|
||||
|
||||
if (result) {
|
||||
// Reset form
|
||||
form.date = new Date().toISOString().slice(0, 10)
|
||||
form.amount = 0
|
||||
form.quantity = 0
|
||||
form.odometer = 0
|
||||
emit('saved')
|
||||
} else {
|
||||
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -3,11 +3,11 @@
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[10001] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div
|
||||
@click.stop
|
||||
class="relative w-full max-w-2xl mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden flex flex-col"
|
||||
:class="{ 'animate-spin-shrink-out': isClosing }"
|
||||
style="max-height: 90vh;"
|
||||
>
|
||||
<!-- ── Modal Header ── -->
|
||||
@@ -22,7 +22,7 @@
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ vehicle ? 'Jármű szerkesztése' : 'Új jármű hozzáadása' }}</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
@click="handleClose"
|
||||
class="text-slate-400 transition-colors duration-200 hover:text-red-500 cursor-pointer"
|
||||
aria-label="Bezárás"
|
||||
>
|
||||
@@ -43,8 +43,9 @@
|
||||
<!-- Step circle -->
|
||||
<button
|
||||
@click="goToStep(idx)"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all duration-200 cursor-pointer"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all duration-200"
|
||||
:class="getStepCircleClass(idx)"
|
||||
:disabled="isEditMode && idx === 0"
|
||||
>
|
||||
<span v-if="step > idx">✓</span>
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
@@ -75,12 +76,16 @@
|
||||
<button
|
||||
v-for="vc in vehicleClasses"
|
||||
:key="vc.value"
|
||||
@click="form.vehicle_class = vc.value"
|
||||
@click="!isEditMode && (form.vehicle_class = vc.value)"
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-1 rounded-xl border-2 px-2 py-2 text-xs font-medium transition-all duration-200 cursor-pointer"
|
||||
:class="form.vehicle_class === vc.value
|
||||
? 'border-sf-accent bg-sf-accent/5 text-sf-accent shadow-sm'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'"
|
||||
:disabled="isEditMode"
|
||||
class="flex flex-col items-center gap-1 rounded-xl border-2 px-2 py-2 text-xs font-medium transition-all duration-200"
|
||||
:class="[
|
||||
form.vehicle_class === vc.value
|
||||
? 'border-sf-accent bg-sf-accent/5 text-sf-accent shadow-sm'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50',
|
||||
isEditMode ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
|
||||
]"
|
||||
>
|
||||
<span class="text-lg">{{ vc.icon }}</span>
|
||||
<span>{{ vc.label }}</span>
|
||||
@@ -130,10 +135,98 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════ -->
|
||||
<!-- ARCHIVE CONFIRMATION (shown instead of steps when active) -->
|
||||
<!-- ════════════════════════════════════════════════════════════ -->
|
||||
<div v-if="showArchiveConfirm" class="space-y-6">
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-red-800">Jármű végleges eltávolítása</h3>
|
||||
<p class="text-sm text-red-600">Ez a művelet nem vonható vissza! A jármű archiválásra kerül.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- License Plate Confirmation -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1.5 block text-sm font-medium text-red-700">
|
||||
A megerősítéshez gépeld be a rendszámot: <strong>{{ vehicle?.license_plate }}</strong>
|
||||
</label>
|
||||
<input
|
||||
v-model="archiveConfirmPlate"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-red-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 uppercase"
|
||||
:placeholder="vehicle?.license_plate || 'Rendszám'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Final Mileage -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1.5 block text-sm font-medium text-red-700">
|
||||
Utolsó km óra állás
|
||||
</label>
|
||||
<input
|
||||
v-model.number="archiveFinalMileage"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full rounded-xl border border-red-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Archive Reason -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1.5 block text-sm font-medium text-red-700">
|
||||
Eltávolítás oka <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="archiveReason"
|
||||
class="w-full rounded-xl border border-red-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" disabled>Válassz okot</option>
|
||||
<option value="Eladás">Eladás</option>
|
||||
<option value="Gazdasági totálkár / Bontás">Gazdasági totálkár / Bontás</option>
|
||||
<option value="Lízing/Bérlet lejárta">Lízing/Bérlet lejárta</option>
|
||||
<option value="Téves rögzítés">Téves rögzítés</option>
|
||||
<option value="Egyéb">Egyéb</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="cancelArchive"
|
||||
class="inline-flex items-center gap-2 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"
|
||||
>
|
||||
Mégsem
|
||||
</button>
|
||||
<button
|
||||
@click="confirmArchive"
|
||||
:disabled="!isArchiveValid || isArchiving"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<svg v-if="isArchiving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
|
||||
{{ isArchiving ? 'Eltávolítás...' : 'Végleges törlés' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════ -->
|
||||
<!-- STEP 1: ALAPADATOK -->
|
||||
<!-- ════════════════════════════════════════════════════════════ -->
|
||||
<div v-if="step === 1" class="space-y-4">
|
||||
<div v-if="step === 1 && !showArchiveConfirm" class="space-y-4">
|
||||
<!-- ── Edit Mode: License Plate & VIN (only shown when editing) ── -->
|
||||
<div v-if="isEditMode" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
@@ -276,6 +369,26 @@
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── DANGER ZONE (Edit Mode only) ── -->
|
||||
<div v-if="isEditMode" class="mt-8 rounded-xl border-2 border-red-200 bg-red-50/50 p-5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg class="h-5 w-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<h4 class="text-sm font-bold text-red-700 uppercase tracking-wider">⚠️ Veszélyzóna</h4>
|
||||
</div>
|
||||
<p class="text-xs text-red-600 mb-3">A jármű végleges eltávolítása a garázsból. Ez a művelet nem vonható vissza!</p>
|
||||
<button
|
||||
@click="openArchiveConfirm"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-red-700 cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
|
||||
Jármű eltávolítása
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════ -->
|
||||
@@ -313,7 +426,7 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<!-- Drive Type -->
|
||||
<!-- Drive Type (DYNAMIC: motorcycle vs car/commercial options) -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-slate-700">Hajtáslánc</label>
|
||||
<select
|
||||
@@ -321,9 +434,12 @@
|
||||
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 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">Válassz</option>
|
||||
<option value="fwd">FWD (Elsőkerék)</option>
|
||||
<option value="rwd">RWD (Hátsókerék)</option>
|
||||
<option value="awd">AWD (Összkerék)</option>
|
||||
<option v-if="form.vehicle_class === 'motorcycle'" value="chain">Lánc</option>
|
||||
<option v-if="form.vehicle_class === 'motorcycle'" value="shaft">Kardán</option>
|
||||
<option v-if="form.vehicle_class === 'motorcycle'" value="belt">Szíj</option>
|
||||
<option v-if="form.vehicle_class !== 'motorcycle'" value="fwd">FWD (Elsőkerék)</option>
|
||||
<option v-if="form.vehicle_class !== 'motorcycle'" value="rwd">RWD (Hátsókerék)</option>
|
||||
<option v-if="form.vehicle_class !== 'motorcycle'" value="awd">AWD (Összkerék)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -335,10 +451,13 @@
|
||||
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 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">Válassz</option>
|
||||
<option value="manual">Manuális</option>
|
||||
<option value="automatic">Automata</option>
|
||||
<option value="cvt">CVT</option>
|
||||
<option value="dct">DCT</option>
|
||||
<option v-if="form.vehicle_class === 'motorcycle'" value="manual_sequential">Kézi (Szekvenciális)</option>
|
||||
<option v-if="form.vehicle_class === 'motorcycle'" value="automatic">Automata</option>
|
||||
<option v-if="form.vehicle_class === 'motorcycle'" value="cvt">CVT</option>
|
||||
<option v-if="form.vehicle_class !== 'motorcycle'" value="manual">Manuális</option>
|
||||
<option v-if="form.vehicle_class !== 'motorcycle'" value="automatic">Automata</option>
|
||||
<option v-if="form.vehicle_class !== 'motorcycle'" value="cvt">CVT</option>
|
||||
<option v-if="form.vehicle_class !== 'motorcycle'" value="dct">DCT</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,39 +482,20 @@
|
||||
<!-- Seat Count -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-slate-700">Ülések száma</label>
|
||||
<select
|
||||
v-model="form.seat_count"
|
||||
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 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">Válassz</option>
|
||||
<option :value="2">2</option>
|
||||
<option :value="4">4</option>
|
||||
<option :value="5">5</option>
|
||||
<option :value="7">7</option>
|
||||
<option :value="8">8</option>
|
||||
<option :value="9">9</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Motorcycle-specific: Drivetrain (chain/shaft/belt) ── -->
|
||||
<div v-if="form.vehicle_class === 'motorcycle'" 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">Hajtáslánc (Motor)</label>
|
||||
<select
|
||||
v-model="form.mcycle_drivetrain"
|
||||
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 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">Válassz</option>
|
||||
<option value="chain">Lánc</option>
|
||||
<option value="shaft">Kardán</option>
|
||||
<option value="belt">Szíj</option>
|
||||
</select>
|
||||
<input
|
||||
v-model.number="form.seat_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="9"
|
||||
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="Pl. 3, 5, 7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════ -->
|
||||
<!-- STEP 3: FELSZERELTSÉG / JSONB -->
|
||||
<!-- STEP 3: FELSZERELTSÉG / EXTRÁK -->
|
||||
<!-- ════════════════════════════════════════════════════════════ -->
|
||||
<div v-if="step === 3" class="space-y-4">
|
||||
<!-- Color -->
|
||||
@@ -439,29 +539,13 @@
|
||||
<div v-if="form.vehicle_class === 'motorcycle'" class="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
|
||||
<h4 class="text-sm font-bold text-slate-700">🏍️ Motor extrák</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment.plexi" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Plexi</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment.oldaldoboz" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Oldaldoboz</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment.hatsodoboz" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Hátsódoboz</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment.kormanyvegtukor" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Kormányvégtükör</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment.telefontolto" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Telefontöltő</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment.egyedi_kipufogo" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Egyedi kipufogó</span>
|
||||
<label
|
||||
v-for="extra in motorcycleExtras"
|
||||
:key="extra.key"
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input type="checkbox" v-model="form.individual_equipment[extra.key]" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">{{ extra.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,26 +554,21 @@
|
||||
<div v-if="['heavy_commercial', 'light_commercial'].includes(form.vehicle_class)" class="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
|
||||
<h4 class="text-sm font-bold text-slate-700">🚛 Haszongépjármű extrák</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Tachográf</label>
|
||||
<select v-model="form.individual_equipment.tachograf" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
|
||||
<option value="">Válassz</option>
|
||||
<option value="van">Van</option>
|
||||
<option value="nincs">Nincs</option>
|
||||
</select>
|
||||
<div v-for="extra in commercialExtras" :key="extra.key">
|
||||
<template v-if="extra.type === 'select'">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">{{ extra.label }}</label>
|
||||
<select v-model="form.individual_equipment[extra.key]" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
|
||||
<option value="">Válassz</option>
|
||||
<option v-for="opt in extra.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</template>
|
||||
<template v-else-if="extra.type === 'checkbox'">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment[extra.key]" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">{{ extra.label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Emelőhátfal</label>
|
||||
<select v-model="form.individual_equipment.emelohatfal" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
|
||||
<option value="">Válassz</option>
|
||||
<option value="van">Van</option>
|
||||
<option value="nincs">Nincs</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer col-span-2">
|
||||
<input type="checkbox" v-model="form.individual_equipment.halofulke" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Hálófülke</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -497,17 +576,13 @@
|
||||
<div v-if="form.vehicle_class === 'personal'" class="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
|
||||
<h4 class="text-sm font-bold text-slate-700">🚗 Személyautó extrák</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment.vonohorog" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Vonóhorog</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment.isofix" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Isofix</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.individual_equipment.napfenyteto" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">Napfénytető</span>
|
||||
<label
|
||||
v-for="extra in personalCarExtras"
|
||||
:key="extra.key"
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input type="checkbox" v-model="form.individual_equipment[extra.key]" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
|
||||
<span class="text-sm text-slate-700">{{ extra.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,8 +619,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── DYNAMIC WIZARD FOOTER ── -->
|
||||
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4 bg-slate-50 shrink-0">
|
||||
<!-- ── DYNAMIC WIZARD FOOTER (hidden during archive confirmation) ── -->
|
||||
<div v-if="!showArchiveConfirm" class="flex items-center justify-between border-t border-slate-200 px-6 py-4 bg-slate-50 shrink-0">
|
||||
<!-- Left side: navigation buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Vissza (visible when step > 0, but hidden in edit mode on step 1 to prevent going back to step 0) -->
|
||||
@@ -584,15 +659,15 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Mégsem (always visible) -->
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
@click="handleClose"
|
||||
class="inline-flex items-center gap-2 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"
|
||||
>
|
||||
Mégsem
|
||||
</button>
|
||||
|
||||
<!-- Mentés (visible only on step 3) -->
|
||||
<!-- Mentés (visible on step 3 OR when editing any step) -->
|
||||
<button
|
||||
v-if="step === 3"
|
||||
v-if="step === 3 || isEditMode"
|
||||
@click="handleSave"
|
||||
:disabled="isSaving || !isValid"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
@@ -610,15 +685,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Toast Notification (sibling root, outside modal Teleport) ── -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="fixed top-6 right-6 z-[99999] flex items-center gap-3 rounded-2xl px-5 py-4 shadow-2xl transition-all duration-300 animate-slide-in"
|
||||
:class="toastType === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'"
|
||||
<!-- ── Toast Notification (sibling root, outside modal Teleport) ── -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="fixed top-6 right-6 z-[99999] flex items-center gap-3 rounded-2xl px-5 py-4 shadow-2xl transition-all duration-300"
|
||||
:class="toastType === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'"
|
||||
>
|
||||
<svg v-if="toastType === 'success'" class="h-5 w-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
@@ -637,11 +711,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import api from '../../api/axios'
|
||||
|
||||
const vehicleStore = useVehicleStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -693,6 +769,29 @@ const vehicleClasses = [
|
||||
{ value: 'other', label: 'Egyéb', icon: '🔧' },
|
||||
]
|
||||
|
||||
// ── Category-specific equipment constant arrays (Bug 3 fix) ──
|
||||
const motorcycleExtras = [
|
||||
{ key: 'plexi', label: 'Plexi' },
|
||||
{ key: 'oldaldoboz', label: 'Oldaldoboz' },
|
||||
{ key: 'hatsodoboz', label: 'Hátsódoboz' },
|
||||
{ key: 'kormanyvegtukor', label: 'Kormányvégtükör' },
|
||||
{ key: 'telefontolto', label: 'Telefontöltő' },
|
||||
{ key: 'egyedi_kipufogo', label: 'Egyedi kipufogó' },
|
||||
{ key: 'gyorsvalto', label: 'Gyorsváltó' },
|
||||
]
|
||||
|
||||
const commercialExtras = [
|
||||
{ key: 'tachograf', label: 'Tachográf', type: 'select', options: [{ value: 'van', label: 'Van' }, { value: 'nincs', label: 'Nincs' }] },
|
||||
{ key: 'emelohatfal', label: 'Emelőhátfal', type: 'select', options: [{ value: 'van', label: 'Van' }, { value: 'nincs', label: 'Nincs' }] },
|
||||
{ key: 'halofulke', label: 'Hálófülke', type: 'checkbox' },
|
||||
]
|
||||
|
||||
const personalCarExtras = [
|
||||
{ key: 'vonohorog', label: 'Vonóhorog' },
|
||||
{ key: 'isofix', label: 'Isofix' },
|
||||
{ key: 'napfenyteto', label: 'Napfénytető' },
|
||||
]
|
||||
|
||||
// ── Normalization helper ──
|
||||
function normalizeId(str: string): string {
|
||||
return str.replace(/[\s-]/g, '').toUpperCase()
|
||||
@@ -727,9 +826,6 @@ const form = reactive({
|
||||
door_count: null as number | null,
|
||||
seat_count: null as number | null,
|
||||
|
||||
// Motorcycle-specific (stored in individual_equipment)
|
||||
mcycle_drivetrain: '',
|
||||
|
||||
// STEP 3: Equipment / JSONB
|
||||
color: '',
|
||||
ac_type: '',
|
||||
@@ -740,6 +836,65 @@ const form = reactive({
|
||||
})
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isClosing = ref(false)
|
||||
|
||||
// ── Archive (Soft Delete) state ──
|
||||
const showArchiveConfirm = ref(false)
|
||||
const archiveConfirmPlate = ref('')
|
||||
const archiveFinalMileage = ref<number | null>(null)
|
||||
const archiveReason = ref('')
|
||||
const isArchiving = ref(false)
|
||||
|
||||
function openArchiveConfirm() {
|
||||
showArchiveConfirm.value = true
|
||||
// Üres mezők — a felhasználónak kell kitöltenie
|
||||
archiveConfirmPlate.value = ''
|
||||
archiveFinalMileage.value = null
|
||||
archiveReason.value = ''
|
||||
}
|
||||
|
||||
function cancelArchive() {
|
||||
showArchiveConfirm.value = false
|
||||
archiveConfirmPlate.value = ''
|
||||
archiveFinalMileage.value = null
|
||||
archiveReason.value = ''
|
||||
}
|
||||
|
||||
const isArchiveValid = computed(() => {
|
||||
const plate = props.vehicle?.license_plate || ''
|
||||
return (
|
||||
archiveConfirmPlate.value.trim().toUpperCase() === plate.trim().toUpperCase() &&
|
||||
archiveFinalMileage.value !== null &&
|
||||
archiveFinalMileage.value > 0 &&
|
||||
archiveReason.value.trim().length > 0
|
||||
)
|
||||
})
|
||||
|
||||
async function confirmArchive() {
|
||||
if (!isArchiveValid.value || !props.vehicle?.id) return
|
||||
isArchiving.value = true
|
||||
|
||||
try {
|
||||
const success = await vehicleStore.archiveVehicle(
|
||||
props.vehicle.id,
|
||||
archiveFinalMileage.value,
|
||||
archiveReason.value
|
||||
)
|
||||
|
||||
if (success) {
|
||||
showToast('✅ Jármű sikeresen eltávolítva a garázsból', 'success')
|
||||
showArchiveConfirm.value = false
|
||||
// Close the modal and refresh the vehicle list
|
||||
handleClose()
|
||||
vehicleStore.fetchVehicles()
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err?.response?.data?.detail || err?.message || 'Ismeretlen hiba történt az eltávolítás során.'
|
||||
showToast(errorMsg, 'error')
|
||||
} finally {
|
||||
isArchiving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dirty tracking ──
|
||||
const isDirty = ref(false)
|
||||
@@ -752,47 +907,23 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// ── 🦎 CHAMELEON: Dynamic Trim Levels based on vehicle_class ──
|
||||
const trimLevelMap: Record<string, { value: string; label: string }[]> = {
|
||||
personal: [
|
||||
{ value: 'sedan', label: 'Sedan' },
|
||||
{ value: 'combi', label: 'Kombi' },
|
||||
{ value: 'suv', label: 'SUV' },
|
||||
{ value: 'hatchback', label: 'Ferdehátú' },
|
||||
{ value: 'cabrio', label: 'Cabrio' },
|
||||
{ value: 'coupe', label: 'Coupé' },
|
||||
],
|
||||
motorcycle: [
|
||||
{ value: 'enduro', label: 'Enduro' },
|
||||
{ value: 'sport', label: 'Sport' },
|
||||
{ value: 'naked', label: 'Naked' },
|
||||
{ value: 'chopper', label: 'Chopper' },
|
||||
{ value: 'cruiser', label: 'Cruiser' },
|
||||
{ value: 'touring', label: 'Touring' },
|
||||
{ value: 'scooter', label: 'Robogó' },
|
||||
],
|
||||
light_commercial: [
|
||||
{ value: 'box', label: 'Dobozos' },
|
||||
{ value: 'open_platform', label: 'Nyitott platós' },
|
||||
{ value: 'tarpaulin', label: 'Ponyvás' },
|
||||
{ value: 'refrigerated', label: 'Hűtős' },
|
||||
],
|
||||
heavy_commercial: [
|
||||
{ value: 'box', label: 'Dobozos' },
|
||||
{ value: 'open_platform', label: 'Nyitott platós' },
|
||||
{ value: 'tarpaulin', label: 'Ponyvás' },
|
||||
{ value: 'refrigerated', label: 'Hűtős' },
|
||||
{ value: 'tractor', label: 'Nyergesvontató' },
|
||||
],
|
||||
machinery: [
|
||||
{ value: 'forklift', label: 'Targonca' },
|
||||
{ value: 'excavator', label: 'Markoló' },
|
||||
{ value: 'tractor', label: 'Traktor' },
|
||||
],
|
||||
}
|
||||
// ── 🦎 BODY TYPE DICTIONARY: Loaded from API ──
|
||||
const bodyTypeDictionary = ref<{ id: number; vehicle_class: string; code: string; name_hu: string }[]>([])
|
||||
|
||||
// Fetch body types from the API on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get('/catalog/body-types')
|
||||
bodyTypeDictionary.value = res.data || []
|
||||
} catch (err) {
|
||||
console.error('[VehicleFormModal] Failed to load body types:', err)
|
||||
}
|
||||
})
|
||||
|
||||
const availableTrimLevels = computed(() => {
|
||||
return trimLevelMap[form.vehicle_class] || []
|
||||
return bodyTypeDictionary.value
|
||||
.filter(bt => bt.vehicle_class === form.vehicle_class)
|
||||
.map(bt => ({ value: bt.code, label: bt.name_hu }))
|
||||
})
|
||||
|
||||
// ── Show door & seat for vehicles with a cabin ──
|
||||
@@ -809,7 +940,9 @@ const showAcField = computed(() => {
|
||||
watch(
|
||||
() => form.vehicle_class,
|
||||
(newClass) => {
|
||||
const validValues = trimLevelMap[newClass]?.map(o => o.value) || []
|
||||
const validValues = bodyTypeDictionary.value
|
||||
.filter(bt => bt.vehicle_class === newClass)
|
||||
.map(bt => bt.code)
|
||||
if (form.trim_level && !validValues.includes(form.trim_level)) {
|
||||
form.trim_level = ''
|
||||
}
|
||||
@@ -820,23 +953,19 @@ watch(
|
||||
const isStepValid = computed(() => {
|
||||
switch (step.value) {
|
||||
case 0:
|
||||
// Step 0: license_plate and vehicle_class required
|
||||
return (
|
||||
form.license_plate.trim().length >= 2 &&
|
||||
form.vehicle_class.trim().length > 0
|
||||
)
|
||||
case 1:
|
||||
// Step 1: brand, model, fuel_type required
|
||||
return (
|
||||
form.brand.trim().length > 0 &&
|
||||
form.model.trim().length > 0 &&
|
||||
form.fuel_type.trim().length > 0
|
||||
)
|
||||
case 2:
|
||||
// Step 2: all optional
|
||||
return true
|
||||
case 3:
|
||||
// Step 3: all optional
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -857,7 +986,6 @@ const isValid = computed(() => {
|
||||
// ── Step Navigation ──
|
||||
async function nextStep() {
|
||||
if (step.value === 0) {
|
||||
// Step 0 → Step 1: Live DB check first
|
||||
const canProceed = await checkVehicleExists()
|
||||
if (!canProceed) return
|
||||
}
|
||||
@@ -869,7 +997,8 @@ function prevStep() {
|
||||
}
|
||||
|
||||
function goToStep(idx: number) {
|
||||
// Allow going back to any previous step, but only forward if step is valid
|
||||
// 🚫 BLOCK navigation to step 0 (Identifikáció) in edit mode
|
||||
if (isEditMode.value && idx === 0) return
|
||||
if (idx <= step.value) {
|
||||
step.value = idx
|
||||
} else if (isStepValid.value) {
|
||||
@@ -879,6 +1008,10 @@ function goToStep(idx: number) {
|
||||
|
||||
// ── Helpers ──
|
||||
function getStepCircleClass(idx: number) {
|
||||
// 🚫 Step 0 is locked in edit mode — show muted styling
|
||||
if (isEditMode.value && idx === 0) {
|
||||
return 'bg-slate-200 text-slate-400 opacity-50 cursor-not-allowed'
|
||||
}
|
||||
if (step.value > idx) return 'bg-emerald-500 text-white'
|
||||
if (step.value === idx) return 'bg-sf-accent text-white ring-2 ring-sf-accent/30'
|
||||
return 'bg-slate-200 text-slate-500'
|
||||
@@ -933,7 +1066,6 @@ function resetForm() {
|
||||
form.transmission_type = ''
|
||||
form.door_count = null
|
||||
form.seat_count = null
|
||||
form.mcycle_drivetrain = ''
|
||||
form.color = ''
|
||||
form.ac_type = ''
|
||||
form.notes = ''
|
||||
@@ -954,7 +1086,6 @@ async function checkVehicleExists(): Promise<boolean> {
|
||||
|
||||
isChecking.value = true
|
||||
try {
|
||||
// Build params: license_plate is required, vin is optional
|
||||
const params: Record<string, string> = { license_plate: plate }
|
||||
const vin = normalizeId(form.vin || '')
|
||||
if (vin) {
|
||||
@@ -970,7 +1101,6 @@ async function checkVehicleExists(): Promise<boolean> {
|
||||
return true
|
||||
} catch (err: any) {
|
||||
console.error('[VehicleFormModal] checkVehicleExists error:', err)
|
||||
// If the check fails, allow proceeding (degraded UX)
|
||||
return true
|
||||
} finally {
|
||||
isChecking.value = false
|
||||
@@ -993,11 +1123,27 @@ function checkDuplicateLicensePlate(plate: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Close Handler ──
|
||||
function handleClose() {
|
||||
isClosing.value = true
|
||||
setTimeout(() => {
|
||||
if (isEditMode.value && props.vehicle) {
|
||||
resetForm()
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
emit('close')
|
||||
// Reset closing state after emit so next open works cleanly
|
||||
setTimeout(() => {
|
||||
isClosing.value = false
|
||||
}, 50)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ── Save Handler ──
|
||||
async function handleSave() {
|
||||
if (!isValid.value) return
|
||||
|
||||
// Frontend Duplicate Validation (CREATE mode + EDIT mode when plate changed)
|
||||
const isDuplicate = checkDuplicateLicensePlate(form.license_plate)
|
||||
if (isDuplicate) {
|
||||
showToast('Ez a rendszám már szerepel a járműveid között!', 'error')
|
||||
@@ -1007,7 +1153,6 @@ async function handleSave() {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
// Build payload matching AssetCreate schema
|
||||
const payload: Record<string, any> = {
|
||||
license_plate: normalizeId(form.license_plate),
|
||||
brand: form.brand.trim(),
|
||||
@@ -1016,7 +1161,6 @@ async function handleSave() {
|
||||
fuel_type: form.fuel_type,
|
||||
}
|
||||
|
||||
// If brand or model changed manually, disconnect the old catalog reference
|
||||
if (props.vehicle?.id) {
|
||||
const brandChanged = form.brand.trim() !== (props.vehicle.brand || '')
|
||||
const modelChanged = form.model.trim() !== (props.vehicle.model || '')
|
||||
@@ -1042,12 +1186,9 @@ async function handleSave() {
|
||||
if (form.ac_type) extraEquipment.ac_type = form.ac_type
|
||||
if (form.notes) extraEquipment.notes = form.notes
|
||||
if (form.name) extraEquipment.name = form.name
|
||||
// Save custom_vehicle_class when 'other' is selected
|
||||
if (form.vehicle_class === 'other' && form.custom_vehicle_class) {
|
||||
extraEquipment.custom_vehicle_class = form.custom_vehicle_class.trim()
|
||||
}
|
||||
// 🦎 CHAMELEON: Category-specific fields → individual_equipment
|
||||
if (form.mcycle_drivetrain) extraEquipment.mcycle_drivetrain = form.mcycle_drivetrain
|
||||
// Merge category-specific individual_equipment (motorcycle, commercial, personal extras)
|
||||
if (form.individual_equipment && Object.keys(form.individual_equipment).length > 0) {
|
||||
Object.assign(extraEquipment, form.individual_equipment)
|
||||
@@ -1056,13 +1197,24 @@ async function handleSave() {
|
||||
payload.individual_equipment = extraEquipment
|
||||
}
|
||||
|
||||
// ── 🛡️ KETTŐS VÉDELEM: Csendes szervezet hozzárendelés ──
|
||||
// A felhasználó tudta nélkül csatoljuk az organization_id-t a payloadhoz,
|
||||
// hogy soha ne kerüljön NULL értékkel az adatbázisba.
|
||||
// Prioritás: active_organization_id > első elérhető szervezet
|
||||
const silentOrgId =
|
||||
authStore.user?.active_organization_id ||
|
||||
authStore.myOrganizations[0]?.organization_id ||
|
||||
null
|
||||
if (silentOrgId !== null && !payload.organization_id) {
|
||||
payload.organization_id = silentOrgId
|
||||
console.log('[VehicleFormModal] 🛡️ Silent org assignment:', silentOrgId)
|
||||
}
|
||||
|
||||
let savedVehicle: any
|
||||
|
||||
if (props.vehicle?.id) {
|
||||
// EDIT mode: call updateVehicle
|
||||
savedVehicle = await vehicleStore.updateVehicle(props.vehicle.id, payload)
|
||||
} else {
|
||||
// CREATE mode: call createVehicle
|
||||
savedVehicle = await vehicleStore.createVehicle(payload)
|
||||
}
|
||||
|
||||
@@ -1087,10 +1239,8 @@ watch(
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (props.vehicle) {
|
||||
// EDIT mode: skip step 0, go directly to step 1 (Basic Data)
|
||||
step.value = 1
|
||||
} else {
|
||||
// CREATE mode: reset everything, start at step 0
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
@@ -1117,7 +1267,6 @@ watch(
|
||||
form.door_count = newVal.door_count || null
|
||||
form.trim_level = newVal.trim_level || ''
|
||||
form.seat_count = newVal.seat_count || null
|
||||
form.mcycle_drivetrain = newVal.individual_equipment?.mcycle_drivetrain || ''
|
||||
form.color = newVal.individual_equipment?.color || newVal.color || ''
|
||||
form.ac_type = newVal.individual_equipment?.ac_type || ''
|
||||
form.notes = newVal.individual_equipment?.notes || newVal.notes || ''
|
||||
@@ -1131,7 +1280,6 @@ watch(
|
||||
delete form.individual_equipment.notes
|
||||
delete form.individual_equipment.name
|
||||
delete form.individual_equipment.custom_vehicle_class
|
||||
delete form.individual_equipment.mcycle_drivetrain
|
||||
isDirty.value = false
|
||||
} else {
|
||||
resetForm()
|
||||
@@ -1156,4 +1304,28 @@ watch(
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes spinShrinkOut {
|
||||
from {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(0.7) rotate(10deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-spin-shrink-out {
|
||||
animation: spinShrinkOut 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
/* ── Hide number input spinners ── */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
|
||||
1159
frontend/src/components/dashboard/VehicleFormModal_original.vue
Normal file
1159
frontend/src/components/dashboard/VehicleFormModal_original.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,11 @@
|
||||
>
|
||||
<div
|
||||
@click.stop
|
||||
class="relative w-full max-w-2xl mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-scale-in"
|
||||
class="relative w-full max-w-3xl mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-scale-in"
|
||||
>
|
||||
<!-- ── Header ── -->
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
HEADER — License Plate Hero + Vehicle Identity
|
||||
════════════════════════════════════════════════════════════════ -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-sf-accent/10">
|
||||
@@ -50,71 +52,374 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Body ── -->
|
||||
<div class="p-6 max-h-[65vh] overflow-y-auto">
|
||||
<!-- EU License Plate Hero -->
|
||||
<div class="flex justify-center mb-6">
|
||||
<VehiclePlateBadge
|
||||
:license-plate="vehicle?.license_plate || ''"
|
||||
:country-code="vehicle?.countryCode || vehicle?.country_code"
|
||||
size="lg"
|
||||
/>
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
LICENSE PLATE HERO
|
||||
════════════════════════════════════════════════════════════════ -->
|
||||
<div class="flex justify-center pt-6 pb-4">
|
||||
<VehiclePlateBadge
|
||||
:license-plate="vehicle?.license_plate || ''"
|
||||
:country-code="vehicle?.countryCode || vehicle?.country_code"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
3-TAB NAVIGATION
|
||||
════════════════════════════════════════════════════════════════ -->
|
||||
<div class="px-6">
|
||||
<div class="flex border-b border-slate-200 gap-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
class="flex items-center gap-2 px-4 py-3 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer"
|
||||
:class="activeTab === tab.id
|
||||
? 'border-sf-accent text-sf-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'"
|
||||
>
|
||||
<span class="text-base">{{ tab.icon }}</span>
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- Brand -->
|
||||
<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">Márka</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.brand || '—' }}</p>
|
||||
</div>
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
TAB CONTENT
|
||||
════════════════════════════════════════════════════════════════ -->
|
||||
<div class="p-6 max-h-[55vh] overflow-y-auto">
|
||||
<!-- ──── TAB 1: Alapadatok (Physical State) ──── -->
|
||||
<div v-if="activeTab === 'basics'" class="space-y-4">
|
||||
<!-- Info Grid -->
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Model -->
|
||||
<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">Modell</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.model || '—' }}</p>
|
||||
</div>
|
||||
<!-- Body / Karosszéria -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Karosszéria</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.vehicle_class || vehicle?.body_type || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Year -->
|
||||
<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">Évjárat</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.year || vehicle?.year_of_manufacture || '—' }}</p>
|
||||
</div>
|
||||
<!-- VIN / Alvázszám -->
|
||||
<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">Alvázszám (VIN)</p>
|
||||
<p class="text-sm font-bold text-slate-800 font-mono tracking-wider">{{ vehicle?.vin || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Mileage -->
|
||||
<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">Km óra állás</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ (vehicle?.current_mileage ?? vehicle?.mileage)?.toLocaleString() || '—' }} km</p>
|
||||
</div>
|
||||
<!-- Current Mileage -->
|
||||
<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">Jelenlegi km óra állás</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ (vehicle?.current_mileage ?? vehicle?.mileage)?.toLocaleString() || '—' }} km</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.engine || '—' }}</p>
|
||||
</div>
|
||||
<!-- Transmission -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Váltó</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.transmission_type || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<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">Szín</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-800">{{ vehicle?.individual_equipment?.color || vehicle?.color || '—' }}</span>
|
||||
<span
|
||||
v-if="vehicle?.individual_equipment?.color || vehicle?.color"
|
||||
class="inline-block h-4 w-4 rounded-full border border-slate-300"
|
||||
:style="{ backgroundColor: vehicle?.individual_equipment?.color || vehicle?.color }"
|
||||
></span>
|
||||
<!-- Drive Type -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Hajtás</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.drive_type || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Type -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Üzemanyag</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.fuel_type || '—' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Trim Level / Felszereltség -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Felszereltség</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.trim_level || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Service -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 sm:col-span-2">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Következő szerviz</p>
|
||||
<p class="text-sm font-bold" :class="serviceDueClass">{{ vehicle?.nextService || '—' }}</p>
|
||||
<!-- Vehicle Condition — Real Trust Score -->
|
||||
<div class="rounded-xl border border-slate-200 bg-gradient-to-r from-slate-50 to-white p-5 mt-2">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm font-bold text-slate-700">Gépjármű Állapot</p>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-bold"
|
||||
:class="trustScoreColorClass"
|
||||
>
|
||||
{{ trustScoreDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-3 overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-1000"
|
||||
:class="trustScoreBarColorClass"
|
||||
:style="{ width: trustScorePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between mt-1.5 text-[10px] text-slate-400">
|
||||
<span>Kopó alkatrészek</span>
|
||||
<span>Karosszéria</span>
|
||||
<span>Motor & Erőátvitel</span>
|
||||
<span>Elektronika</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ──── TAB 2: Pénzügyek / TCO ──── -->
|
||||
<div v-if="activeTab === 'finance'" class="space-y-5">
|
||||
<!-- 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>
|
||||
<p class="text-4xl font-extrabold text-slate-800">1,245,000 Ft</p>
|
||||
<p class="text-xs text-slate-400 mt-1">~ 3,411 Ft / nap</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Task 5: Freemium Logic ── -->
|
||||
<!-- Free users: simple clean table -->
|
||||
<template v-if="!isPremium">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-100 border-b border-slate-200">
|
||||
<th class="text-left px-4 py-3 font-semibold text-slate-600">Költségtípus</th>
|
||||
<th class="text-right px-4 py-3 font-semibold text-slate-600">Összeg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-slate-200">
|
||||
<td class="px-4 py-2.5 text-slate-600">Biztosítás (KGFB + Casco)</td>
|
||||
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">320,000 Ft</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200">
|
||||
<td class="px-4 py-2.5 text-slate-600">Gépjárműadó</td>
|
||||
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">45,000 Ft</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200">
|
||||
<td class="px-4 py-2.5 text-slate-600">Műszaki vizsga</td>
|
||||
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">18,000 Ft</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200">
|
||||
<td class="px-4 py-2.5 text-slate-600">Tankolás (üzemanyag)</td>
|
||||
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">520,000 Ft</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200">
|
||||
<td class="px-4 py-2.5 text-slate-600">Szerviz & Karbantartás</td>
|
||||
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">210,000 Ft</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 py-2.5 text-slate-600">Parkolás & Útdíj</td>
|
||||
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">132,000 Ft</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bg-slate-100 border-t border-slate-200">
|
||||
<td class="px-4 py-3 font-bold text-slate-700">Összesen</td>
|
||||
<td class="px-4 py-3 text-right font-bold text-slate-800">1,245,000 Ft</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Premium users: detailed breakdown with charts -->
|
||||
<template v-if="isPremium">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- Fixed Costs -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600 text-sm">🔒</span>
|
||||
<p class="text-sm font-bold text-slate-700">Állandó költségek</p>
|
||||
</div>
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Biztosítás (KGFB + Casco)</span>
|
||||
<span class="text-sm font-bold text-slate-700">320,000 Ft</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Gépjárműadó</span>
|
||||
<span class="text-sm font-bold text-slate-700">45,000 Ft</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Műszaki vizsga</span>
|
||||
<span class="text-sm font-bold text-slate-700">18,000 Ft</span>
|
||||
</div>
|
||||
<div class="border-t border-slate-200 pt-2 flex items-center justify-between">
|
||||
<span class="text-xs font-bold text-slate-600">Állandó összesen</span>
|
||||
<span class="text-sm font-bold text-slate-800">383,000 Ft</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Running Costs -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600 text-sm">⚡</span>
|
||||
<p class="text-sm font-bold text-slate-700">Folyó költségek</p>
|
||||
</div>
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Tankolás (üzemanyag)</span>
|
||||
<span class="text-sm font-bold text-slate-700">520,000 Ft</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Szerviz & Karbantartás</span>
|
||||
<span class="text-sm font-bold text-slate-700">210,000 Ft</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-500">Parkolás & Útdíj</span>
|
||||
<span class="text-sm font-bold text-slate-700">132,000 Ft</span>
|
||||
</div>
|
||||
<div class="border-t border-slate-200 pt-2 flex items-center justify-between">
|
||||
<span class="text-xs font-bold text-slate-600">Folyó összesen</span>
|
||||
<span class="text-sm font-bold text-slate-800">862,000 Ft</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Premium: Cost distribution chart placeholder -->
|
||||
<div class="rounded-xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-5">
|
||||
<p class="text-sm font-bold text-slate-700 mb-3">Költségeloszlás (éves)</p>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span>Biztosítás</span>
|
||||
<span>26%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-blue-500" style="width: 26%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span>Üzemanyag</span>
|
||||
<span>42%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-emerald-500" style="width: 42%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span>Szerviz</span>
|
||||
<span>17%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-amber-500" style="width: 17%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span>Egyéb</span>
|
||||
<span>15%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-purple-500" style="width: 15%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TCO per km hint (visible to all) -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-xs text-slate-500">
|
||||
<span class="font-semibold text-slate-700">TCO / km:</span> ~ 62 Ft/km
|
||||
<span class="text-slate-300 mx-2">|</span>
|
||||
<span class="font-semibold text-slate-700">Havi átlag:</span> ~ 103,750 Ft
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ──── TAB 3: Figyelmeztetések & Időpontok ──── -->
|
||||
<div v-if="activeTab === 'alerts'" class="space-y-4">
|
||||
<!-- ── Task 6: Statistics moved here from Tab 2 ── -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Fogyasztás</p>
|
||||
<p class="text-2xl font-extrabold text-slate-800">
|
||||
{{ vehicle?.fuel_type === 'electric' ? '—' : '8.2' }} <span class="text-sm font-semibold text-slate-500">{{ vehicle?.fuel_type === 'electric' ? 'kWh/100km' : 'l/100km' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Km Költség</p>
|
||||
<p class="text-2xl font-extrabold text-slate-800">62 <span class="text-sm font-semibold text-slate-500">Ft/km</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Idővonal — Közelgő események</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Alert 1: Insurance renewal -->
|
||||
<div class="flex items-start gap-4 rounded-xl border border-amber-200 bg-amber-50/50 p-4">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-600 text-base shrink-0">📋</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-slate-800">Kötelező biztosítás fordulónap</p>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-[10px] font-bold text-amber-700 uppercase">30 nap</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Lemondási határidő: 2026. 07. 12. — Jelenlegi díj: 85,000 Ft/év</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-600">⚠️ Lemondási határidő</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert 2: MOT / Műszaki vizsga -->
|
||||
<div class="flex items-start gap-4 rounded-xl border border-red-200 bg-red-50/50 p-4">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-red-100 text-red-600 text-base shrink-0">🔧</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-slate-800">Műszaki vizsga lejárata</p>
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-[10px] font-bold text-red-700 uppercase">14 nap</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Lejárat: 2026. 06. 26. — Hátralévő napok: 14</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-600">🔴 Sürgős</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert 3: Next service -->
|
||||
<div class="flex items-start gap-4 rounded-xl border border-blue-200 bg-blue-50/50 p-4">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-blue-100 text-blue-600 text-base shrink-0">⏰</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-slate-800">Következő szerviz</p>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase">45 nap</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-0.5">
|
||||
{{ vehicle?.current_mileage
|
||||
? `Jelenleg: ${vehicle.current_mileage.toLocaleString()} km — Következő: ${(vehicle.current_mileage + 15000).toLocaleString()} km`
|
||||
: '15,000 km vagy 1 év' }}
|
||||
</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-600">📅 Tervezett</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert 4: Seasonal tire change -->
|
||||
<div class="flex items-start gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-200 text-slate-500 text-base shrink-0">🛞</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-slate-800">Szezonális gumicsere</p>
|
||||
<span class="inline-flex items-center rounded-full bg-slate-200 px-2.5 py-0.5 text-[10px] font-bold text-slate-600 uppercase">90 nap</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Téli → Nyári gumi: 2026. 09. 10. (ajánlott időszak)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Footer ── -->
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
FOOTER
|
||||
════════════════════════════════════════════════════════════════ -->
|
||||
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4 bg-slate-50">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
@@ -122,7 +427,9 @@
|
||||
>
|
||||
Bezárás
|
||||
</button>
|
||||
<!-- Task 1: Szerkesztés gomb csak az Alapadatok fülön látható -->
|
||||
<button
|
||||
v-if="activeTab === 'basics'"
|
||||
@click="$emit('edit', vehicle)"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue cursor-pointer"
|
||||
>
|
||||
@@ -138,8 +445,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { VehicleData } from '../../types/vehicle'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import VehiclePlateBadge from './VehiclePlateBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -153,16 +461,87 @@ const emit = defineEmits<{
|
||||
'set-primary': [vehicle: VehicleData]
|
||||
}>()
|
||||
|
||||
const serviceDueClass = computed(() => {
|
||||
if (!props.vehicle?.nextService) return 'text-slate-500'
|
||||
const match = props.vehicle.nextService.match(/([\d\s]+)/)
|
||||
if (!match) return 'text-slate-500'
|
||||
const kmStr = match[1].replace(/\s/g, '')
|
||||
const km = parseInt(kmStr, 10)
|
||||
if (isNaN(km)) return 'text-slate-500'
|
||||
if (km > 10000) return 'text-green-600'
|
||||
if (km > 3000) return 'text-orange-500'
|
||||
return 'text-red-600'
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Tab state ──
|
||||
const activeTab = ref<'basics' | 'finance' | 'alerts'>('basics')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'basics' as const, label: 'Alapadatok', icon: '📋' },
|
||||
{ id: 'finance' as const, label: 'Pénzügyek (TCO)', icon: '💰' },
|
||||
{ id: 'alerts' as const, label: 'Figyelmeztetések', icon: '🔔' },
|
||||
]
|
||||
|
||||
// ── Task 2: Reset activeTab to 'basics' every time modal opens ──
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
activeTab.value = 'basics'
|
||||
}
|
||||
})
|
||||
|
||||
// ── Task 4: Trust Score computed helpers ──
|
||||
const trustScorePercent = computed(() => {
|
||||
const v = props.vehicle
|
||||
if (!v) return 0
|
||||
// Use condition_score from backend (0-100), fallback to a derived value
|
||||
if (v.condition_score !== undefined && v.condition_score !== null) {
|
||||
return Math.round(Math.min(100, Math.max(0, v.condition_score)))
|
||||
}
|
||||
// Fallback: derive from is_verified and status
|
||||
if (v.is_verified && v.status === 'active') return 85
|
||||
if (v.status === 'active') return 65
|
||||
return 40
|
||||
})
|
||||
|
||||
const trustScoreLabel = computed(() => {
|
||||
const pct = trustScorePercent.value
|
||||
if (pct >= 90) return 'Kiváló'
|
||||
if (pct >= 75) return 'Jó'
|
||||
if (pct >= 55) return 'Átlagos'
|
||||
if (pct >= 35) return 'Gyenge'
|
||||
return 'Kritikus'
|
||||
})
|
||||
|
||||
const trustScoreDisplay = computed(() => {
|
||||
return `${trustScorePercent.value}% (${trustScoreLabel.value})`
|
||||
})
|
||||
|
||||
const trustScoreColorClass = computed(() => {
|
||||
const pct = trustScorePercent.value
|
||||
if (pct >= 75) return 'bg-emerald-100 text-emerald-700'
|
||||
if (pct >= 55) return 'bg-amber-100 text-amber-700'
|
||||
if (pct >= 35) return 'bg-orange-100 text-orange-700'
|
||||
return 'bg-red-100 text-red-700'
|
||||
})
|
||||
|
||||
const trustScoreBarColorClass = computed(() => {
|
||||
const pct = trustScorePercent.value
|
||||
if (pct >= 75) return 'bg-gradient-to-r from-emerald-400 to-emerald-600'
|
||||
if (pct >= 55) return 'bg-gradient-to-r from-amber-400 to-amber-600'
|
||||
if (pct >= 35) return 'bg-gradient-to-r from-orange-400 to-orange-600'
|
||||
return 'bg-gradient-to-r from-red-400 to-red-600'
|
||||
})
|
||||
|
||||
// ── Task 5: Freemium check ──
|
||||
const isPremium = computed(() => {
|
||||
return authStore.user?.subscription_plan === 'premium' || authStore.user?.subscription_plan === 'enterprise'
|
||||
})
|
||||
|
||||
// ── Computed helpers ──
|
||||
const engineDisplay = computed(() => {
|
||||
const v = props.vehicle
|
||||
if (!v) return '—'
|
||||
if (v.engine) return v.engine
|
||||
const parts: string[] = []
|
||||
if (v.engine_capacity) parts.push(`${(v.engine_capacity / 1000).toFixed(1)}L`)
|
||||
if (v.fuel_type) parts.push(v.fuel_type)
|
||||
return parts.length > 0 ? parts.join(' ') : '—'
|
||||
})
|
||||
|
||||
const hpDisplay = computed(() => {
|
||||
const kw = props.vehicle?.power_kw
|
||||
if (!kw) return '—'
|
||||
return `${Math.round(kw * 1.341)} LE`
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { VehicleData } from '../types/vehicle'
|
||||
|
||||
/**
|
||||
* Mock vehicle data used as fallback when the API store is empty.
|
||||
* Single source of truth — import this instead of duplicating data.
|
||||
*/
|
||||
export const mockVehicles: VehicleData[] = [
|
||||
{
|
||||
id: 1,
|
||||
license_plate: 'ABC-123',
|
||||
brand: 'BMW',
|
||||
model: 'X5 xDrive30d',
|
||||
year: 2021,
|
||||
mileage: 45230,
|
||||
engine: '3.0 TDI',
|
||||
color: 'Sötétkék',
|
||||
nextService: '5 400 km múlva',
|
||||
image: null,
|
||||
nickname: 'Gizike',
|
||||
countryCode: 'H',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
license_plate: 'DEF-456',
|
||||
brand: 'Toyota',
|
||||
model: 'Corolla Hybrid',
|
||||
year: 2023,
|
||||
mileage: 12450,
|
||||
engine: '1.8 Hybrid',
|
||||
color: 'Fehér',
|
||||
nextService: '12 500 km múlva',
|
||||
image: null,
|
||||
nickname: 'Kispiros',
|
||||
countryCode: 'H',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
license_plate: 'W-XY-1234',
|
||||
brand: 'Volkswagen',
|
||||
model: 'Golf 8 R-Line',
|
||||
year: 2022,
|
||||
mileage: 28700,
|
||||
engine: '2.0 TSI',
|
||||
color: 'Szürke',
|
||||
nextService: '2 100 km múlva',
|
||||
image: null,
|
||||
nickname: '',
|
||||
countryCode: 'D',
|
||||
},
|
||||
]
|
||||
104
frontend/src/stores/cost.ts
Normal file
104
frontend/src/stores/cost.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
/**
|
||||
* Payload shape for creating a new expense via POST /expenses/
|
||||
*/
|
||||
export interface AddExpensePayload {
|
||||
asset_id: string
|
||||
organization_id?: number | null // Auto-resolved by backend from asset if omitted
|
||||
category_id: number
|
||||
cost_type: string
|
||||
amount_local: number
|
||||
currency_local?: string
|
||||
date: string
|
||||
mileage_at_cost?: number | null
|
||||
description?: string | null
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape from POST /expenses/
|
||||
*/
|
||||
export interface ExpenseResponse {
|
||||
status: string
|
||||
id: string
|
||||
asset_id: string
|
||||
cost_category: string
|
||||
amount_net: number
|
||||
date: string
|
||||
}
|
||||
|
||||
export const useCostStore = defineStore('cost', () => {
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
const isSubmitting = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
const lastSuccess = ref<ExpenseResponse | null>(null)
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a new expense (fuel, fee, or other cost) via POST /expenses/.
|
||||
* Used by SimpleFuelModal, ComplexExpenseModal, and the dashboard cost card.
|
||||
*/
|
||||
async function addExpense(payload: AddExpensePayload): Promise<ExpenseResponse | null> {
|
||||
isSubmitting.value = true
|
||||
lastError.value = null
|
||||
lastSuccess.value = null
|
||||
|
||||
try {
|
||||
// Build request body — omit organization_id if not provided (backend resolves it)
|
||||
const body: Record<string, any> = {
|
||||
asset_id: payload.asset_id,
|
||||
category_id: payload.category_id,
|
||||
cost_type: payload.cost_type,
|
||||
amount_local: payload.amount_local,
|
||||
currency_local: payload.currency_local || 'HUF',
|
||||
date: payload.date,
|
||||
mileage_at_cost: payload.mileage_at_cost ?? null,
|
||||
description: payload.description || null,
|
||||
data: payload.data || {},
|
||||
}
|
||||
if (payload.organization_id != null) {
|
||||
body.organization_id = payload.organization_id
|
||||
}
|
||||
|
||||
const res = await api.post('/expenses/', body)
|
||||
|
||||
const responseData = res.data as ExpenseResponse
|
||||
lastSuccess.value = responseData
|
||||
console.log('[CostStore] Expense created successfully:', responseData)
|
||||
return responseData
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Nem sikerült rögzíteni a költséget. Ellenőrizd a kapcsolatot.'
|
||||
lastError.value = message
|
||||
console.error('[CostStore] addExpense error:', err)
|
||||
return null
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the last error and success state.
|
||||
*/
|
||||
function resetState() {
|
||||
lastError.value = null
|
||||
lastSuccess.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isSubmitting,
|
||||
lastError,
|
||||
lastSuccess,
|
||||
|
||||
// Actions
|
||||
addExpense,
|
||||
resetState,
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
/**
|
||||
@@ -44,6 +44,10 @@ export interface Vehicle {
|
||||
|
||||
// Primary vehicle flag (from backend individual_equipment JSONB)
|
||||
is_primary: boolean
|
||||
|
||||
// Organization association (from backend AssetResponse)
|
||||
current_organization_id: number | null
|
||||
owner_organization_id: number | null
|
||||
}
|
||||
|
||||
export const useVehicleStore = defineStore('vehicle', () => {
|
||||
@@ -52,6 +56,24 @@ export const useVehicleStore = defineStore('vehicle', () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ── Getters ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sorted vehicles: primary/favorite vehicles first, then alphabetical by brand/model.
|
||||
* Used across all vehicle list displays (dashboard card, manager, etc.).
|
||||
*/
|
||||
const sortedVehicles = computed<Vehicle[]>(() => {
|
||||
return [...vehicles.value].sort((a, b) => {
|
||||
// Primary vehicles first
|
||||
if (a.is_primary && !b.is_primary) return -1
|
||||
if (!a.is_primary && b.is_primary) return 1
|
||||
// Then alphabetical by brand + model
|
||||
const nameA = `${a.brand || ''} ${a.model || ''} ${a.license_plate || ''}`.trim().toLowerCase()
|
||||
const nameB = `${b.brand || ''} ${b.model || ''} ${b.license_plate || ''}`.trim().toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -168,16 +190,58 @@ export const useVehicleStore = defineStore('vehicle', () => {
|
||||
console.log('[VehicleStore] setPrimaryVehicle:', vehicleId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive (soft delete) a vehicle via POST /assets/vehicles/{id}/archive.
|
||||
* On success, removes the vehicle from the local state.
|
||||
*/
|
||||
async function archiveVehicle(id: string, finalMileage: number, archiveReason: string): Promise<boolean> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await api.post(`/assets/vehicles/${id}/archive`, {
|
||||
final_mileage: finalMileage,
|
||||
archive_reason: archiveReason,
|
||||
})
|
||||
|
||||
// Remove the vehicle from local state
|
||||
vehicles.value = vehicles.value.filter(v => v.id !== id)
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
if (err.response) {
|
||||
console.error('[VehicleStore] archiveVehicle error response:', {
|
||||
status: err.response.status,
|
||||
data: err.response.data,
|
||||
})
|
||||
} else {
|
||||
console.error('[VehicleStore] archiveVehicle network error:', err.message)
|
||||
}
|
||||
const detail = err.response?.data?.detail
|
||||
const message = Array.isArray(detail)
|
||||
? detail.map((d: any) => `${d.loc?.join('.')}: ${d.msg}`).join('; ')
|
||||
: detail || err.response?.data?.message || 'Nem sikerült eltávolítani a járművet.'
|
||||
error.value = message
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
vehicles,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
sortedVehicles,
|
||||
|
||||
// Actions
|
||||
fetchVehicles,
|
||||
createVehicle,
|
||||
updateVehicle,
|
||||
setPrimaryVehicle,
|
||||
archiveVehicle,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
|
||||
<span class="ml-auto text-xs text-white/60">{{ displayVehicles.length }} {{ t('dashboard.vehicles') }}</span>
|
||||
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
|
||||
@@ -72,7 +72,7 @@
|
||||
:key="vehicle.id"
|
||||
:vehicle="vehicle"
|
||||
@click="openCard('vehicles')"
|
||||
@plate-click="openCard('vehicles', vehicle.license_plate)"
|
||||
@plate-click="openVehicleDetail(vehicle)"
|
||||
/>
|
||||
<!-- Empty state (only when BOTH real and mock are empty) -->
|
||||
<div
|
||||
@@ -89,49 +89,67 @@
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 2: 💰 Costs & TCO (Költségek & Analitika)
|
||||
Card 2: 💰 Költségek & Gyorsműveletek (Action Center)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="openCard('financials')"
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.fuelCost') }} & TCO</span>
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2">
|
||||
<span class="text-white font-bold text-sm tracking-wide">💰 Költségek & Gyorsműveletek</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Monthly cost summary -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p class="text-xs text-slate-500 uppercase tracking-wider font-semibold">{{ t('dashboard.monthlyScore') }}</p>
|
||||
<p class="text-2xl font-extrabold text-slate-800 mt-1">₿ 2,450</p>
|
||||
</div>
|
||||
<!-- Stat rows -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
|
||||
<span class="text-slate-500">{{ t('dashboard.totalKm') }}</span>
|
||||
<span class="font-semibold text-slate-800">12,430 km</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
|
||||
<span class="text-slate-500">{{ t('dashboard.fuelCost') }}</span>
|
||||
<span class="font-semibold text-slate-800">₿ 0.42</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
|
||||
<span class="text-slate-500">{{ t('dashboard.serviceCost') }}</span>
|
||||
<span class="font-semibold text-slate-800">₿ 1.28</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
|
||||
<span class="text-slate-500">{{ t('dashboard.co2Savings') }}</span>
|
||||
<span class="font-semibold text-emerald-600">-340 kg</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Vehicle Selector Dropdown -->
|
||||
<div class="mb-3">
|
||||
<label class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1 block">Jármű</label>
|
||||
<select
|
||||
v-model="selectedActionVehicleId"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
>
|
||||
<option
|
||||
v-for="v in vehicleStore.sortedVehicles"
|
||||
:key="v.id"
|
||||
:value="v.id"
|
||||
>
|
||||
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex-1 flex flex-col gap-2.5 justify-center">
|
||||
<!-- ⛽ Tankolás (Big, prominent) -->
|
||||
<button
|
||||
@click="openFuelModal"
|
||||
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg">⛽</span>
|
||||
<span>Tankolás rögzítése</span>
|
||||
<span class="ml-auto text-white/60 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<!-- 🅿️ Parkolás / Útdíj (Medium) -->
|
||||
<button
|
||||
@click="openFeeModal"
|
||||
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>Parkolás / Útdíj</span>
|
||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<!-- ➕ További költségek (Medium) -->
|
||||
<button
|
||||
@click="openComplexModal"
|
||||
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>További költségek</span>
|
||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom CTA button -->
|
||||
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||
{{ t('menu.finance') }} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
@@ -297,7 +315,12 @@
|
||||
<!-- Dynamic content area -->
|
||||
<div class="flex-1 p-8 overflow-y-auto text-slate-800">
|
||||
<!-- ── Private Vehicle Manager ── -->
|
||||
<PrivateVehicleManager v-if="activeCard === 'vehicles'" :target-vehicle-id="selectedTargetId" />
|
||||
<PrivateVehicleManager
|
||||
v-if="activeCard === 'vehicles'"
|
||||
:target-vehicle-id="selectedTargetId"
|
||||
:edit-target-vehicle="editTargetVehicle"
|
||||
@clear-edit-target="editTargetVehicle = null"
|
||||
/>
|
||||
|
||||
<!-- ── Fallback for other cards ── -->
|
||||
<template v-if="activeCard && activeCard !== 'vehicles'">
|
||||
@@ -312,17 +335,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Cost Action Modals (Teleported to body)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<SimpleFuelModal
|
||||
:is-open="isFuelModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
@close="isFuelModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
<ComplexExpenseModal
|
||||
:is-open="isFeeModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
:is-fee-mode="true"
|
||||
@close="isFeeModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
<ComplexExpenseModal
|
||||
:is-open="isComplexModalOpen"
|
||||
:vehicle="selectedActionVehicle"
|
||||
:is-fee-mode="false"
|
||||
@close="isComplexModalOpen = false"
|
||||
@saved="onModalSaved"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Vehicle Detail Modal (Digital Twin — 3-Tab Deep Link)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<VehicleDetailModal
|
||||
:is-open="isVehicleDetailOpen"
|
||||
:vehicle="detailVehicle"
|
||||
@close="closeVehicleDetail"
|
||||
@edit="handleEditFromDetail"
|
||||
@set-primary="handleSetPrimaryFromDetail"
|
||||
/>
|
||||
</div><!-- end min-h-screen -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useVehicleStore } from '../stores/vehicle'
|
||||
import type { VehicleData } from '../types/vehicle'
|
||||
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
|
||||
import VehicleCardCompact from '../components/vehicle/VehicleCardCompact.vue'
|
||||
import { mockVehicles } from '../data/mockVehicles'
|
||||
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
|
||||
import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue'
|
||||
import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -340,24 +401,83 @@ const openCard = (cardId: string, targetId: string | null = null) => {
|
||||
selectedTargetId.value = targetId
|
||||
}
|
||||
|
||||
// ── Vehicle Detail Modal (Deep Link from plate click) ──
|
||||
const isVehicleDetailOpen = ref(false)
|
||||
const detailVehicle = ref<VehicleData | null>(null)
|
||||
|
||||
// ── Task 3: Edit target for direct VehicleFormModal open ──
|
||||
const editTargetVehicle = ref<VehicleData | null>(null)
|
||||
|
||||
function openVehicleDetail(vehicle: VehicleData) {
|
||||
detailVehicle.value = vehicle
|
||||
isVehicleDetailOpen.value = true
|
||||
}
|
||||
|
||||
function closeVehicleDetail() {
|
||||
isVehicleDetailOpen.value = false
|
||||
detailVehicle.value = null
|
||||
}
|
||||
|
||||
function handleEditFromDetail(vehicle: VehicleData) {
|
||||
closeVehicleDetail()
|
||||
// Task 3: Open the vehicles card AND pass the vehicle to edit directly
|
||||
editTargetVehicle.value = vehicle
|
||||
activeCard.value = 'vehicles'
|
||||
}
|
||||
|
||||
function handleSetPrimaryFromDetail(vehicle: VehicleData) {
|
||||
vehicleStore.setPrimaryVehicle(vehicle.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart vehicle display: always show at least 3 vehicles for testing.
|
||||
* - Real vehicles from the backend come first (sorted by is_primary).
|
||||
* - If fewer than 3 real vehicles exist, fill the gap with mock data
|
||||
* (excluding any mock that has the same license plate as a real vehicle).
|
||||
* Display vehicles from the backend API only.
|
||||
* Sorted by is_primary flag (favorite first), then alphabetically.
|
||||
* Shows all real vehicles; no mock/fallback data is injected.
|
||||
*/
|
||||
const displayVehicles = computed(() => {
|
||||
const real = vehicleStore.vehicles
|
||||
if (real.length >= 3) return real.slice(0, 3)
|
||||
|
||||
const needed = 3 - real.length
|
||||
const realPlates = new Set(real.map(v => v.license_plate || v.licensePlate))
|
||||
const mocksToUse = mockVehicles
|
||||
.filter(mv => !real.some(rv => (rv.license_plate || rv.licensePlate) === (mv.license_plate || mv.licensePlate)))
|
||||
.slice(0, needed)
|
||||
return [...real, ...mocksToUse]
|
||||
return vehicleStore.sortedVehicles
|
||||
})
|
||||
|
||||
// ── Cost Card Action Center State ──
|
||||
const selectedActionVehicleId = ref<string>('')
|
||||
const isFuelModalOpen = ref(false)
|
||||
const isFeeModalOpen = ref(false)
|
||||
const isComplexModalOpen = ref(false)
|
||||
|
||||
/** The currently selected vehicle object for the action modals */
|
||||
const selectedActionVehicle = computed(() => {
|
||||
if (!selectedActionVehicleId.value) return null
|
||||
return vehicleStore.vehicles.find(v => v.id === selectedActionVehicleId.value) || null
|
||||
})
|
||||
|
||||
/** Auto-select the first (primary/sorted) vehicle on load */
|
||||
function autoSelectVehicle() {
|
||||
if (vehicleStore.sortedVehicles.length > 0 && !selectedActionVehicleId.value) {
|
||||
selectedActionVehicleId.value = vehicleStore.sortedVehicles[0].id
|
||||
}
|
||||
}
|
||||
|
||||
function openFuelModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isFuelModalOpen.value = true
|
||||
}
|
||||
|
||||
function openFeeModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isFeeModalOpen.value = true
|
||||
}
|
||||
|
||||
function openComplexModal() {
|
||||
if (!selectedActionVehicle.value) return
|
||||
isComplexModalOpen.value = true
|
||||
}
|
||||
|
||||
function onModalSaved() {
|
||||
isFuelModalOpen.value = false
|
||||
isFeeModalOpen.value = false
|
||||
isComplexModalOpen.value = false
|
||||
}
|
||||
|
||||
// ── Placeholder notifications ──
|
||||
const notifications = ref([
|
||||
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
|
||||
@@ -378,6 +498,11 @@ onMounted(() => {
|
||||
vehicleStore.fetchVehicles()
|
||||
authStore.fetchMyOrganizations()
|
||||
})
|
||||
|
||||
// Watch for vehicles to load, then auto-select
|
||||
watch(() => vehicleStore.vehicles.length, () => {
|
||||
autoSelectVehicle()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user