nyelvi refaktorálás előtt

This commit is contained in:
Roo
2026-06-19 09:09:24 +00:00
parent 9ba2d9180d
commit 5b437b220d
17 changed files with 1007 additions and 1088 deletions

View File

@@ -109,9 +109,10 @@ async function fetchAds() {
const res = await api.get<PlacementResponse>(`/marketing/placements/${props.zone}`)
ads.value = res.data.ads || []
} catch (err: any) {
// Silently handle 404 (placement not configured yet)
// Silently handle 404 (placement not configured yet) — no console noise
if (err.response?.status === 404) {
ads.value = []
error.value = null
} else {
error.value = 'Failed to load ads'
console.error('[AdPlacementWidget] Error:', err)

View File

@@ -1,8 +1,13 @@
<template>
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="$emit('open-card', 'vehicles')"
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Invisible overlay to capture clicks on the whole card -->
<div
class="absolute inset-0 z-10 cursor-pointer"
@click="navigateToFleet"
/>
<!-- 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>
@@ -15,13 +20,15 @@
v-for="vehicle in displayVehicles.slice(0, 3)"
:key="vehicle.id"
:vehicle="vehicle"
@click="$emit('open-card', 'vehicles')"
@plate-click="openVehicleDetail(vehicle)"
class="relative z-20"
@click.stop="navigateToFleet"
@plate-click.stop="openVehicleDetail(vehicle)"
/>
<!-- Empty state (only when BOTH real and mock are empty) -->
<div
v-if="displayVehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400"
class="flex flex-col items-center justify-center py-6 text-slate-400 relative z-20"
@click.stop="navigateToFleet"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
@@ -35,19 +42,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle'
import type { VehicleData } from '../../types/vehicle'
import VehicleCardCompact from '../vehicle/VehicleCardCompact.vue'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
const emit = defineEmits<{
'open-card': [cardId: string, targetId?: string | null]
'open-vehicle-detail': [vehicle: VehicleData]
}>()
/**
* Display vehicles from the backend API only.
* Sorted by is_primary flag (favorite first), then alphabetically.
@@ -57,7 +63,33 @@ const displayVehicles = computed(() => {
return vehicleStore.sortedVehicles
})
/**
* Navigate directly to the Vehicle Details page.
* Dynamically decides the route based on the user's current context:
* - Corporate mode → /organization/:id/vehicles/:vehicleId
* - Private mode → /dashboard/vehicles/:vehicleId
*/
function openVehicleDetail(vehicle: VehicleData) {
emit('open-vehicle-detail', vehicle)
const vehicleId = vehicle.id
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
router.push(`/organization/${authStore.user.active_organization_id}/vehicles/${vehicleId}`)
} else {
router.push(`/dashboard/vehicles/${vehicleId}`)
}
}
/**
* Navigate to the Fleet View (Garage) page.
* Dynamically decides the route based on the user's current context:
* - Corporate mode → /organization/:id/vehicles
* - Private mode → /dashboard/vehicles
*/
function navigateToFleet() {
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
router.push(`/organization/${authStore.user.active_organization_id}/vehicles`)
} else {
// FleetView is a child route under /dashboard
router.push('/dashboard/vehicles')
}
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabDocuments') }}</h3>
<div class="flex flex-col items-center justify-center py-12 text-white/50">
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<p class="text-base">{{ t('vehicleDetail.placeholderDocuments') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabFinancials') }}</h3>
<div class="flex flex-col items-center justify-center py-12 text-white/50">
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-base">{{ t('vehicleDetail.placeholderFinancials') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,165 @@
<template>
<div class="space-y-6">
<!-- Row 1: Photo + Basic Info -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Vehicle Photo -->
<div class="flex aspect-[16/9] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 backdrop-blur-sm lg:aspect-square">
<img
src="https://images.unsplash.com/photo-1503376712341-a67b5e40e2d1?auto=format&fit=crop&w=800&q=80"
alt="Vehicle photo"
class="h-full w-full object-cover rounded-2xl"
/>
</div>
<!-- Basic Info -->
<div class="flex flex-col justify-center rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm lg:col-span-2">
<!-- License Plate (large, prominent) -->
<h2 class="text-4xl font-bold tracking-wider text-white">
{{ vehicle?.license_plate || t('vehicleDetail.noPlate') }}
</h2>
<!-- Brand / Model -->
<p class="mt-2 text-2xl text-white/80">
{{ vehicle?.brand || '—' }} {{ vehicle?.model || '—' }}
</p>
<!-- Year of Manufacture -->
<p class="mt-1 text-lg text-white/50">
{{ vehicle?.year_of_manufacture || '—' }}
</p>
<!-- Divider -->
<div class="my-4 border-t border-white/10" />
<!-- Quick specs row -->
<div class="flex flex-wrap gap-x-8 gap-y-2">
<div>
<span class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.vin') }}</span>
<p class="text-sm text-white/70">{{ vehicle?.vin || '—' }}</p>
</div>
<div>
<span class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.fuelType') }}</span>
<p class="text-sm text-white/70">{{ fuelLabel }}</p>
</div>
<div>
<span class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.mileage') }}</span>
<p class="text-sm text-white/70">{{ formattedMileage }}</p>
</div>
</div>
</div>
</div>
<!-- Row 2: Vital Signs (3 cards) -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<!-- MOT Expiry -->
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/20">
<svg class="h-5 w-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div>
<p class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.motExpiry') }}</p>
<p class="text-base font-semibold text-white" :class="motStatusClass">
{{ motExpiryLabel }}
</p>
</div>
</div>
</div>
<!-- Current Mileage -->
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/20">
<svg class="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.mileage') }}</p>
<p class="text-base font-semibold text-white">
{{ formattedMileage }}
</p>
</div>
</div>
</div>
<!-- Next Service -->
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/20">
<svg class="h-5 w-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div>
<p class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.nextService') }}</p>
<p class="text-base font-semibold text-white">
{{ nextServiceLabel }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Vehicle } from '../../../stores/vehicle'
const { t } = useI18n()
// ── Inject vehicle from parent (provided by VehicleDetailsView) ──
const vehicle = inject<computed<Vehicle | null>>('vehicle')
// ── Fuel type label ──
const fuelLabel = computed(() => {
if (!vehicle?.value?.fuel_type) return '—'
const key = `vehicle.fuelTypes.${vehicle.value.fuel_type}`
const translated = t(key)
return translated !== key ? translated : vehicle.value.fuel_type
})
// ── Formatted mileage ──
const formattedMileage = computed(() => {
if (vehicle?.value?.current_mileage == null) return '—'
return `${Number(vehicle.value.current_mileage).toLocaleString()} km`
})
// ── MOT Expiry ──
const motExpiryLabel = computed(() => {
const dates = vehicle?.value?.individual_equipment?.dates
if (!dates?.mot_expiry) return t('vehicleDetail.noData')
return formatDate(dates.mot_expiry)
})
const motStatusClass = computed(() => {
const dates = vehicle?.value?.individual_equipment?.dates
if (!dates?.mot_expiry) return ''
const expiry = new Date(dates.mot_expiry)
const now = new Date()
const diffDays = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) return 'text-red-400'
if (diffDays < 30) return 'text-amber-400'
return 'text-emerald-400'
})
// ── Next Service ──
const nextServiceLabel = computed(() => {
// Placeholder — will be connected to the service book / maintenance schedule later
return t('vehicleDetail.comingSoon')
})
// ── Date formatter helper ──
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr)
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return dateStr
}
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabServiceBook') }}</h3>
<div class="flex flex-col items-center justify-center py-12 text-white/50">
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-base">{{ t('vehicleDetail.placeholderServiceBook') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabTechData') }}</h3>
<div class="flex flex-col items-center justify-center py-12 text-white/50">
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p class="text-base">{{ t('vehicleDetail.placeholderTechData') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -7,6 +7,12 @@ export default {
optional: 'optional',
select: 'Select',
retry: 'Retry',
back: 'Back',
licensePlate: 'License Plate',
vin: 'VIN',
fuel: 'Fuel',
mileage: 'Mileage',
year: 'Year',
},
subscription: {
title: 'Subscription Plans',
@@ -65,6 +71,48 @@ export default {
addVehicle: '+ Add New Vehicle',
service: 'Service',
},
garage: {
title: 'My Garage',
subtitle: 'Managing {count} vehicles',
backToDashboard: 'Back to Dashboard',
backToGarage: 'Back to Garage',
noPlate: 'No Plate',
primary: 'Primary',
brandModel: 'Brand / Model',
year: 'Year',
nextService: 'Next Service',
mileage: 'Mileage',
emptyTitle: 'Your garage is empty',
emptySubtitle: 'Add your first vehicle to get started!',
},
vehicleDetail: {
title: 'Vehicle Details',
placeholderTitle: 'Deep Dive Coming Soon',
placeholderSubtitle: 'This page will soon feature a full tabbed layout with detailed vehicle information, service history, costs, and more.',
notFound: 'Vehicle not found.',
noPlate: 'No Plate',
// Tab labels
tabOverview: 'Overview',
tabTechData: 'Technical Data',
tabServiceBook: 'Service Book',
tabFinancials: 'Financials',
tabDocuments: 'Documents',
// Tab placeholders
placeholderOverview: 'Vehicle overview details will appear here.',
placeholderTechData: 'Technical specifications will appear here.',
placeholderServiceBook: 'Service history will appear here.',
placeholderFinancials: 'Financial records will appear here.',
placeholderDocuments: 'Documents will appear here.',
// OverviewTab keys
photoPlaceholder: 'Photo upload coming soon',
vin: 'VIN',
fuelType: 'Fuel',
mileage: 'Mileage',
motExpiry: 'MOT Expiry',
nextService: 'Next Service',
noData: 'No data',
comingSoon: 'Coming soon',
},
header: {
welcome: 'Welcome',
userMenu: 'User menu',

View File

@@ -7,6 +7,12 @@ export default {
optional: 'opcionális',
select: 'Válassz',
retry: 'Újrapróbálkozás',
back: 'Vissza',
licensePlate: 'Rendszám',
vin: 'Alvázszám (VIN)',
fuel: 'Üzemanyag',
mileage: 'Futásteljesítmény',
year: 'Évjárat',
},
subscription: {
title: 'Előfizetési Csomagok',
@@ -65,6 +71,45 @@ export default {
addVehicle: '+ Új Jármű Hozzáadása',
service: 'Szervíz',
},
garage: {
title: 'Garázsom',
subtitle: '{count} jármű kezelése',
backToDashboard: 'Vissza az irányítópultra',
backToGarage: 'Vissza a Garázsba',
noPlate: 'Nincs rendszám',
primary: 'Elsődleges',
brandModel: 'Márka / Modell',
year: 'Évjárat',
nextService: 'Következő Szerviz',
mileage: 'Futásteljesítmény',
emptyTitle: 'A garázsod üres',
emptySubtitle: 'Add hozzá az első járművedet!',
},
vehicleDetail: {
title: 'Jármű Részletek',
placeholderTitle: 'Részletes nézet hamarosan',
placeholderSubtitle: 'Ez az oldal hamarosan teljes tabokkal ellátott részletes járműinformációkat, szerviztörténetet, költségeket és egyebeket tartalmaz.',
notFound: 'A jármű nem található.',
noPlate: 'Nincs rendszám',
tabOverview: 'Áttekintés',
tabTechData: 'Műszaki adatok',
tabServiceBook: 'Szervizkönyv',
tabFinancials: 'Pénzügyek',
tabDocuments: 'Dokumentumok',
placeholderOverview: 'A jármű áttekintő adatai itt jelennek meg.',
placeholderTechData: 'A műszaki adatok itt jelennek meg.',
placeholderServiceBook: 'A szerviztörténet itt jelenik meg.',
placeholderFinancials: 'A pénzügyi adatok itt jelennek meg.',
placeholderDocuments: 'A dokumentumok itt jelennek meg.',
photoPlaceholder: 'Fotó feltöltés hamarosan',
vin: 'Alvázszám (VIN)',
fuelType: 'Üzemanyag',
mileage: 'Futásteljesítmény',
motExpiry: 'Műszaki érvényesség',
nextService: 'Következő Szerviz',
noData: 'Nincs adat',
comingSoon: 'Hamarosan',
},
header: {
welcome: 'Üdvözlünk',
userMenu: 'Felhasználói menü',

View File

@@ -38,6 +38,17 @@
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<div class="py-1">
<!-- Járművek (Fleet) -->
<button
@click="navigateToVehicles"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{{ t('menu.garage') }}
</button>
<button
@click="openCompanyData"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
@@ -125,6 +136,13 @@ function openCompanyData() {
showCompanyDataModal.value = true
}
// ── Navigate to Vehicles (Fleet) page ──────────────────────────────
function navigateToVehicles() {
isHamburgerOpen.value = false
const orgId = route.params.id
router.push(`/organization/${orgId}/vehicles`)
}
// ── Navigate to Subscription Plans page ────────────────────────────
function navigateToSubscription() {
isHamburgerOpen.value = false

View File

@@ -146,9 +146,11 @@ const isHamburgerOpen = ref(false)
// ── Navigate to dashboard with card query param ────────────────────
function navigateToCard(cardId: string) {
isHamburgerOpen.value = false
// 'vehicles' opens the flip modal with PrivateVehicleManager
// 'vehicles' navigates to the full FleetView page
// 'costs' and 'service' navigate to dashboard where their cards are already visible inline
if (cardId === 'costs' || cardId === 'service') {
if (cardId === 'vehicles') {
router.push('/dashboard/vehicles')
} else if (cardId === 'costs' || cardId === 'service') {
router.push('/dashboard')
} else {
router.push({ path: '/dashboard', query: { card: cardId } })

View File

@@ -28,6 +28,16 @@ const router = createRouter({
path: 'subscription',
name: 'subscription',
component: () => import('../views/SubscriptionPlansView.vue')
},
{
path: 'vehicles',
name: 'FleetView',
component: () => import('../views/vehicles/FleetView.vue')
},
{
path: 'vehicles/:id',
name: 'VehicleDetails',
component: () => import('../views/vehicles/VehicleDetailsView.vue')
}
]
},
@@ -45,6 +55,16 @@ const router = createRouter({
path: 'subscription',
name: 'org-subscription',
component: () => import('../views/SubscriptionPlansView.vue')
},
{
path: 'vehicles',
name: 'OrgFleetView',
component: () => import('../views/vehicles/FleetView.vue')
},
{
path: 'vehicles/:id',
name: 'OrgVehicleDetails',
component: () => import('../views/vehicles/VehicleDetailsView.vue')
}
]
},

View File

@@ -55,7 +55,6 @@
<!-- Card 1: 🚗 My Garage (Járműveim) -->
<MyVehiclesCard
@open-card="openCard"
@open-vehicle-detail="openVehicleDetail"
/>
<!-- Card 2: 💰 Costs & Quick Actions (Action Center) -->

View File

@@ -0,0 +1,150 @@
<template>
<div class="relative min-h-screen">
<!-- Garage Background Image (no dark overlay) -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Content -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Page header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">{{ t('garage.title') }}</h1>
<p class="mt-1 text-sm text-white/60">
{{ t('garage.subtitle', { count: vehicleStore.vehicles.length }) }}
</p>
</div>
<!-- Back to Dashboard button -->
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
<!-- Loading state -->
<div
v-if="vehicleStore.isLoading"
class="flex items-center justify-center py-20"
>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]" />
</div>
<!-- Empty state -->
<div
v-else-if="vehicleStore.vehicles.length === 0"
class="flex flex-col items-center justify-center py-20 text-white/50"
>
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-lg font-medium">{{ t('garage.emptyTitle') }}</p>
<p class="mt-1 text-sm">{{ t('garage.emptySubtitle') }}</p>
</div>
<!-- Vehicle Grid -->
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<BaseCard
v-for="vehicle in vehicleStore.sortedVehicles"
:key="vehicle.id"
:hoverable="true"
:clickable="true"
height="h-[280px]"
headerColor="bg-slate-700"
@click="openVehicleDetail(vehicle.id)"
>
<!-- Header slot: License plate -->
<template #header>
<div class="flex w-full items-center justify-between">
<span class="text-white font-bold text-sm tracking-wide truncate">
{{ vehicle.license_plate || t('garage.noPlate') }}
</span>
<span v-if="vehicle.is_primary" class="ml-2 rounded-full bg-yellow-400/20 px-2 py-0.5 text-[10px] font-semibold text-yellow-300">
{{ t('garage.primary') }}
</span>
</div>
</template>
<!-- Default slot: Vehicle info -->
<div class="flex flex-col gap-3 p-2">
<!-- Vehicle icon / image placeholder -->
<div class="flex items-center justify-center rounded-xl bg-slate-100 py-4">
<svg class="h-12 w-12 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<!-- Key details -->
<div class="space-y-1.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.brandModel') }}</span>
<span class="font-medium text-slate-800">
{{ vehicle.brand || '—' }} {{ vehicle.model || '' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.year') }}</span>
<span class="font-medium text-slate-800">
{{ vehicle.year_of_manufacture || vehicle.year || '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.nextService') }}</span>
<span class="font-medium text-slate-800">
{{ vehicle.individual_equipment?.dates?.mot_expiry || '—' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.mileage') }}</span>
<span class="font-medium text-slate-800">
{{ vehicle.current_mileage ? `${vehicle.current_mileage.toLocaleString()} km` : '—' }}
</span>
</div>
</div>
</div>
</BaseCard>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle'
import BaseCard from '../../components/ui/BaseCard.vue'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
/**
* Navigate to the Vehicle Details page.
* Respects corporate vs private context.
*/
function openVehicleDetail(vehicleId: string) {
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
router.push(`/organization/${authStore.user.active_organization_id}/vehicles/${vehicleId}`)
} else {
router.push(`/dashboard/vehicles/${vehicleId}`)
}
}
onMounted(() => {
// Ensure vehicles are loaded when navigating directly to this page
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
})
</script>

View File

@@ -0,0 +1,161 @@
<template>
<div class="relative min-h-screen">
<!-- Garage background image -->
<div class="fixed inset-0 z-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
<!-- Content -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Back button -->
<button
@click="goBack"
class="mb-6 flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('common.back') }}
</button>
<!-- Loading State -->
<div v-if="isLoading" class="flex flex-col items-center justify-center py-24">
<svg class="h-10 w-10 animate-spin text-white/40" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p class="mt-4 text-sm text-white/50">{{ t('dashboard.loading') }}</p>
</div>
<!-- Error State -->
<div v-else-if="!vehicle" class="flex flex-col items-center justify-center py-24 text-white/50">
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-lg">{{ t('vehicleDetail.notFound') }}</p>
</div>
<!-- Vehicle Loaded -->
<template v-if="vehicle">
<!-- Vehicle header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-white">
{{ vehicle.license_plate || t('vehicleDetail.title') }}
</h1>
<p class="mt-1 text-sm text-white/60">
{{ vehicle.brand }} {{ vehicle.model }}
<span v-if="vehicle.year_of_manufacture"> · {{ vehicle.year_of_manufacture }}</span>
</p>
</div>
<!-- Tab Navigation -->
<div class="mb-6 flex flex-wrap gap-1 border-b border-white/10">
<button
v-for="tab in tabDefinitions"
:key="tab.key"
@click="activeTab = tab.key"
class="relative px-5 py-3 text-sm font-medium transition-colors duration-200"
:class="activeTab === tab.key
? 'text-white'
: 'text-white/50 hover:text-white/80'"
>
{{ tab.label }}
<!-- Active indicator line -->
<span
v-if="activeTab === tab.key"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-[#70BC84]"
/>
</button>
</div>
<!-- Tab Content (dynamic component) -->
<Transition name="fade" mode="out-in">
<component :is="activeComponent" :key="activeTab" />
</Transition>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, markRaw, provide } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle'
// ── Tab Components ──
import OverviewTab from '../../components/vehicles/tabs/OverviewTab.vue'
import TechDataTab from '../../components/vehicles/tabs/TechDataTab.vue'
import ServiceBookTab from '../../components/vehicles/tabs/ServiceBookTab.vue'
import FinancialsTab from '../../components/vehicles/tabs/FinancialsTab.vue'
import DocumentsTab from '../../components/vehicles/tabs/DocumentsTab.vue'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
// ── Provide vehicle data to child tab components ──
const vehicle = computed(() => {
const id = route.params.id as string
return vehicleStore.vehicles.find(v => v.id === id) || null
})
provide('vehicle', vehicle)
// ── Loading state ──
const isLoading = computed(() => {
return vehicleStore.isLoading || (vehicleStore.vehicles.length === 0 && !vehicle.value)
})
// ── Tab definitions (fully reactive via computed) ──
interface TabDefinition {
key: string
label: string
component: any
}
const tabDefinitions = computed<TabDefinition[]>(() => [
{ key: 'overview', label: t('vehicleDetail.tabOverview'), component: markRaw(OverviewTab) },
{ key: 'techdata', label: t('vehicleDetail.tabTechData'), component: markRaw(TechDataTab) },
{ key: 'servicebook', label: t('vehicleDetail.tabServiceBook'), component: markRaw(ServiceBookTab) },
{ key: 'financials', label: t('vehicleDetail.tabFinancials'), component: markRaw(FinancialsTab) },
{ key: 'documents', label: t('vehicleDetail.tabDocuments'), component: markRaw(DocumentsTab) },
])
const activeTab = ref<string>('overview')
const activeComponent = computed(() => {
const found = tabDefinitions.value.find(t => t.key === activeTab.value)
return found ? found.component : OverviewTab
})
/**
* Navigate back to the fleet view.
* Respects corporate vs private context.
*/
function goBack() {
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
router.push(`/organization/${authStore.user.active_organization_id}/vehicles`)
} else {
router.push('/dashboard/vehicles')
}
}
onMounted(async () => {
// Ensure vehicles are loaded — critical for direct navigation (F5 / URL paste)
if (vehicleStore.vehicles.length === 0) {
await vehicleStore.fetchVehicles()
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>