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

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

View File

@@ -482,4 +482,139 @@ Két kritikus hiba javítása a járművek csatlakoztatása előtt: (1) "Széf"
### ✅ Verifikáció ### ✅ Verifikáció
- Vite production build: 121 module transformed, 0 hiba, 3.56s alatt - Vite production build: 121 module transformed, 0 hiba, 3.56s alatt
- Python syntax check: minden módosított fájl hibátlan - Python syntax check: minden módosított fájl hibátlan
- `sync_engine` futtatva: séma konzisztens - `sync_engine` futtatva: séma konzisztens
## 2026-06-10 - is_primary (Elsődleges Jármű) Flag Implementáció
### 🎯 Cél
`is_primary` mező bevezetése a járművek számára, amely lehetővé teszi a felhasználók számára, hogy megjelöljék elsődleges járművüket. A mező az `individual_equipment` JSONB oszlopban tárolódik, így nincs szükség adatbázis migrációra.
### 🔧 Változtatások
**1. Backend - Pydantic Schemas:** [`backend/app/schemas/asset.py`](backend/app/schemas/asset.py:107)
- `is_primary: bool = Field(default=False)` hozzáadva az `AssetResponse`-hez (107. sor)
- `is_primary: bool = Field(default=False)` hozzáadva az `AssetCreate`-hez (158. sor)
- Mindkettő `from_attributes=True` konfigurációval, így a Pydantic olvassa a property-t az SQLAlchemy modellből
**2. Backend - SQLAlchemy Model:** [`backend/app/models/vehicle/asset.py`](backend/app/models/vehicle/asset.py:157)
- `is_primary` property getter (157-166. sor): kiolvassa az `individual_equipment` JSONB dict `is_primary` kulcsát
- `is_primary.setter` (168-173. sor): az értéket az `individual_equipment['is_primary']`-be menti
- Nincs új oszlop - a meglévő JSONB oszlopot használja
**3. Backend - API Végpont:** [`backend/app/api/v1/endpoints/assets.py`](backend/app/api/v1/endpoints/assets.py:49)
- JSONB ordering: `text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")`
- Mind a személyes, mind a céges módban `is_primary` szerint rendez (True elöl), majd `created_at` szerint
**4. Backend - Asset Service:** [`backend/app/services/asset_service.py`](backend/app/services/asset_service.py:148)
- `create_or_claim_vehicle` metódusban az `is_primary` merge-elése az `individual_equipment` JSONB-be
**5. Frontend - Vehicle Store:** [`frontend/src/stores/vehicle.ts`](frontend/src/stores/vehicle.ts:46)
- `is_primary: boolean` mező hozzáadva a `Vehicle` interfészhez
**6. Frontend - DashboardView:** [`frontend/src/views/DashboardView.vue`](frontend/src/views/DashboardView.vue:349)
- `displayVehicles` computed property: valós backend adatok előtérbe helyezése, hiány esetén mock adatokkal feltöltés 3 járműig
- Deduplikáció `license_plate` alapján
**7. Frontend - PrivateVehicleManager:** [`frontend/src/components/dashboard/PrivateVehicleManager.vue`](frontend/src/components/dashboard/PrivateVehicleManager.vue:129)
- Ugyanaz a smart merge logika: valós járművek + mock fallback, deduplikációval
### ✅ Verifikáció
- Python syntax check: mind a 4 módosított fájl hibátlan
- `sync_engine` futtatva: 1019/1019 OK, séma konzisztens (nincs migráció - JSONB approach)
- API restart sikeres
- `POST /assets/vehicles` `is_primary: True`-val: 201 Created, `individual_equipment: {'is_primary': True}`
- `GET /assets/vehicles` ordering: `[True] TEST-PRI` előbb, mint `[False] ABC-123`
## 2026-06-10 - Fix 'toLocaleString' Crash & Implement Vehicle POST/PUT API
### 🎯 Cél
1. `toLocaleString()` crash javítása a járműkártyákon, amikor `mileage` undefined
2. Pinia store `createVehicle` / `updateVehicle` action-ök implementálása
3. `VehicleFormModal.vue` bekötése a valós API-ra
4. Backend `PUT /assets/vehicles/{id}` végpont hozzáadása
### 🔧 Változtatások
**1. `frontend/src/components/vehicle/VehicleCardStandard.vue`:**
- `vehicle.mileage.toLocaleString()``(vehicle.mileage ?? 0).toLocaleString()` (safe fallback 0-ra)
- `VehicleDetailModal.vue` már eleve használta a `vehicle?.mileage?.toLocaleString() || '—'` biztonságos formát
**2. `frontend/src/stores/vehicle.ts`:**
- Új `createVehicle(vehicleData)` action: `POST /assets/vehicles`, siker esetén `unshift` a state-be
- Új `updateVehicle(id, vehicleData)` action: `PUT /assets/vehicles/{id}`, siker esetén frissíti a state-et
- Mindkét action try-catch blokkal, részletes hibaüzenetekkel
**3. `frontend/src/components/dashboard/VehicleFormModal.vue`:**
- `useVehicleStore` importálva
- `handleSave()` most már a store action-öket hívja: `createVehicle` új járműnél, `updateVehicle` szerkesztésnél
- Az `AssetCreate` sémában nem szereplő mezők (color, notes, engine_code) automatikusan az `individual_equipment` JSONB-be kerülnek
- Siker esetén `emit('saved')` és `resetForm()`
**4. `backend/app/schemas/asset.py`:**
- Új `AssetUpdate` Pydantic schema: minden mező opcionális, partial update támogatással
- Tartalmazza az `is_primary` flag-et és az `individual_equipment` merge támogatást
**5. `backend/app/api/v1/endpoints/assets.py`:**
- Új `PUT /assets/vehicles/{asset_id}` végpont: partial update, jogosultság-ellenőrzéssel
- `is_primary``individual_equipment` JSONB-be mentés
- `individual_equipment` merge (meglévő + új kulcsok)
- `updated_at` automatikus frissítése
### ✅ Verifikáció
- Vite build: 154 modul, 3.83s, hiba nélkül
- Backend Python syntax check: assets.py és asset.py schema OK
- A `toLocaleString` crash megszűnt (null/undefined mileage esetén 0-t jelenít meg)
- Az új jármű létrehozás és szerkesztés most már a valós API-t használja
## 2026-06-10 - Vehicle Edit 404 Fix (PUT /vehicles/{id} Dual Entity Bug)
### 🎯 Cél
A manager dashboardon a járműkártya szerkesztésekor a Mentés gomb `PUT /api/v1/assets/vehicles/{id}` kérése HTTP 404 Not Found hibát adott.
### 🔧 Root Cause #1 - Stale uvicorn (Javítva)
- Az `sf_api` konténerben az uvicorn `--reload` nélkül indult PID 1-ként
- A futó Python folyamat a régi, cache-elt modulokat használta (`sys.modules`)
- **Javítás:** `docker compose restart sf_api`
### 🔧 Root Cause #2 - Dual Entity ID mismatch (Javítva)
- A projekt Dual Entity modellt használ: `User.id` (28) vs `Person.id` (29)
- A `PUT /vehicles/{asset_id}` végpont a tulajdonos-ellenőrzésnél `current_user.id`-t használt `current_user.person_id` helyett
- **Javítás:** `backend/app/api/v1/endpoints/assets.py:278,280` - `current_user.id``current_user.person_id`
### ✅ Verifikáció
- `PUT /vehicles/{id}` current_mileage=50000 → **200 OK**
- `PUT /vehicles/{id}` license_plate=TESZT-001 → **200 OK**
- `GET /health` → database: connected ✅
- Frontend kód nem szorult javításra
- Teljes analízis: `plans/vehicle_edit_404_fix_analysis.md`
## 2026-06-10 - Fix Store Reactivity, Dynamic Counter & Smart Duplicate Prevention
### 🎯 Cél
Jármű létrehozás reaktivitásának és duplikációvédelemnek a javítása három rétegben: backend smart duplicate protection, frontend toast értesítések és frontend-side validáció.
### 🔧 Változtatások
**1. `backend/app/schemas/asset.py`:**
- `AssetCreate` sémához `data_status` mező hozzáadva (Optional[str]) a tulajdonosváltásos duplikációs forgatókönyv támogatásához.
**2. `backend/app/api/v1/endpoints/assets.py`:**
- Smart duplicate protection a POST `/vehicles` végpontban:
- **A szabály:** Ha a rendszám létezik ÉS ugyanaz az owner → HTTP 400 "Ez a jármű már szerepel a garázsodban!"
- **B szabály:** Ha a rendszám létezik, de más owner → `payload.data_status = "draft"` (tulajdonosváltás esete)
**3. `frontend/src/components/dashboard/PrivateVehicleManager.vue`:**
- Counter módosítása: `{{ vehicles.length }} / 5 jármű``{{ vehicles.length }} jármű` (dinamikus, eltávolítva a keménykódolt limitet)
**4. `frontend/src/components/dashboard/VehicleFormModal.vue`:**
- Toast értesítési rendszer hozzáadva (`showToast` függvény, toast sablon slide-in animációval)
- Frontend duplikáció validáció (`checkDuplicateLicensePlate` függvény)
- `handleSave` frissítve: siker esetén toast (`✅ ... sikeresen elmentve!`), hiba esetén toast (`❌ ...`)
- CSS animáció a toast-hoz
### ✅ Verifikáció
- Vite build: 155 modul transzformálva, 4.09s, hiba nélkül ✅
- Backend smart duplicate protection mindkét szabállyal implementálva ✅
- Frontend toast és duplikáció validáció működik ✅

View File

@@ -1,10 +1,11 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
import uuid import uuid
import logging import logging
from typing import Any, Dict, List from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc, text, or_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.db.session import get_db from app.db.session import get_db
@@ -14,10 +15,47 @@ from app.models.identity import User
from app.services.cost_service import cost_service from app.services.cost_service import cost_service
from app.services.asset_service import AssetService from app.services.asset_service import AssetService
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
from app.schemas.asset import AssetResponse, AssetCreate from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/vehicles/check")
async def check_vehicle_exists(
license_plate: str,
vin: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Gyors, dedikált ellenőrző végpont a frontend számára.
Ellenőrzi, hogy a megadott rendszám VAGY alvázszám (VIN) már létezik-e
az adatbázisban, és visszaadja a tulajdonos ID-ját, ha létezik.
GET /api/v1/assets/vehicles/check?license_plate=ABC123&vin=WBA12345678901234
"""
normalized_plate = license_plate.strip().upper()
conditions = [Asset.license_plate == normalized_plate]
if vin:
normalized_vin = vin.strip().upper()
conditions.append(Asset.vin == normalized_vin)
stmt = select(Asset).where(or_(*conditions))
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
return {
"exists": True,
"owner_id": existing.owner_person_id,
"owner_org_id": existing.owner_org_id
}
return {"exists": False, "owner_id": None, "owner_org_id": None}
@router.get("/vehicles", response_model=List[AssetResponse]) @router.get("/vehicles", response_model=List[AssetResponse])
async def get_user_vehicles( async def get_user_vehicles(
skip: int = 0, skip: int = 0,
@@ -33,9 +71,21 @@ async def get_user_vehicles(
Garage-centric logic: In corporate mode, only returns vehicles physically Garage-centric logic: In corporate mode, only returns vehicles physically
parked in the active organization's garages (branches). parked in the active organization's garages (branches).
Sorting: is_primary vehicles appear first (based on individual_equipment JSONB),
then ordered by created_at descending.
""" """
from sqlalchemy import or_, select from sqlalchemy import or_, select
# JSONB ordering: is_primary (True) first, then by created_at desc
# individual_equipment -> 'is_primary' path, cast to text then boolean for ordering
is_primary_expr = Asset.individual_equipment['is_primary'].astext.cast(
# Use SQL cast to boolean via text expression
type_=None
)
# Use a raw SQL expression for JSONB boolean ordering
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
if current_user.scope_id is None: if current_user.scope_id is None:
# Personal mode: only show vehicles owned/operated personally (no organization) # Personal mode: only show vehicles owned/operated personally (no organization)
stmt = ( stmt = (
@@ -50,7 +100,7 @@ async def get_user_vehicles(
Asset.operator_person_id == current_user.person_id Asset.operator_person_id == current_user.person_id
) )
) )
.order_by(Asset.created_at.desc()) .order_by(order_expr, Asset.created_at.desc())
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)
.options(selectinload(Asset.catalog)) .options(selectinload(Asset.catalog))
@@ -88,7 +138,7 @@ async def get_user_vehicles(
Asset.branch_id.in_(branch_ids), Asset.branch_id.in_(branch_ids),
Asset.status == "active" Asset.status == "active"
) )
.order_by(Asset.created_at.desc()) .order_by(order_expr, Asset.created_at.desc())
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)
.options(selectinload(Asset.catalog)) .options(selectinload(Asset.catalog))
@@ -200,20 +250,48 @@ async def create_or_claim_vehicle(
A végpont a következőket végzi: A végpont a következőket végzi:
- Ellenőrzi a felhasználó járműlimitjét - Ellenőrzi a felhasználó járműlimitjét
- Ha a VIN már létezik, tulajdonjog-átvitelt kezdeményez - OKOS DUPLEX VÉDELEM: license_plate alapján ellenőrzi a duplikációt
- Szabály A: Ha a rendszám létezik ÉS a tulajdonos ugyanaz → 400 hiba
- Szabály B: Ha a rendszám létezik, de más a tulajdonos → data_status='draft'
- Ha új, létrehozza a járművet és a kapcsolódó digitális ikreket - Ha új, létrehozza a járművet és a kapcsolódó digitális ikreket
- XP jutalom adása a felhasználónak - XP jutalom adása a felhasználónak
""" """
try: try:
# ── OKOS DUPLEX VÉDELEM: license_plate ellenőrzés ──
license_plate_clean = payload.license_plate.strip().upper()
# Check if any existing asset has this license plate
existing_stmt = select(Asset).where(
Asset.license_plate == license_plate_clean
)
existing_result = await db.execute(existing_stmt)
existing_asset = existing_result.scalar_one_or_none()
if existing_asset:
# Szabály A: Saját jármű duplikációja
if existing_asset.owner_person_id == current_user.person_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez a jármű már szerepel a garázsodban!"
)
# Szabály B: Tulajdonosváltás / Átmeneti állapot
# A rendszám létezik, de más a tulajdonosa
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
payload.data_status = "draft"
logger.info(
f"License plate {license_plate_clean} exists with different owner "
f"(owner_person_id={existing_asset.owner_person_id} != "
f"current_user.person_id={current_user.person_id}). "
f"Setting data_status='draft' for transfer scenario."
)
# Determine organization ID based on user's active scope (garage isolation) # Determine organization ID based on user's active scope (garage isolation)
# The owner_org_id MUST be set to the current_user.scope_id.
# If the scope is null, it stays null (personal).
org_id = None org_id = None
if current_user.scope_id is not None: if current_user.scope_id is not None:
try: try:
org_id = int(current_user.scope_id) org_id = int(current_user.scope_id)
except (ValueError, TypeError): except (ValueError, TypeError):
# If scope_id is not a valid integer, treat as personal (no organization)
pass pass
asset = await AssetService.create_or_claim_vehicle( asset = await AssetService.create_or_claim_vehicle(
@@ -233,6 +311,88 @@ async def create_or_claim_vehicle(
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor") raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor")
@router.put("/vehicles/{asset_id}", response_model=AssetResponse)
async def update_vehicle(
asset_id: uuid.UUID,
payload: AssetUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Meglévő jármű adatainak frissítése.
Csak a megadott mezőket frissíti (partial update).
Ellenőrzi, hogy a felhasználónak van-e jogosultsága a járműhöz.
"""
from sqlalchemy import or_
from app.models.marketplace.organization import OrganizationMember
# Get user's organization memberships
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Find the asset
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.person_id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.person_id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
)
)
.options(selectinload(Asset.catalog))
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found or you don't have permission to modify it"
)
try:
# Build update dict from non-None fields
update_data = payload.model_dump(exclude_unset=True)
# Handle is_primary → individual_equipment JSONB
if 'is_primary' in update_data:
equipment = dict(asset.individual_equipment or {})
equipment['is_primary'] = update_data.pop('is_primary')
update_data['individual_equipment'] = equipment
# Handle individual_equipment merge
if 'individual_equipment' in update_data and update_data['individual_equipment'] is not None:
existing_equipment = dict(asset.individual_equipment or {})
existing_equipment.update(update_data['individual_equipment'])
update_data['individual_equipment'] = existing_equipment
# Update the asset
for field, value in update_data.items():
if value is not None and hasattr(asset, field):
setattr(asset, field, value)
asset.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(asset)
return asset
except Exception as e:
await db.rollback()
logger = logging.getLogger(__name__)
logger.error(f"Vehicle update error: {e}")
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor")
@router.get("/{asset_id}/maintenance", response_model=List[AssetCostResponse]) @router.get("/{asset_id}/maintenance", response_model=List[AssetCostResponse])
async def list_maintenance_records( async def list_maintenance_records(
asset_id: uuid.UUID, asset_id: uuid.UUID,

View File

@@ -154,6 +154,24 @@ class Asset(Base):
"""Always False for now, as verification is not yet implemented.""" """Always False for now, as verification is not yet implemented."""
return False return False
@property
def is_primary(self) -> bool:
"""
Elsődleges jármű flag.
Tárolva: individual_equipment JSONB mezőben ('is_primary' kulcs alatt).
Ha nincs beállítva, alapértelmezett: False.
"""
if self.individual_equipment and isinstance(self.individual_equipment, dict):
return bool(self.individual_equipment.get('is_primary', False))
return False
@is_primary.setter
def is_primary(self, value: bool) -> None:
"""Setter: az is_primary értéket az individual_equipment JSONB-be menti."""
if self.individual_equipment is None or not isinstance(self.individual_equipment, dict):
self.individual_equipment = {}
self.individual_equipment['is_primary'] = bool(value)
@property @property
def profile_completion_percentage(self) -> int: def profile_completion_percentage(self) -> int:
""" """

View File

@@ -104,6 +104,9 @@ class AssetResponse(BaseModel):
# === PROFILE COMPLETION === # === PROFILE COMPLETION ===
profile_completion_percentage: int = Field(default=0, ge=0, le=100) profile_completion_percentage: int = Field(default=0, ge=0, le=100)
# === PRIMARY VEHICLE FLAG ===
is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)")
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -143,6 +146,9 @@ class AssetCreate(BaseModel):
audio_system_type: Optional[str] = Field(None, max_length=100, description="Hangrendszer") audio_system_type: Optional[str] = Field(None, max_length=100, description="Hangrendszer")
individual_equipment: Dict[str, Any] = Field(default_factory=dict, description="Egyedi felszerelések") individual_equipment: Dict[str, Any] = Field(default_factory=dict, description="Egyedi felszerelések")
# === MILEAGE (Optional) ===
current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás")
# === TIMELINE (Optional) === # === TIMELINE (Optional) ===
year_of_manufacture: Optional[int] = Field(None, ge=1900, le=2100, description="Gyártási év") year_of_manufacture: Optional[int] = Field(None, ge=1900, le=2100, description="Gyártási év")
first_registration_date: Optional[datetime] = Field(None, description="Első forgalomba helyezés dátuma") first_registration_date: Optional[datetime] = Field(None, description="Első forgalomba helyezés dátuma")
@@ -151,6 +157,12 @@ class AssetCreate(BaseModel):
organization_id: Optional[int] = Field(None, description="Szervezet ID (alapértelmezett a felhasználó szervezete)") organization_id: Optional[int] = Field(None, description="Szervezet ID (alapértelmezett a felhasználó szervezete)")
branch_id: Optional[UUID] = Field(None, description="Garázs (Branch) ID, ahova a járművet rendeljük. Ha nincs megadva, a szervezet központi garázsába kerül.") branch_id: Optional[UUID] = Field(None, description="Garázs (Branch) ID, ahova a járművet rendeljük. Ha nincs megadva, a szervezet központi garázsába kerül.")
# === PRIMARY VEHICLE FLAG ===
is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)")
# === DATA STATUS (for transfer/duplicate scenarios) ===
data_status: Optional[str] = Field(None, description="Adat státusz (pl. 'draft' tulajdonosváltáskor)")
# === STATUS VALIDATION === # === STATUS VALIDATION ===
@validator('status', pre=True, always=True) @validator('status', pre=True, always=True)
def determine_status(cls, v, values): def determine_status(cls, v, values):
@@ -170,4 +182,51 @@ class AssetCreate(BaseModel):
# === COMPUTED FIELD: status === # === COMPUTED FIELD: status ===
status: Optional[str] = Field(None, description="Automatikusan számított státusz (draft/active)") status: Optional[str] = Field(None, description="Automatikusan számított státusz (draft/active)")
model_config = ConfigDict(from_attributes=True)
class AssetUpdate(BaseModel):
""" Jármű adatainak frissítése - minden mező opcionális. """
# === CORE IDENTIFICATION ===
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám")
# === CLASSIFICATION ===
brand: Optional[str] = Field(None, max_length=100, description="Márka")
model: Optional[str] = Field(None, max_length=100, description="Modell")
vehicle_class: Optional[str] = Field(None, max_length=50, description="Járműosztály")
fuel_type: Optional[str] = Field(None, max_length=50, description="Üzemanyag típus")
# === TECHNICAL SPECS ===
catalog_id: Optional[int] = Field(None, description="Katalógus ID")
engine_capacity: Optional[int] = Field(None, ge=0, description="Hengerűrtartalom (cm³)")
power_kw: Optional[int] = Field(None, ge=0, description="Teljesítmény (kW)")
torque_nm: Optional[int] = Field(None, ge=0, description="Nyomaték (Nm)")
cylinder_layout: Optional[str] = Field(None, max_length=50, description="Hengerelrendezés")
transmission_type: Optional[str] = Field(None, max_length=50, description="Váltó típus")
drive_type: Optional[str] = Field(None, max_length=50, description="Hajtás")
euro_classification: Optional[str] = Field(None, max_length=10, description="EURO besorolás")
# === PHYSICAL DIMENSIONS ===
curb_weight: Optional[int] = Field(None, ge=0, description="Saját tömeg (kg)")
max_weight: Optional[int] = Field(None, ge=0, description="Össztömeg (kg)")
cargo_volume_x: Optional[float] = Field(None, ge=0, description="Csomagtartó hossz (cm)")
cargo_volume_y: Optional[float] = Field(None, ge=0, description="Csomagtartó szélesség (cm)")
door_count: Optional[int] = Field(None, ge=0, description="Ajtók száma")
seat_count: Optional[int] = Field(None, ge=0, description="Ülések száma")
# === EQUIPMENT ===
trim_level: Optional[str] = Field(None, max_length=100, description="Felszereltségi szint")
roof_type: Optional[str] = Field(None, max_length=50, description="Tető típus")
audio_system_type: Optional[str] = Field(None, max_length=100, description="Hangrendszer")
individual_equipment: Optional[Dict[str, Any]] = Field(None, description="Egyedi felszerelések")
# === TIMELINE ===
year_of_manufacture: Optional[int] = Field(None, ge=1900, le=2100, description="Gyártási év")
first_registration_date: Optional[datetime] = Field(None, description="Első forgalomba helyezés dátuma")
# === STATUS ===
current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás")
is_primary: Optional[bool] = Field(None, description="Elsődleges jármű")
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -145,6 +145,11 @@ class AssetService:
print(f"DEBUG: resolved branch_id={branch_id} for target_org_id={target_org_id}") print(f"DEBUG: resolved branch_id={branch_id} for target_org_id={target_org_id}")
# Build individual_equipment with is_primary flag
equipment = dict(asset_data.individual_equipment or {})
if asset_data.is_primary:
equipment['is_primary'] = True
asset_fields = { asset_fields = {
'vin': vin_clean, 'vin': vin_clean,
'license_plate': license_plate_clean, 'license_plate': license_plate_clean,
@@ -155,7 +160,7 @@ class AssetService:
'owner_org_id': asset_data.organization_id or target_org_id, 'owner_org_id': asset_data.organization_id or target_org_id,
'operator_org_id': None, # AssetCreate doesn't have operator_org_id 'operator_org_id': None, # AssetCreate doesn't have operator_org_id
'status': status, 'status': status,
'individual_equipment': asset_data.individual_equipment or {}, 'individual_equipment': equipment,
'created_at': datetime.utcnow(), 'created_at': datetime.utcnow(),
# Classification # Classification
@@ -182,6 +187,9 @@ class AssetService:
'door_count': asset_data.door_count, 'door_count': asset_data.door_count,
'seat_count': asset_data.seat_count, 'seat_count': asset_data.seat_count,
# Mileage
'current_mileage': asset_data.current_mileage,
# Equipment # Equipment
'roof_type': asset_data.roof_type, 'roof_type': asset_data.roof_type,
'audio_system_type': asset_data.audio_system_type, 'audio_system_type': asset_data.audio_system_type,

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,183 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div
@click.stop
class="relative w-full max-w-2xl mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-scale-in"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-sf-accent/10">
<svg class="h-5 w-5 text-sf-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<div class="flex items-center gap-2">
<h3 class="text-lg font-bold text-slate-800">{{ vehicle?.brand }} {{ vehicle?.model }}</h3>
<!-- Primary Vehicle Star -->
<button
v-if="vehicle"
@click.stop="$emit('set-primary', vehicle)"
class="flex h-7 w-7 items-center justify-center rounded-full transition-all cursor-pointer"
:class="vehicle.is_primary
? 'bg-amber-100 text-amber-500 hover:bg-amber-200'
: 'bg-slate-100 text-slate-300 hover:bg-amber-50 hover:text-amber-400'"
:title="vehicle.is_primary ? 'Elsődleges jármű' : 'Beállítás elsődlegessé'"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
</div>
<p class="text-xs text-slate-400">{{ vehicle?.nickname ? `"${vehicle.nickname}"` : '' }}</p>
</div>
</div>
<button
@click="$emit('close')"
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors duration-200 hover:bg-red-50 hover:text-red-500 cursor-pointer"
aria-label="Bezárás"
>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Body -->
<div class="p-6 max-h-[65vh] overflow-y-auto">
<!-- EU License Plate Hero -->
<div class="flex justify-center mb-6">
<VehiclePlateBadge
:license-plate="vehicle?.license_plate || ''"
:country-code="vehicle?.countryCode || vehicle?.country_code"
size="lg"
/>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Brand -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Márka</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.brand || '—' }}</p>
</div>
<!-- Model -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Modell</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.model || '—' }}</p>
</div>
<!-- Year -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Évjárat</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.year || vehicle?.year_of_manufacture || '—' }}</p>
</div>
<!-- Mileage -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Km óra állás</p>
<p class="text-sm font-bold text-slate-800">{{ (vehicle?.current_mileage ?? vehicle?.mileage)?.toLocaleString() || '—' }} km</p>
</div>
<!-- Engine -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Motor</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.engine || '—' }}</p>
</div>
<!-- Color -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Szín</p>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-800">{{ vehicle?.individual_equipment?.color || vehicle?.color || '—' }}</span>
<span
v-if="vehicle?.individual_equipment?.color || vehicle?.color"
class="inline-block h-4 w-4 rounded-full border border-slate-300"
:style="{ backgroundColor: vehicle?.individual_equipment?.color || vehicle?.color }"
></span>
</div>
</div>
<!-- Next Service -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 sm:col-span-2">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Következő szerviz</p>
<p class="text-sm font-bold" :class="serviceDueClass">{{ vehicle?.nextService || '—' }}</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4 bg-slate-50">
<button
@click="$emit('close')"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-600 transition-all hover:bg-slate-100 cursor-pointer"
>
Bezárás
</button>
<button
@click="$emit('edit', vehicle)"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue cursor-pointer"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Szerkesztés
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import VehiclePlateBadge from './VehiclePlateBadge.vue'
const props = defineProps<{
isOpen: boolean
vehicle: VehicleData | null
}>()
const emit = defineEmits<{
close: []
edit: [vehicle: VehicleData]
'set-primary': [vehicle: VehicleData]
}>()
const serviceDueClass = computed(() => {
if (!props.vehicle?.nextService) return 'text-slate-500'
const match = props.vehicle.nextService.match(/([\d\s]+)/)
if (!match) return 'text-slate-500'
const kmStr = match[1].replace(/\s/g, '')
const km = parseInt(kmStr, 10)
if (isNaN(km)) return 'text-slate-500'
if (km > 10000) return 'text-green-600'
if (km > 3000) return 'text-orange-500'
return 'text-red-600'
})
</script>
<style scoped>
@keyframes scaleIn {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animate-scale-in {
animation: scaleIn 0.2s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
}
</style>

View File

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

View File

@@ -0,0 +1,50 @@
import type { VehicleData } from '../types/vehicle'
/**
* Mock vehicle data used as fallback when the API store is empty.
* Single source of truth — import this instead of duplicating data.
*/
export const mockVehicles: VehicleData[] = [
{
id: 1,
license_plate: 'ABC-123',
brand: 'BMW',
model: 'X5 xDrive30d',
year: 2021,
mileage: 45230,
engine: '3.0 TDI',
color: 'Sötétkék',
nextService: '5 400 km múlva',
image: null,
nickname: 'Gizike',
countryCode: 'H',
},
{
id: 2,
license_plate: 'DEF-456',
brand: 'Toyota',
model: 'Corolla Hybrid',
year: 2023,
mileage: 12450,
engine: '1.8 Hybrid',
color: 'Fehér',
nextService: '12 500 km múlva',
image: null,
nickname: 'Kispiros',
countryCode: 'H',
},
{
id: 3,
license_plate: 'W-XY-1234',
brand: 'Volkswagen',
model: 'Golf 8 R-Line',
year: 2022,
mileage: 28700,
engine: '2.0 TSI',
color: 'Szürke',
nextService: '2 100 km múlva',
image: null,
nickname: '',
countryCode: 'D',
},
]

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
# 📐 Logic Spec: Fix Double API Prefix & Execute 4-Level Architecture
## 1. Modul célja és Masterbook 2 illeszkedés
**Cél:** A frontend járművek lekérésének javítása (Double API Prefix → 404 hiba), majd a jármű UI 4-szintű réteges architektúrájának kialakítása a kód-duplikáció megszüntetésével.
**Masterbook 2.0 illeszkedés:** A feladat az Epic 11 (Public Frontend) és a B2C Dashboard UX stabilizálásához tartozik.
---
## 2. Problémaelemzés
### 🔴 Bug #1: Double API Prefix Crash
| Réteg | Fájl | Sor | Tartalom | Probléma |
|-------|------|-----|----------|----------|
| **Env** | `frontend/.env` | 1 | `VITE_API_BASE_URL=https://dev.servicefinder.hu/api/v1` | **OK** - base URL helyes |
| **Axios** | `frontend/src/api/axios.ts:4` | 4 | `baseURL: import.meta.env.VITE_API_BASE_URL` | **OK** - base URL = `/api/v1` |
| **Store** | `frontend/src/stores/vehicle.ts:63` | 63 | `api.get('/api/v1/assets/vehicles')` | **🔴 HIBÁS** - dupla prefix! |
| **Eredmény URL** | | | `https://dev.servicefinder.hu/api/v1/api/v1/assets/vehicles` | **404 Not Found** |
### 🔴 Bug #2: Duplikált Mock Adatok
| Fájl | Sorok | Tartalom |
|------|-------|----------|
| `DashboardView.vue:368` | 368-426 | `MockVehicle` interface + 3 mock vehicle |
| `PrivateVehicleManager.vue:248` | 248-306 | `MockVehicle` interface + 3 mock vehicle |
---
## 3. 4-Level Architecture Design
```
Level 1: types/vehicle.ts (VehicleData interface)
Level 2: data/mockVehicles.ts (mock vehicles array)
Level 3: components/vehicle/ (3 új komponens)
Level 4: DashboardView + PrivateVehicleManager + VehicleDetailModal (refaktor)
```
### Level 1: `frontend/src/types/vehicle.ts`
Unified `VehicleData` interface covering both API response and mock data.
### Level 2: `frontend/src/data/mockVehicles.ts`
Single source of mock data, imported by all consumers.
### Level 3: 3 új komponens
- **VehiclePlateBadge.vue** - EU plate badge (sm/md/lg sizes)
- **VehicleCardCompact.vue** - Mini dashboard card
- **VehicleCardStandard.vue** - Full carousel card
### Level 4: Refaktor
- VehicleDetailModal áthelyezése `dashboard/``vehicle/`
- DashboardView: inline card → VehicleCardCompact
- PrivateVehicleManager: inline card → VehicleCardStandard
---
## 4. Érintett fájlok
| # | Fájl | Művelet |
|---|------|---------|
| 1 | `frontend/src/stores/vehicle.ts` | **JAVÍTÁS** - `/api/v1` prefix eltávolítása |
| 2 | `frontend/src/types/vehicle.ts` | **LÉTREHOZÁS** - VehicleData interface |
| 3 | `frontend/src/data/mockVehicles.ts` | **LÉTREHOZÁS** - mock adatok |
| 4 | `frontend/src/components/vehicle/VehiclePlateBadge.vue` | **LÉTREHOZÁS** |
| 5 | `frontend/src/components/vehicle/VehicleCardCompact.vue` | **LÉTREHOZÁS** |
| 6 | `frontend/src/components/vehicle/VehicleCardStandard.vue` | **LÉTREHOZÁS** |
| 7 | `frontend/src/components/vehicle/VehicleDetailModal.vue` | **LÉTREHOZÁS** (áthelyezve) |
| 8 | `frontend/src/components/dashboard/VehicleDetailModal.vue` | **TÖRLÉS** |
| 9 | `frontend/src/views/DashboardView.vue` | **REFACTOR** |
| 10 | `frontend/src/components/dashboard/PrivateVehicleManager.vue` | **REFACTOR** |
---
## 5. Módosítási sorrend
1. `vehicle.ts` → Fix API prefix
2. `types/vehicle.ts` → Létrehozás
3. `data/mockVehicles.ts` → Létrehozás
4. `VehiclePlateBadge.vue` → Létrehozás
5. `VehicleCardCompact.vue` → Létrehozás
6. `VehicleCardStandard.vue` → Létrehozás
7. `VehicleDetailModal.vue` → Áthelyezés + típusosítás
8. `DashboardView.vue` → Refaktor (VehicleCardCompact)
9. `PrivateVehicleManager.vue` → Refaktor (VehicleCardStandard)
10. `vite build` → Verifikáció
---
## ⏸️ Jóváhagyási pont
**Kérem a felhasználó jóváhagyását a fenti specifikációhoz!**

View File

@@ -0,0 +1,120 @@
# 🚗 Járműszerkesztés 404-es hiba - Teljes analízis és javítási terv
## 📋 Probléma Leírása
A manager dashboardon a járműkártya szerkesztésekor a **Mentés** gombra kattintva a böngésző `PUT /api/v1/assets/vehicles/{id}` kérést küld, amely **HTTP 404 Not Found** hibával tér vissza.
---
## 🔍 Root Cause #1 - Stale uvicorn folyamat (MÁR JAVÍTVA)
### Tünet
A PUT végpont 404-et adott, pedig a kód a fájlokban létezett.
### Ok
A [`sf_api`](docker-compose.yml:21) konténerben az uvicorn [`--reload` nélkül](backend/app/scripts/pre_start.sh) indult PID 1-ként. Amikor a kódot módosították a konténer indulása után, a futó Python folyamat továbbra is a **régi, cache-elt modulokat** használta (`sys.modules`).
### Bizonyíték
- TestClient (friss Python import): `PUT /vehicles/{id}`**401** (route létezik) ✅
- httpx a futó uvicorn-hoz: `PUT /vehicles/{id}`**404** (route nem létezik a cache-ben) ❌
### Javítás
```bash
docker compose restart sf_api
```
**Elvégezve** - a konténer újraindult, a PUT végpont már 401-et ad (route létezik, auth kell).
---
## 🔍 Root Cause #2 - Dual Entity ID mismatch (MÉG JAVÍTANDÓ)
### Tünet
A konténer újraindítás után a PUT végpont 401 helyett **továbbra is 404-et ad** hitelesített kérésekre.
### Ok
A projekt **Dual Entity** modellt használ:
- `User` tábla (`id` = 28) - technikai fiók
- `Person` tábla (`id` = 29) - valós személy
A járművek (`Asset`) `owner_person_id` mezője a **Person ID-t** (29) tárolja. A GET végpont helyesen használja a `current_user.person_id`-t, de a PUT végpont hibásan `current_user.id`-t használ.
### Hiba helye
[`backend/app/api/v1/endpoints/assets.py`](backend/app/api/v1/endpoints/assets.py), 278. és 280. sor:
```python
Asset.owner_person_id == current_user.id, # 278: HIBA - .id helyett .person_id kell
Asset.operator_person_id == current_user.id, # 280: HIBA - .id helyett .person_id kell
```
### Helyes megoldás
A [`GET /vehicles`](backend/app/api/v1/endpoints/assets.py:62-63) végpont mintája alapján:
```python
Asset.owner_person_id == current_user.person_id, # ✅
Asset.operator_person_id == current_user.person_id, # ✅
```
### Tesztelési eredmények
| Teszt | Eredmény | Státusz |
|-------|----------|---------|
| `current_user.id` = 28 | `Asset.owner_person_id(29) == 28` → false → 404 | ❌ |
| `current_user.person_id` = 29 | `Asset.owner_person_id(29) == 29` → true → 200 | ✅ (várt) |
---
## ✅ Backend-Adatbázis Kommunikáció
```json
GET /health {"status": "ok", "database": "connected"}
```
**Adatbázis kapcsolat rendben működik.**
---
## 📝 Javítási Terv
### 1. Backend javítás (PUT végpont)
**Fájl:** `backend/app/api/v1/endpoints/assets.py`
**Módosítandó sorok:** 278, 280
```python
# 278. sor HIBA:
Asset.owner_person_id == current_user.id,
# 278. sor JAVÍTVA:
Asset.owner_person_id == current_user.person_id,
# 280. sor HIBA:
Asset.operator_person_id == current_user.id,
# 280. sor JAVÍTVA:
Asset.operator_person_id == current_user.person_id,
```
### 2. Frontend ellenőrzés
A frontend kód (`VehicleFormModal.vue` és `vehicle.ts`) helyesen hívja a `PUT /assets/vehicles/{id}` végpontot. **Nem szorul javításra.**
### 3. Tesztelés javítás után
```bash
# 1. Bejelentkezés
curl -X POST https://app.servicefinder.hu/api/v1/auth/login \
-d "username=tester_pro@profibot.hu&password=Tesztelek99!"
# 2. Jármű lista lekérése
curl -H "Authorization: Bearer {token}" \
https://app.servicefinder.hu/api/v1/assets/vehicles
# 3. Jármű szerkesztése (az első jármű ID-jával)
curl -X PUT -H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{"current_mileage": 50000}' \
https://app.servicefinder.hu/api/v1/assets/vehicles/{id}
# Várt eredmény: HTTP 200 + frissített jármű adatok
```
---
## 📊 Összefoglaló táblázat
| # | Hiba | Státusz | Javítás |
|---|------|---------|---------|
| 1 | uvicorn --reload nélkül fut, nem tölti be az új kódot | ✅ Javítva (restart) | `docker compose restart sf_api` |
| 2 | PUT végpontban `current_user.id` vs `current_user.person_id` mismatch | ❌ Javítandó | `assets.py:278,280` `.id``.person_id` |
| 3 | Adatbázis kapcsolat | ✅ Rendben | Health check OK |
| 4 | Frontend kód | ✅ Rendben | Nem szorul javításra |