Járműkezelés majdnem kész frontend

This commit is contained in:
Roo
2026-06-11 11:22:47 +00:00
parent 90e3173fbc
commit 0a3fd8de74
18 changed files with 2749 additions and 99 deletions

View File

@@ -0,0 +1,238 @@
<template>
<div class="flex flex-col h-full">
<!-- Header: Title + Counter + Add Button (nav arrows removed) -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<h2 class="text-2xl font-bold text-slate-800">Saját Járművek</h2>
<span
v-if="vehicles.length > 0"
class="inline-flex items-center rounded-full bg-sf-accent/10 px-3 py-1 text-xs font-semibold text-sf-accent"
>
{{ vehicles.length }} jármű
</span>
</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"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Új jármű hozzáadása
</button>
</div>
</div>
<!-- Carousel (with vehicles) or Empty State -->
<template v-if="vehicles.length > 0">
<div class="relative">
<!-- Left Edge Navigation Strip (only show when scrolling is needed) -->
<div
v-if="vehicles.length > 2"
@click="scrollLeft"
class="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-white/80 to-transparent flex items-center justify-start cursor-pointer z-10 rounded-l-2xl"
>
<svg class="h-6 w-6 text-slate-500 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</div>
<!-- Right Edge Navigation Strip (only show when scrolling is needed) -->
<div
v-if="vehicles.length > 2"
@click="scrollRight"
class="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-white/80 to-transparent flex items-center justify-end cursor-pointer z-10 rounded-r-2xl"
>
<svg class="h-6 w-6 text-slate-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<div
ref="carouselRef"
class="flex overflow-x-auto snap-x snap-mandatory gap-6 pb-4 w-full scroll-smooth no-scrollbar"
:class="vehicles.length > 2 ? 'px-12' : 'px-2'"
style="scrollbar-width: none; -ms-overflow-style: none;"
>
<VehicleCardStandard
v-for="vehicle in vehicles"
:key="vehicle.id"
:vehicle="vehicle"
@click="openDetailView(vehicle)"
@edit="openEditDirect(vehicle)"
@set-primary="setPrimaryVehicle"
/>
</div>
</div>
</template>
<!-- Empty State (v-else) -->
<template v-else>
<div class="flex flex-1 flex-col items-center justify-center text-slate-400">
<svg class="mb-4 h-20 w-20 text-slate-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<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"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Új jármű hozzáadása
</button>
</div>
</template>
<!-- Vehicle Form Modal (Add) -->
<VehicleFormModal
:is-open="isAddFormOpen"
@close="isAddFormOpen = false"
@saved="onVehicleSaved"
/>
<!-- Vehicle Form Modal (Edit) -->
<VehicleFormModal
:is-open="isEditFormOpen"
:vehicle="editingVehicle"
@close="isEditFormOpen = false"
@saved="onVehicleEdited"
/>
<!-- Vehicle Detail Modal (read-only) -->
<VehicleDetailModal
:is-open="isDetailOpen"
:vehicle="detailVehicle"
@close="isDetailOpen = false"
@edit="openEditFromDetail"
@set-primary="setPrimaryVehicle"
/>
</div>
</template>
<script setup lang="ts">
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 VehicleFormModal from './VehicleFormModal.vue'
import VehicleDetailModal from '../vehicle/VehicleDetailModal.vue'
import VehicleCardStandard from '../vehicle/VehicleCardStandard.vue'
const props = defineProps<{
targetVehicleId?: string | null
}>()
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.
*/
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]
})
// ── Carousel ref & scroll functions ──
const carouselRef = ref<HTMLElement | null>(null)
function scrollLeft() {
if (carouselRef.value) {
carouselRef.value.scrollBy({ left: -300, behavior: 'smooth' })
}
}
function scrollRight() {
if (carouselRef.value) {
carouselRef.value.scrollBy({ left: 300, behavior: 'smooth' })
}
}
// ── Detail Modal state ──
const isDetailOpen = ref(false)
const detailVehicle = ref<VehicleData | null>(null)
function openDetailView(vehicle: VehicleData) {
detailVehicle.value = vehicle
isDetailOpen.value = true
}
// ── Direct Edit (from card edit button) ──
function openEditDirect(vehicle: VehicleData) {
editingVehicle.value = vehicle ? { ...vehicle } : null
isEditFormOpen.value = true
}
// ── Bridge: Detail → Edit ──
async function openEditFromDetail(vehicle: VehicleData) {
isDetailOpen.value = false
// Use nextTick so detail modal closes before edit opens
await nextTick()
editingVehicle.value = vehicle ? { ...vehicle } : null
isEditFormOpen.value = true
}
// ── Modal state (Add) ──
const isAddFormOpen = ref(false)
function onVehicleSaved() {
isAddFormOpen.value = false
// In real implementation, refresh the vehicle list from the store
}
// ── Modal state (Edit) ──
const isEditFormOpen = ref(false)
const editingVehicle = ref<VehicleData | null>(null)
function onVehicleEdited() {
isEditFormOpen.value = false
editingVehicle.value = null
// In real implementation, refresh the vehicle list from the store
}
// ── Targeted scroll to vehicle card ──
function scrollToVehicle(licensePlate: string) {
const el = document.getElementById('vehicle-card-' + licensePlate)
if (el) {
el.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'center' })
}
}
onMounted(() => {
if (props.targetVehicleId) {
// Small delay to ensure DOM is rendered
setTimeout(() => scrollToVehicle(props.targetVehicleId!), 100)
}
})
watch(() => props.targetVehicleId, (newVal) => {
if (newVal) {
setTimeout(() => scrollToVehicle(newVal), 100)
}
})
// ── Set Primary Vehicle ──
async function setPrimaryVehicle(vehicle: VehicleData) {
console.log('[PrivateVehicleManager] setPrimaryVehicle called for:', vehicle.id, vehicle.license_plate)
// TODO: Real API call — PUT /api/v1/assets/vehicles/{id} with { is_primary: true }
// This will also need to unset any other primary vehicle for this user.
// For now, just log and update local state optimistically.
vehicleStore.setPrimaryVehicle(vehicle.id)
}
</script>
<style scoped>
/* Hide scrollbar for the carousel container */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
<template>
<div
class="flex items-center gap-3 p-2 hover:bg-slate-50 rounded-lg cursor-pointer transition-colors"
@click="$emit('click', vehicle)"
>
<!-- Mini EU License Plate (clickable) -->
<div
v-if="vehicle.license_plate"
@click.stop="$emit('plate-click', vehicle)"
>
<VehiclePlateBadge
:license-plate="vehicle.license_plate"
:country-code="vehicle.countryCode || vehicle.country_code"
size="sm"
/>
</div>
<!-- Nickname or Brand/Model text -->
<div class="flex-1 min-w-0">
<span class="text-sm font-semibold text-slate-700 truncate block">
{{ vehicle.nickname || vehicle.brand || vehicle.license_plate }}
</span>
<p v-if="vehicle.nickname && vehicle.brand" class="text-[11px] text-slate-400 truncate">
{{ vehicle.brand }}<span v-if="vehicle.model"> {{ vehicle.model }}</span>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { VehicleData } from '../../types/vehicle'
import VehiclePlateBadge from './VehiclePlateBadge.vue'
defineProps<{
vehicle: VehicleData
}>()
defineEmits<{
click: [vehicle: VehicleData]
'plate-click': [vehicle: VehicleData]
}>()
</script>

View File

@@ -0,0 +1,170 @@
<template>
<div
:id="'vehicle-card-' + vehicle.license_plate"
class="group relative min-w-[260px] w-[280px] snap-center flex-shrink-0 rounded-2xl border border-slate-200 bg-white shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-1 overflow-hidden cursor-pointer"
@click="$emit('click', vehicle)"
>
<!-- Image Area (Top 40%) -->
<div class="h-36 rounded-t-2xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center overflow-hidden relative">
<template v-if="vehicle.image">
<img :src="vehicle.image" :alt="vehicle.brand" class="h-full w-full object-cover" />
</template>
<template v-else>
<!-- Car SVG silhouette placeholder -->
<div class="flex flex-col items-center justify-center text-slate-300">
<svg
class="w-16 h-16"
fill="none"
stroke="currentColor"
stroke-width="1.2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 17h2a1 1 0 001-1v-3.172a1 1 0 00-.293-.707l-2.828-2.828A1 1 0 0018.172 9H17l-3-3H7a2 2 0 00-2 2v2M5 17H3a1 1 0 01-1-1v-3.172a1 1 0 01.293-.707l2.828-2.828A1 1 0 016.828 9H7l3-3h5"
/>
<circle cx="7" cy="17" r="2" />
<circle cx="17" cy="17" r="2" />
</svg>
<span class="text-xs text-slate-400 mt-1">Nincs feltöltött fotó</span>
</div>
</template>
</div>
<!-- Card Body (relative for watermark) -->
<div class="p-4 space-y-3 relative overflow-hidden">
<!-- Halvány vízjel (kisebb, nem lóg ki) -->
<div
class="absolute inset-0 pointer-events-none select-none flex items-center justify-center overflow-hidden"
>
<span
class="text-5xl font-black text-slate-900/5 w-full text-center truncate px-4 leading-none"
>
{{ vehicle.brand }}
</span>
</div>
<!-- Nickname (if present) fixed height to prevent layout shift -->
<div class="min-h-[24px] mb-2">
<p
v-if="vehicle.nickname"
class="text-sm italic text-slate-500"
>
&bdquo;{{ vehicle.nickname }}&rdquo;
</p>
</div>
<!-- Dynamic EU License Plate Badge -->
<VehiclePlateBadge
:license-plate="vehicle.license_plate"
:country-code="vehicle.countryCode || vehicle.country_code"
size="md"
/>
<!-- Brand & Model -->
<h3 class="text-base font-bold text-slate-800 relative z-10">
{{ vehicle.brand }}
<span class="font-normal text-slate-500">{{ vehicle.model }}</span>
</h3>
<!-- Rich Data Rows (Icon + Text) -->
<div class="space-y-1.5 text-xs text-slate-500 relative z-10">
<!-- Year -->
<div class="flex items-center gap-2">
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{{ vehicle.year_of_manufacture || vehicle.year }}</span>
</div>
<!-- Mileage -->
<div class="flex items-center gap-2">
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span>{{ (vehicle.current_mileage ?? vehicle.mileage)?.toLocaleString() || '-' }} km</span>
</div>
<!-- Engine -->
<div class="flex items-center gap-2">
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h-2m2 4h-2m-4-4h-2m2 4h-2m6-8h2a2 2 0 012 2v4a2 2 0 01-2 2h-2m-4 0H8a2 2 0 01-2-2V8a2 2 0 012-2h4m0 0V4m0 4v4" />
</svg>
<span>{{ vehicle.engine || vehicle.fuel_type || '—' }}</span>
</div>
<!-- Color -->
<div class="flex items-center gap-2">
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
<span>{{ vehicle.individual_equipment?.color || vehicle.color || '—' }}</span>
</div>
<!-- Next Service (highlighted) -->
<div
class="flex items-center gap-2 pt-1"
:class="serviceDueClass"
>
<svg class="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-semibold">Következő Szerviz: {{ vehicle.nextService || 'Nincs adat' }}</span>
</div>
</div>
</div>
<!-- Primary Vehicle Star (top-left overlay) -->
<button
class="absolute top-3 left-3 flex h-8 w-8 items-center justify-center rounded-full shadow-sm backdrop-blur-sm transition-all z-20"
:class="vehicle.is_primary
? 'bg-amber-400 text-white hover:bg-amber-500'
: 'bg-white/60 text-slate-300 opacity-0 group-hover:opacity-100 hover:text-amber-400 hover:bg-white/90'"
@click.stop="$emit('set-primary', vehicle)"
:title="vehicle.is_primary ? 'Elsődleges jármű' : 'Beállítás elsődlegessé'"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
<!-- Edit Button (top-right overlay) -->
<button
class="absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-slate-400 opacity-0 shadow-sm backdrop-blur-sm transition-all hover:bg-sf-accent hover:text-white group-hover:opacity-100 z-20"
@click.stop="$emit('edit', vehicle)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import VehiclePlateBadge from './VehiclePlateBadge.vue'
const props = defineProps<{
vehicle: VehicleData
}>()
defineEmits<{
click: [vehicle: VehicleData]
edit: [vehicle: VehicleData]
'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'
})
</script>

View File

@@ -0,0 +1,183 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[10000] 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 animate-scale-in"
>
<!-- Header -->
<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">
<svg class="h-5 w-5 text-sf-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<div class="flex items-center gap-2">
<h3 class="text-lg font-bold text-slate-800">{{ vehicle?.brand }} {{ vehicle?.model }}</h3>
<!-- Primary Vehicle Star -->
<button
v-if="vehicle"
@click.stop="$emit('set-primary', vehicle)"
class="flex h-7 w-7 items-center justify-center rounded-full transition-all cursor-pointer"
:class="vehicle.is_primary
? 'bg-amber-100 text-amber-500 hover:bg-amber-200'
: 'bg-slate-100 text-slate-300 hover:bg-amber-50 hover:text-amber-400'"
:title="vehicle.is_primary ? 'Elsődleges jármű' : 'Beállítás elsődlegessé'"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
</div>
<p class="text-xs text-slate-400">{{ vehicle?.nickname ? `"${vehicle.nickname}"` : '' }}</p>
</div>
</div>
<button
@click="$emit('close')"
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors duration-200 hover:bg-red-50 hover:text-red-500 cursor-pointer"
aria-label="Bezárás"
>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</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"
/>
</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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
</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>
</div>
</div>
</div>
<!-- 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')"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-600 transition-all hover:bg-slate-100 cursor-pointer"
>
Bezárás
</button>
<button
@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"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Szerkesztés
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import VehiclePlateBadge from './VehiclePlateBadge.vue'
const props = defineProps<{
isOpen: boolean
vehicle: VehicleData | null
}>()
const emit = defineEmits<{
close: []
edit: [vehicle: VehicleData]
'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'
})
</script>
<style scoped>
@keyframes scaleIn {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animate-scale-in {
animation: scaleIn 0.2s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div
class="inline-flex items-center overflow-hidden rounded border border-gray-400 shadow-sm shrink-0 transition-transform duration-200 hover:-translate-y-0.5"
:class="containerClasses"
>
<!-- EU blue band -->
<div
class="flex flex-col items-center justify-center bg-[#003399] text-white"
:class="bandClasses"
>
<div class="flex flex-wrap justify-center gap-[1px] px-0.5">
<span
v-for="i in 12"
:key="i"
class="inline-block rounded-full bg-yellow-300"
:class="starClasses"
></span>
</div>
<span class="font-black leading-none tracking-wide" :class="codeClasses">
{{ countryCode || 'H' }}
</span>
</div>
<!-- Plate number -->
<div
class="flex items-center justify-center bg-white font-mono font-bold tracking-widest text-gray-900"
:class="plateClasses"
>
{{ licensePlate }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
licensePlate: string
countryCode?: string
size?: 'sm' | 'md' | 'lg'
}>(), {
countryCode: 'H',
size: 'md',
})
const containerClasses = computed(() => {
switch (props.size) {
case 'sm': return 'rounded'
case 'lg': return 'rounded-lg'
default: return 'rounded-md'
}
})
const bandClasses = computed(() => {
switch (props.size) {
case 'sm': return 'w-5 h-6 py-0.5'
case 'lg': return 'w-10 h-14 py-1'
default: return 'w-8 h-10 py-0.5'
}
})
const starClasses = computed(() => {
switch (props.size) {
case 'sm': return 'h-[2px] w-[2px]'
case 'lg': return 'h-[3px] w-[3px]'
default: return 'h-[2px] w-[2px]'
}
})
const codeClasses = computed(() => {
switch (props.size) {
case 'sm': return 'text-[6px]'
case 'lg': return 'text-xs'
default: return 'text-[10px]'
}
})
const plateClasses = computed(() => {
switch (props.size) {
case 'sm': return 'h-6 px-1.5 text-[10px]'
case 'lg': return 'h-14 px-2 text-base'
default: return 'h-10 px-1 text-sm'
}
})
</script>

View File

@@ -0,0 +1,50 @@
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',
},
]

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue'
import api from '../api/axios'
/**
* Vehicle data shape returned by GET /api/v1/assets/vehicles
* Vehicle data shape returned by GET /assets/vehicles
* Based on the AssetResponse Pydantic schema from the backend.
*/
export interface Vehicle {
@@ -41,6 +41,9 @@ export interface Vehicle {
is_for_sale: boolean
price: number | null
currency: string
// Primary vehicle flag (from backend individual_equipment JSONB)
is_primary: boolean
}
export const useVehicleStore = defineStore('vehicle', () => {
@@ -53,14 +56,14 @@ export const useVehicleStore = defineStore('vehicle', () => {
/**
* Fetch all vehicles/assets for the current user or their organization.
* GET /api/v1/assets/vehicles
* GET /assets/vehicles
*/
async function fetchVehicles() {
isLoading.value = true
error.value = null
try {
const res = await api.get('/api/v1/assets/vehicles')
const res = await api.get('/assets/vehicles')
vehicles.value = res.data as Vehicle[]
} catch (err: any) {
const message =
@@ -74,6 +77,97 @@ export const useVehicleStore = defineStore('vehicle', () => {
}
}
/**
* Create a new vehicle via POST /assets/vehicles.
* On success, adds the new vehicle to the local state and refreshes the list.
*/
async function createVehicle(vehicleData: Record<string, any>): Promise<Vehicle | null> {
isLoading.value = true
error.value = null
try {
const res = await api.post('/assets/vehicles', vehicleData)
const newVehicle = res.data as Vehicle
// Add the new vehicle to local state
vehicles.value.unshift(newVehicle)
return newVehicle
} catch (err: any) {
// Log full error response for debugging (422 Validation Error details, etc.)
if (err.response) {
console.error('[VehicleStore] createVehicle error response:', {
status: err.response.status,
data: err.response.data,
headers: err.response.headers,
})
} else {
console.error('[VehicleStore] createVehicle 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 létrehozni a járművet.'
error.value = message
throw err
} finally {
isLoading.value = false
}
}
/**
* Update an existing vehicle via PUT /assets/vehicles/{id}.
* On success, updates the vehicle in the local state.
*/
async function updateVehicle(id: string, vehicleData: Record<string, any>): Promise<Vehicle | null> {
isLoading.value = true
error.value = null
try {
const res = await api.put(`/assets/vehicles/${id}`, vehicleData)
const updatedVehicle = res.data as Vehicle
// Update the vehicle in local state
const index = vehicles.value.findIndex(v => v.id === id)
if (index !== -1) {
vehicles.value[index] = updatedVehicle
}
return updatedVehicle
} catch (err: any) {
// Log full error response for debugging (422 Validation Error details, etc.)
if (err.response) {
console.error('[VehicleStore] updateVehicle error response:', {
status: err.response.status,
data: err.response.data,
headers: err.response.headers,
})
} else {
console.error('[VehicleStore] updateVehicle 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 frissíteni a járművet.'
error.value = message
throw err
} finally {
isLoading.value = false
}
}
/**
* Optimistically set a vehicle as the primary vehicle.
* Unsets any other primary vehicle in the local state.
*/
function setPrimaryVehicle(vehicleId: string | number) {
vehicles.value = vehicles.value.map(v => ({
...v,
is_primary: v.id === vehicleId,
}))
console.log('[VehicleStore] setPrimaryVehicle:', vehicleId)
}
return {
// State
vehicles,
@@ -82,5 +176,8 @@ export const useVehicleStore = defineStore('vehicle', () => {
// Actions
fetchVehicles,
createVehicle,
updateVehicle,
setPrimaryVehicle,
}
})

View File

@@ -0,0 +1,55 @@
/**
* Unified VehicleData interface.
* Covers both API response (Vehicle from store) and mock data shapes.
* Used across all vehicle-related components for type safety.
*
* The backend AssetResponse uses `current_mileage` as the canonical field.
* Components should use `current_mileage` for display; `mileage` is kept
* as a backward-compatible alias for mock data.
*/
export interface VehicleData {
id: number | string
license_plate: string
brand: string
model: string
// Display fields (unified)
year: number | null
/** @deprecated Use current_mileage instead */
mileage?: number
/** Canonical mileage field from backend AssetResponse.current_mileage */
current_mileage: number
engine: string
color: string
nextService?: string
image: string | null
nickname?: string
countryCode?: string
// Extended API fields (optional — present in store Vehicle but not in mock)
vin?: string | null
year_of_manufacture?: number | null
fuel_type?: string
engine_capacity?: number | null
power_kw?: number | null
transmission_type?: string
drive_type?: string
trim_level?: string
vehicle_class?: string
status?: string
is_verified?: boolean
is_primary?: boolean
created_at?: string
updated_at?: string | null
is_for_sale?: boolean
price?: number | null
currency?: string
// JSONB individual_equipment from backend (color, notes, engine_code, etc.)
individual_equipment?: {
color?: string
notes?: string
engine_code?: string
[key: string]: any
}
}

View File

@@ -3,22 +3,6 @@
<!-- Garage Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
<!-- 3D Wall Text: Szerviznaptár (left wall calendar area) -->
<div
class="absolute top-[18%] left-[12%] text-2xl font-bold text-[#04151F] opacity-80 uppercase pointer-events-none"
style="transform: perspective(500px) rotateY(2deg) rotateX(-1deg);"
>
{{ t('menu.calendar') }}
</div>
<!-- 3D Wall Text: SERVICE FINDER (center wall logo area) -->
<div
class="absolute top-[35%] left-[45%] text-4xl font-extrabold text-[#418890] tracking-widest opacity-90 pointer-events-none"
style="transform: perspective(800px) rotateY(-5deg);"
>
SERVICE FINDER
</div>
</div>
<!-- Zen Mode FAB -->
@@ -78,43 +62,21 @@
<!-- 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">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
<span class="ml-auto text-xs text-white/60">{{ displayVehicles.length }} {{ t('dashboard.vehicles') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
<div class="flex-1 overflow-y-auto space-y-2">
<div
v-for="vehicle in vehicleStore.vehicles.slice(0, 4)"
<VehicleCardCompact
v-for="vehicle in displayVehicles.slice(0, 3)"
:key="vehicle.id"
class="rounded-xl border border-slate-200 bg-slate-50 p-3 transition-colors hover:bg-slate-100 cursor-pointer"
>
<!-- License Plate -->
<div
v-if="vehicle.license_plate"
class="mb-1 inline-flex items-center overflow-hidden rounded-md border border-gray-400 shadow-sm"
>
<div class="flex h-6 w-6 items-center justify-center bg-[#003399] text-[7px] font-bold leading-tight text-white">
<span class="block text-center leading-tight">SF</span>
</div>
<div class="flex h-6 items-center bg-white px-2">
<span class="font-mono text-xs font-bold tracking-widest text-gray-900">
{{ vehicle.license_plate }}
</span>
</div>
</div>
<!-- Brand + Model -->
<h3 class="text-sm font-bold text-slate-800">
{{ vehicle.brand || t('dashboard.statusUnknown') }}
<span v-if="vehicle.model" class="font-normal text-slate-500"> {{ vehicle.model }}</span>
</h3>
<!-- Mileage -->
<p class="mt-0.5 text-xs text-slate-400">
{{ vehicle.current_mileage?.toLocaleString() || 0 }} {{ t('dashboard.mileage') }}
</p>
</div>
<!-- Empty state -->
:vehicle="vehicle"
@click="openCard('vehicles')"
@plate-click="openCard('vehicles', vehicle.license_plate)"
/>
<!-- Empty state (only when BOTH real and mock are empty) -->
<div
v-if="vehicleStore.vehicles.length === 0"
v-if="displayVehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -124,10 +86,6 @@
</div>
</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('dashboard.addVehicle') }}
</button>
</div>
<!--
@@ -319,41 +277,52 @@
</Transition>
<!--
Flip Modal 3D átforduló részletes nézet
Flip Modal 3D átforduló részletes nézet (Teleport to body)
-->
<div
v-if="activeCard"
class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
@click.self="activeCard = null"
>
<div class="bg-white w-11/12 max-w-6xl h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
<!-- Close button (X) -->
<button
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
@click="activeCard = null"
>
</button>
<Teleport to="body">
<div
v-if="activeCard"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
@click.self="activeCard = null"
>
<div class="bg-white w-11/12 max-w-5xl h-auto min-h-[500px] pb-6 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
<!-- Close button (X) -->
<button
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
@click="activeCard = null"
>
</button>
<!-- Dynamic content area -->
<div class="flex-1 p-8 overflow-y-auto text-slate-800">
<h2 class="text-2xl font-bold mb-6">
{{ t('dashboard.detailedView') }}: <span class="text-slate-500">{{ activeCard }}</span>
</h2>
<p class="text-slate-500">
{{ t('dashboard.modalPlaceholder') }}
</p>
<!-- 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" />
<!-- Fallback for other cards -->
<template v-if="activeCard && activeCard !== 'vehicles'">
<h2 class="text-2xl font-bold mb-6">
{{ t('dashboard.detailedView') }}: <span class="text-slate-500">{{ activeCard }}</span>
</h2>
<p class="text-slate-500">
{{ t('dashboard.modalPlaceholder') }}
</p>
</template>
</div>
</div>
</div>
</div>
</Teleport>
</div><!-- end min-h-screen -->
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle'
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
import VehicleCardCompact from '../components/vehicle/VehicleCardCompact.vue'
import { mockVehicles } from '../data/mockVehicles'
const { t } = useI18n()
@@ -365,10 +334,30 @@ const isUiVisible = ref(true)
// ── Flip Modal State ──
const activeCard = ref<string | null>(null)
const openCard = (cardId: string) => {
const selectedTargetId = ref<string | null>(null)
const openCard = (cardId: string, targetId: string | null = null) => {
activeCard.value = cardId
selectedTargetId.value = targetId
}
/**
* 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).
*/
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]
})
// ── Placeholder notifications ──
const notifications = ref([
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },

View File

@@ -666,14 +666,6 @@
</div>
</div>
<!-- 🩻 DIAGNOSTIC JSON DUMP -->
<div class="rounded-2xl border border-white/10 bg-black/40 p-4 mt-6 backdrop-blur-xl shadow-xl shadow-black/20">
<details>
<summary class="text-xs font-semibold text-[#00E5A0] cursor-pointer select-none mb-2">{{ t('profile.xray') }}</summary>
<pre class="text-xs text-green-400 bg-black p-4 mt-2 overflow-auto rounded-lg max-h-64">{{ JSON.stringify(authStore.user?.person, null, 2) }}</pre>
</details>
</div>
<!-- Back to Garage Button -->
<div class="mt-8 text-center">
<button