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>
|
||||
Reference in New Issue
Block a user