nyelvi refaktorálás előtt
This commit is contained in:
1117
.roo/history.md
1117
.roo/history.md
File diff suppressed because it is too large
Load Diff
242
docs/i18n_duplicate_key_analysis.md
Normal file
242
docs/i18n_duplicate_key_analysis.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 🌐 i18n Duplikált Kulcsok Összehasonlító Elemzése
|
||||
|
||||
**Dátum:** 2026-06-19
|
||||
**Készítette:** Rendszer-Architect
|
||||
**Cél:** Nyelvi modulok (i18n) kulcsainak felmérése, duplikációk azonosítása, optimalizálási javaslat
|
||||
|
||||
---
|
||||
|
||||
## 1. Feltárt Fájlok Áttekintése
|
||||
|
||||
Összesen **8 i18n fájl** található a projektben, két elkülönült rendszerben:
|
||||
|
||||
### 1.1 Frontend (vue-i18n, TypeScript) — `frontend/src/i18n/`
|
||||
|
||||
| Fájl | Sorok | Hatókör | Státusz |
|
||||
|------|-------|---------|---------|
|
||||
| en.ts | ~1603 | Teljes (25 szekció) | ✅ Teljes |
|
||||
| hu.ts | ~1600 | Teljes (25 szekció) | ✅ Teljes |
|
||||
| de.ts | ~83 | Részleges: menu, header, dashboard, zen, auth | ⚠️ Hiányos |
|
||||
| ro.ts | ~83 | Részleges: menu, header, dashboard, zen, auth | ⚠️ Hiányos |
|
||||
| cz.ts | ~83 | Részleges: menu, header, dashboard, zen, auth | ⚠️ Hiányos |
|
||||
| sk.ts | ~83 | Részleges: menu, header, dashboard, zen, auth | ⚠️ Hiányos |
|
||||
|
||||
### 1.2 Backend (statikus JSON) — `backend/static/locales/`
|
||||
|
||||
| Fájl | Sorok | Hatókör | Státusz |
|
||||
|------|-------|---------|---------|
|
||||
| en.json | ~76 | AUTH, SENTINEL, EMAIL, ORGANIZATION | ✅ Teljes |
|
||||
| hu.json | ~83 | AUTH, SENTINEL, EMAIL, ORGANIZATION | ⚠️ Aszimmetrikus |
|
||||
|
||||
> **⚠️ Fontos:** A frontend és backend rendszerek **teljesen elkülönülnek**. Más kulcs-sémát használnak (dot-notation vs UPPER_SNAKE_CASE), így közöttük NINCS duplikáció.
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend Kulcsok Hierarchikus Szerkezete
|
||||
|
||||
A frontend i18n rendszer 25 gyökér-szekcióra tagolódik:
|
||||
|
||||
```
|
||||
common (9 kulcs) — Általános (cancel, save, select, year, mileage, stb.)
|
||||
subscription (11) — Előfizetések
|
||||
landing (20+) — Nyitóoldal
|
||||
menu (8) — Navigációs menü
|
||||
garage (18) — Garázs felület
|
||||
vehicleDetail (20+) — Jármű részletek
|
||||
header (35+) — Fejléc
|
||||
dashboard (40+) — Irányítópult
|
||||
finance (25+) — Pénzügyi motor
|
||||
zen (2) — Zen mód
|
||||
auth (50+) — Bejelentkezés, regisztráció
|
||||
profile (60+) — Felhasználói profil
|
||||
kyc (20+) — KYC
|
||||
verifyEmail (15+) — E-mail megerősítés
|
||||
company (70+) — Cég műveletek
|
||||
organization (2) — Szervezeti név
|
||||
address (7) — Cím komponensek
|
||||
servicebook (15+) — Szervizkönyv
|
||||
vehicle (200+) — Jármű adatok
|
||||
serviceFinder (30+) — Szervizkereső
|
||||
provider (60+) — Szolgáltató adatok
|
||||
categories (4) — Kategóriák
|
||||
gamification (2) — Gamifikáció
|
||||
admin.services (20+) — Admin: szolgáltatások
|
||||
admin.packages (30+) — Admin: csomagok
|
||||
admin.users (30+) — Admin: felhasználók
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Duplikált Kulcsok Kategorizált Listája
|
||||
|
||||
### 3.1 ✅ Pontos Egyezések (Biztonságosan Összevonható)
|
||||
|
||||
Ezek a kulcsok **angolban és magyarban is azonos értéket** adnak vissza. Egyetlen közös kulccsá vonhatók össze.
|
||||
|
||||
| # | Érintett Kulcsok | EN érték | HU érték | Előfordulás |
|
||||
|---|-----------------|----------|----------|-------------|
|
||||
| 1 | common.cancel / profile.cancel / servicebook.cancel | "Cancel" | "Mégse" | **3 helyen** |
|
||||
| 2 | common.save / profile.save | "Save" | "Mentés" | **2 helyen** |
|
||||
| 3 | common.saving / servicebook.saving / vehicle.saving / admin.services.saving / admin.packages.saving | "Saving..." | "Mentés..." | **5 helyen** |
|
||||
| 4 | common.back / kyc.back / company.back | "Back" | "Vissza" | **3 helyen** |
|
||||
| 5 | common.mileage / garage.mileage / vehicleDetail.mileage | "Mileage" | "Futásteljesítmény" | **3 helyen** |
|
||||
| 6 | common.year / garage.year | "Year" | "Évjárat" | **2 helyen** |
|
||||
| 7 | common.retry / dashboard.errorRetry | "Retry" | "Újrapróbálkozás" | **2 helyen** |
|
||||
| 8 | garage.noPlate / vehicleDetail.noPlate | "No Plate" | "Nincs rendszám" | **2 helyen** |
|
||||
| 9 | garage.nextService / vehicleDetail.nextService / vehicle.nextService | "Next Service" | "Következő Szerviz" | **3 helyen** |
|
||||
| 10 | vehicleDetail.noData / vehicle.noData | "No data" | "Nincs adat" | **2 helyen** |
|
||||
| 11 | header.subtitle / dashboard.subtitle | "Your dashboard and vehicle overview" | "Irányítópultod és járműveid áttekintése" | **2 helyen ⭐** |
|
||||
| 12 | garage.backToGarage / profile.backToGarage | "Back to Garage" | "Vissza a Garázsba" | **2 helyen** |
|
||||
| 13 | header.profile / profile.title | "Profile Settings" | "Profil beállítások" | **2 helyen** |
|
||||
| 14 | dashboard.statusActive / company.fleetActive / admin.users.status_active | "Active" | "Aktív" | **3 helyen** |
|
||||
| 15 | vehicleDetail.vin / vehicle.label_vin | "VIN (Chassis Number)" | "Alvázszám (VIN)" | **2 helyen** |
|
||||
| 16 | kyc.country / company.country / profile.country | "Country" | "Ország" | **3 helyen** |
|
||||
| 17 | kyc.zipCode / company.zipCode / profile.zipCode | "ZIP Code" | "Irányítószám" | **3 helyen** |
|
||||
| 18 | kyc.city / company.city / profile.city | "City" | "Város" | **3 helyen** |
|
||||
| 19 | kyc.houseNumber / company.houseNumber / profile.houseNumber | "House Number" | "Házszám" | **3 helyen** |
|
||||
| 20 | profile.stairwell / company.stairwell | "Stairwell" | "Lépcsőház" | **2 helyen** |
|
||||
| 21 | profile.floor / company.floor | "Floor" | "Emelet" | **2 helyen** |
|
||||
| 22 | profile.door / company.door | "Door" | "Ajtó" | **2 helyen** |
|
||||
| 23 | profile.parcelId / company.parcelId | "Parcel ID" | "Helyrajzi szám" | **2 helyen** |
|
||||
| 24 | kyc.streetName / company.streetName / provider.streetNameLabel | "Street Name" | "Utca neve" | **3 helyen** |
|
||||
| 25 | kyc.streetType / company.streetType | "Type" | "Típus" | **2 helyen** |
|
||||
|
||||
> ⭐ A **#11** (header.subtitle = dashboard.subtitle) a legszembetűnőbb: **teljesen azonos mondat**, mindkét nyelven. Egyértelműen összevonandó.
|
||||
|
||||
### 3.2 ⚠️ Kis Eltéréssel (Egyedi Kontextus, Megfontolandó)
|
||||
|
||||
| # | Érintett Kulcsok | EN érték | HU érték(ek) | Megjegyzés |
|
||||
|---|-----------------|----------|-------------|------------|
|
||||
| 26 | common.cancel vs company.cancel vs admin.services.cancel | "Cancel" | "Mégse" / "Mégsem" | "Mégsem" elütés? |
|
||||
| 27 | finance.retry vs common.retry vs dashboard.errorRetry | "Retry" | "Újra" / "Újrapróbálkozás" | finance.retry rövidebb |
|
||||
| 28 | vehicleDetail.vin vs vehicle.label_vin | "VIN" / "VIN (Chassis Number)" | "Alvázszám (VIN)" | EN nem azonos |
|
||||
| 29 | company.streetType vs profile.streetType | "Type" / "Street Type" | "Típus" / "Közterület jellege" | Profile formalisabb |
|
||||
| 30 | company.email vs provider.emailLabel | "Email" / "Email" | "E-mail" / "E-mail cím" | Provider explicitabb |
|
||||
| 31 | company.phone vs provider.phoneLabel | "Phone" / "Phone" | "Telefonszám" / "Telefon" | Különböző hossz |
|
||||
| 32 | dashboard.addVehicle vs menu.addVehicle | "+ Add Vehicle" / "+ Add New Vehicle" | "+ Jármű Hozzáadása" / "+ Új Jármű Hozzáadása" | Menü-ben "New" |
|
||||
|
||||
### 3.3 🔄 Jármű Funkciók Átfedései
|
||||
|
||||
A vehicle.features (~130), vehicle.motorcycleFeatures (~70), vehicle.lcvFeatures (~40) és vehicle.hgvFeatures (~90) között jelentős átfedés.
|
||||
|
||||
#### Pontos egyezések (összevonható):
|
||||
|
||||
| Kulcs | EN | HU | Érintett szekciók |
|
||||
|-------|----|----|------------------|
|
||||
| cruise_control | "Cruise Control" | "Tempomat" | features, motorcycleFeatures, lcvFeatures |
|
||||
| alloy_wheels | "Alloy Wheels" | "Könnyűfém felni" | features, lcvFeatures |
|
||||
| garage_kept | "Garage Kept" | "Garázsban tartott" | features, motorcycleFeatures |
|
||||
| xenon_headlights | "Xenon Headlights" | "Xenon fényszóró" | features, lcvFeatures |
|
||||
| fog_lights | "Fog Lights" | "Ködlámpa" | features, lcvFeatures |
|
||||
| sunroof | "Sunroof" | "Napfénytető" | features, lcvFeatures |
|
||||
| heated_seat | "Heated Seat" | "Fűthető ülés" | motorcycleFeatures, lcvFeatures |
|
||||
| navigation | "Navigation (GPS)" | "Navigáció (GPS)" | features, hgvFeatures |
|
||||
| apple_carplay | "Apple CarPlay" | "Apple CarPlay" | features, hgvFeatures |
|
||||
| android_auto | "Android Auto" | "Android Auto" | features, hgvFeatures |
|
||||
| bluetooth | "Bluetooth" | "Bluetooth" | features, hgvFeatures |
|
||||
| wireless_charging | "Wireless Charging" | "Vezeték nélküli töltő" | features, hgvFeatures |
|
||||
| dab_radio | "DAB+ Digital Radio" | "DAB+ digitális rádió" | features, hgvFeatures |
|
||||
| premium_sound | "Premium Sound System" | "Prémium hangrendszer" | features, hgvFeatures |
|
||||
| wifi_hotspot | "WiFi Hotspot" | "WiFi Hotspot" | features, hgvFeatures |
|
||||
| adaptive_cruise | "Adaptive Cruise Control (ACC)" | "Adaptív tempomat (ACC)" | features, hgvFeatures |
|
||||
| 360_camera | "360° Camera" | "360°-os kamera" | features, hgvFeatures |
|
||||
| parking_sensors_rear | "Rear Parking Sensors" | "Hátsó parkolóérzékelők" | features, lcvFeatures |
|
||||
|
||||
#### Eltérő magyar fordítás (manuális átgondolást igényel):
|
||||
|
||||
| Kulcs | EN (features) | EN (másik) | HU (features) | HU (másik) | Forrás |
|
||||
|-------|--------------|------------|---------------|------------|--------|
|
||||
| abs | "ABS" | "ABS" | "ABS" | "ABS (blokkolásgátló)" | motorcycleFeatures |
|
||||
| gps_tracker | "GPS Tracker" | "GPS Tracker" | "GPS követő" | "GPS nyomkövető" | lcvFeatures, hgvFeatures |
|
||||
| alarm | "Alarm System" | "Alarm" | "Riasztórendszer" | "Riasztó" | motorcycleFeatures, lcvFeatures |
|
||||
| first_owner | "First Owner" | "First Owner" | "Első tulajdonos" | "Első tulajdonostól" | motorcycleFeatures |
|
||||
| service_book | "Service Book" | "Service Book" | "Szervizkönyvvel" | "Szervizkönyv" | motorcycleFeatures |
|
||||
| isofix | "ISOFIX" | "ISOFIX Child Seat Anchors" | "Isofix" | "Isofix gyerekülés rögzítés" | lcvFeatures |
|
||||
| tow_bar | "Tow Bar" | "Tow Bar" | "Vontatóhorog (vonóhorog)" | "Vontatóhorog" | lcvFeatures |
|
||||
| usb_charging | "USB Charging" | "USB Charging" | "USB töltőcsatlakozó" | "USB töltő" | hgvFeatures |
|
||||
| subwoofer | "Subwoofer" | "Subwoofer" | "Mélynyomó (Subwoofer)" | "Mélynyomó" | hgvFeatures |
|
||||
| rear_entertainment | "Rear Entertainment" | "Rear Entertainment System" | "Hátsó szórakoztató rendszer" | "Hátsó szórakoztató rendszer" | hgvFeatures |
|
||||
| traction_control | "Traction Control (TCS)" | "Traction Control" | "Kipörgésgátló (TCS)" | "Kipörgésgátló" | hgvFeatures |
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend JSON Aszimmetria
|
||||
|
||||
### en.json vs hu.json
|
||||
|
||||
| Kulcs | en.json | hu.json | Státusz |
|
||||
|-------|---------|---------|---------|
|
||||
| AUTH.ERROR.UNAUTHORIZED | ✅ "Unauthorized access" | ❌ **HIÁNYZIK** | Pótlandó hu.json-ban |
|
||||
| COMMON.SAVE_SUCCESS | ❌ **HIÁNYZIK** | ✅ "Sikeres mentés!" | Pótlandó en.json-ban |
|
||||
| SENTINEL.APPROVAL.REQUIRED | ❌ **HIÁNYZIK** | ✅ "A kérelem jóváhagyása szükséges" | Pótlandó en.json-ban |
|
||||
|
||||
A fennmaradó ~40 kulcs mindkét fájlban azonos szerkezetű.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hiányos Fordítások (de, ro, cz, sk)
|
||||
|
||||
A 4 részleges fájl mindegyike **csak 5 szekciót** fed le a 25-ből:
|
||||
|
||||
| Szekció | de.ts | ro.ts | cz.ts | sk.ts |
|
||||
|---------|-------|-------|-------|-------|
|
||||
| menu | ✅ | ✅ | ✅ | ✅ |
|
||||
| header | ✅ | ✅ | ✅ | ✅ |
|
||||
| dashboard | ✅ | ✅ | ✅ | ✅ |
|
||||
| zen | ✅ | ✅ | ✅ | ✅ |
|
||||
| auth | ✅ | ✅ | ✅ | ✅ |
|
||||
| Többi 20 szekció | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
**Hiányzó:** common, subscription, landing, garage, vehicleDetail, finance, profile, kyc, verifyEmail, company, organization, address, servicebook, vehicle, serviceFinder, provider, categories, gamification, admin
|
||||
|
||||
---
|
||||
|
||||
## 6. Statisztikai Összesítés
|
||||
|
||||
| Mérőszám | Érték |
|
||||
|----------|-------|
|
||||
| Összes i18n fájl | **8 db** (6 frontend + 2 backend) |
|
||||
| Frontend teljes kulcsok (en.ts) | **~900+** egyedi kulcs |
|
||||
| Frontend hiányos fájlok | **4 db** (de, ro, cz, sk) — csak 20% lefedettség |
|
||||
| Backend teljes kulcsok | **~55** db |
|
||||
| **Pontos duplikációk (összevonható)** | **25 csoport**, ~60 kulcs érintett |
|
||||
| **Közel-duplikációk (felülvizsgálandó)** | **7 csoport**, ~15 kulcs érintett |
|
||||
| **Jármű feature átfedések (pontos)** | **18 csoport**, ~50 kulcs érintett |
|
||||
| **Jármű feature átfedések (eltérő)** | **11 csoport**, ~25 kulcs érintett |
|
||||
| **Backend hiányzó kulcsok** | **3 db** |
|
||||
| **Összes optimalizálható kulcs** | **~150+** (a ~900-ból) ≈ **16-17%** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Optimalizálási Javaslat
|
||||
|
||||
### 7.1 Azonnal összevonható (25 csoport)
|
||||
|
||||
Javasolt elnevezési konvenció: A leggyakrabban használt kulcsot tartsuk meg (pl. common), a többit alias-ként hivatkozzuk rá.
|
||||
|
||||
### 7.2 Jármű feature-ök konszolidációja
|
||||
|
||||
Javasolt struktúra: egy központi `vehicle.features` szekció, a motorcycleFeatures/lcvFeatures/hgvFeatures pedig hivatkozzon rá. Az eltérő magyar fordításokat egységesíteni kell.
|
||||
|
||||
### 7.3 Backend hiányzó kulcsok pótlása
|
||||
|
||||
- AUTH.ERROR.UNAUTHORIZED → hozzáadandó a hu.json-hoz
|
||||
- COMMON.SAVE_SUCCESS → hozzáadandó az en.json-hoz
|
||||
- SENTINEL.APPROVAL.REQUIRED → hozzáadandó az en.json-hoz
|
||||
|
||||
### 7.4 Várakozó lista (nem sürgős)
|
||||
|
||||
A 4 hiányos fordítást (de, ro, cz, sk) csak az optimalizálás után érdemes bővíteni.
|
||||
|
||||
---
|
||||
|
||||
## 8. Összefoglaló
|
||||
|
||||
A frontend i18n rendszerben **~150 kulcs (16-17%)** duplikált vagy átfedő. Összevonásukkal:
|
||||
|
||||
- **Karbantarthatóság javul:** egy helyen módosítandó fordítás
|
||||
- **Konzisztencia nő:** nem jelenhet meg kétféleképp ugyanaz
|
||||
- **Fájlméret csökken:** ~5-10% méretcsökkenés
|
||||
- **Backend hiba javul:** 3 hiányzó kulcs pótlása
|
||||
|
||||
**A véglegesítéshez kérjük a felhasználó jóváhagyását.**
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
frontend/src/components/vehicles/tabs/DocumentsTab.vue
Normal file
16
frontend/src/components/vehicles/tabs/DocumentsTab.vue
Normal 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>
|
||||
16
frontend/src/components/vehicles/tabs/FinancialsTab.vue
Normal file
16
frontend/src/components/vehicles/tabs/FinancialsTab.vue
Normal 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>
|
||||
165
frontend/src/components/vehicles/tabs/OverviewTab.vue
Normal file
165
frontend/src/components/vehicles/tabs/OverviewTab.vue
Normal 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>
|
||||
16
frontend/src/components/vehicles/tabs/ServiceBookTab.vue
Normal file
16
frontend/src/components/vehicles/tabs/ServiceBookTab.vue
Normal 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>
|
||||
17
frontend/src/components/vehicles/tabs/TechDataTab.vue
Normal file
17
frontend/src/components/vehicles/tabs/TechDataTab.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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ü',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
150
frontend/src/views/vehicles/FleetView.vue
Normal file
150
frontend/src/views/vehicles/FleetView.vue
Normal 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>
|
||||
161
frontend/src/views/vehicles/VehicleDetailsView.vue
Normal file
161
frontend/src/views/vehicles/VehicleDetailsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user