Járműkezelés majdnem kész frontend
This commit is contained in:
238
frontend/src/components/dashboard/PrivateVehicleManager.vue
Normal file
238
frontend/src/components/dashboard/PrivateVehicleManager.vue
Normal 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>
|
||||
1159
frontend/src/components/dashboard/VehicleFormModal.vue
Normal file
1159
frontend/src/components/dashboard/VehicleFormModal.vue
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/src/components/vehicle/VehicleCardCompact.vue
Normal file
41
frontend/src/components/vehicle/VehicleCardCompact.vue
Normal 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>
|
||||
170
frontend/src/components/vehicle/VehicleCardStandard.vue
Normal file
170
frontend/src/components/vehicle/VehicleCardStandard.vue
Normal 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"
|
||||
>
|
||||
„{{ vehicle.nickname }}”
|
||||
</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>
|
||||
183
frontend/src/components/vehicle/VehicleDetailModal.vue
Normal file
183
frontend/src/components/vehicle/VehicleDetailModal.vue
Normal 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>
|
||||
84
frontend/src/components/vehicle/VehiclePlateBadge.vue
Normal file
84
frontend/src/components/vehicle/VehiclePlateBadge.vue
Normal 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>
|
||||
50
frontend/src/data/mockVehicles.ts
Normal file
50
frontend/src/data/mockVehicles.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
55
frontend/src/types/vehicle.ts
Normal file
55
frontend/src/types/vehicle.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user