egyedi jármű szerkesztés előtti mentés

This commit is contained in:
Roo
2026-06-12 07:56:15 +00:00
parent 0a3fd8de74
commit ef8df9608c
29 changed files with 3863 additions and 396 deletions

View File

@@ -618,3 +618,25 @@ Jármű létrehozás reaktivitásának és duplikációvédelemnek a javítása
- 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 ✅
## 2026-06-12 - Kettős Védelem (Double-Shield) Organization Assignment for Assets
### 🎯 Cél
B2C járművek létrehozásakor a `owner_org_id` soha ne kerüljön NULL értékkel az adatbázisba. Kétvédelmes architektúra: frontend "Silent" injekció + backend "Iron Door" fallback.
### 🔧 Változtatások
**`backend/app/api/v1/endpoints/assets.py`:**
- **Lines 360-394**: 3-szintű org_id feloldás a `create_or_claim_vehicle` végpontban:
1. PRIORITÁS: `payload.organization_id` (frontend által küldött)
2. FALLBACK: `current_user.scope_id` (aktív scope)
3. VASÁJTÓ: `fleet.organization_members` tábla lekérdezése a felhasználó első elérhető szervezetéért
**`frontend/src/components/dashboard/VehicleFormModal.vue`:**
- **Line 716**: `useAuthStore` import hozzáadva
- **Line 720**: `authStore` példányosítva
- **Lines 1200-1211**: "Silent" org_id injekció a `handleSave()`-ben: `authStore.user?.active_organization_id``authStore.myOrganizations[0]?.organization_id`
### ✅ Verifikáció
- **Backend Syntax**: ✅ OK
- **Sync Engine**: 1026/1026 elem szinkronban ✅
- **AssetCreate schema**: `organization_id: Optional[int] = Field(None)` már opcionális volt, nem kellett módosítani

View File

@@ -4,7 +4,7 @@ from app.api.v1.endpoints import (
auth, catalog, assets, organizations, documents,
services, admin, expenses, evidence, social, security,
billing, finance_admin, analytics, vehicles, system_parameters,
gamification, translations, users, reports
gamification, translations, users, reports, dictionaries
)
api_router = APIRouter()
@@ -29,3 +29,4 @@ api_router.include_router(gamification.router, prefix="/gamification", tags=["Ga
api_router.include_router(translations.router, prefix="/translations", tags=["i18n"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])

View File

@@ -4,6 +4,7 @@ import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, text, or_
from sqlalchemy.orm import selectinload
@@ -17,11 +18,81 @@ from app.services.asset_service import AssetService
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
class ArchiveVehicleRequest(BaseModel):
"""Payload a jármű archiválásához (Soft Delete)."""
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
archive_reason: str = Field(..., min_length=1, max_length=100, description="Eltávolítás oka")
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/vehicles/quota-status")
async def get_vehicle_quota_status(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Preemptive quota check endpoint for the frontend.
Returns whether the user can add a new vehicle BEFORE they start the wizard.
GET /api/v1/assets/vehicles/quota-status
Returns:
{ "can_add": bool, "current_count": int, "limit": int }
"""
try:
# Determine organization ID based on user's active scope (garage isolation)
org_id = None
if current_user.scope_id is not None:
try:
org_id = int(current_user.scope_id)
except (ValueError, TypeError):
pass
# Get the user's vehicle limit
allowed_limit = await AssetService.get_user_vehicle_limit(
db, current_user.id, org_id
)
# SAFETY: Ensure limit is at least 1
allowed_limit = max(allowed_limit or 1, 1)
# Count only active vehicles (draft vehicles don't count toward the limit)
from sqlalchemy import func
if org_id is not None:
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id,
Asset.owner_person_id == current_user.person_id,
Asset.status == "active"
)
else:
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id.is_(None),
Asset.owner_person_id == current_user.person_id,
Asset.status == "active"
)
current_count = (await db.execute(count_stmt)).scalar() or 0
can_add = current_count < allowed_limit
logger.info(
f"Quota check for user {current_user.id} (org={org_id}): "
f"current_count={current_count}, allowed_limit={allowed_limit}, can_add={can_add}"
)
return {
"can_add": can_add,
"current_count": current_count,
"limit": allowed_limit
}
except Exception as e:
logger.error(f"Quota status check error for user {current_user.id}: {e}")
# Fail open — if we can't check quota, allow the user to proceed
return {"can_add": True, "current_count": 0, "limit": 9999}
@router.get("/vehicles/check")
async def check_vehicle_exists(
license_plate: str,
@@ -286,14 +357,42 @@ async def create_or_claim_vehicle(
f"Setting data_status='draft' for transfer scenario."
)
# Determine organization ID based on user's active scope (garage isolation)
org_id = None
if current_user.scope_id is not None:
# ── 🛡️ KETTŐS VÉDELEM: Szervezet hozzárendelés ──
# 1. PRIORITÁS: A frontend által küldött organization_id (ha van)
# 2. FALLBACK: A felhasználó aktív scope-ja (scope_id)
# 3. VASÁJTÓ: Ha egyik sincs, lekérdezzük a fleet.organization_members táblából
org_id = payload.organization_id
if org_id is None and current_user.scope_id is not None:
try:
org_id = int(current_user.scope_id)
except (ValueError, TypeError):
pass
# VASÁJTÓ FALLBACK: Ha még mindig nincs org_id, keressük meg a felhasználó
# első elérhető szervezetét a fleet.organization_members táblában
if org_id is None:
from app.models.marketplace.organization import OrganizationMember
from sqlalchemy import select
member_stmt = (
select(OrganizationMember.organization_id)
.where(OrganizationMember.user_id == current_user.id)
.limit(1)
)
member_result = await db.execute(member_stmt)
member_row = member_result.first()
if member_row:
org_id = member_row[0]
logger.info(
f"Iron Door fallback: assigned org_id={org_id} from "
f"organization_members for user {current_user.id}"
)
else:
logger.warning(
f"Iron Door fallback: user {current_user.id} has no "
f"organization memberships — asset will be created without org"
)
asset = await AssetService.create_or_claim_vehicle(
db=db,
user_id=current_user.id,
@@ -592,3 +691,45 @@ async def create_maintenance_record(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error while creating maintenance record"
)
@router.post("/vehicles/{vehicle_id}/archive", response_model=AssetResponse)
async def archive_vehicle(
vehicle_id: uuid.UUID,
payload: ArchiveVehicleRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Jármű biztonságos kivezetése (Strict Soft Delete).
POST /api/v1/assets/vehicles/{vehicle_id}/archive
A végpont:
1. Frissíti a jármű current_mileage értékét a megadott final_mileage-ra
2. Átállítja a státuszt 'archived'-ra
3. Nullázza a tulajdonosi mezőket (owner_person_id, owner_org_id)
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
5. Naplózza a biztonsági auditba
Payload:
- final_mileage: int (utolsó km óra állás)
- archive_reason: string (Eladás, Gazdasági totálkár / Bontás, Lízing/Bérlet lejárta, Téves rögzítés, Egyéb)
"""
try:
asset = await AssetService.archive_vehicle(
db=db,
asset_id=vehicle_id,
user_id=current_user.id,
final_mileage=payload.final_mileage,
archive_reason=payload.archive_reason,
)
return asset
except HTTPException:
raise
except Exception as e:
logger.error(f"Vehicle archive error for {vehicle_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Belső szerverhiba a jármű archiválásakor"
)

View File

@@ -1,8 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.services.asset_service import AssetService
from app.api import deps
from app.models.vehicle import BodyTypeDictionary
from typing import List, Optional
router = APIRouter()
@@ -75,3 +77,27 @@ async def list_engines(
"factory_data": e.factory_data
} for e in engines
]
# Secured endpoint: Closed premium ecosystem
@router.get("/body-types")
async def list_body_types(
vehicle_class: Optional[str] = Query(None, description="Szűrés járműosztályra (pl. personal, motorcycle, light_commercial)"),
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user)
):
"""Karosszéria-típus szótár lekérése. Opcionálisan szűrhető vehicle_class alapján."""
stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_hu)
if vehicle_class:
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
result = await db.execute(stmt)
rows = result.scalars().all()
return [
{
"id": r.id,
"vehicle_class": r.vehicle_class,
"code": r.code,
"name_hu": r.name_hu,
}
for r in rows
]

View File

@@ -0,0 +1,68 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/dictionaries.py
"""
Szótárak és katalógusok végpontjai.
- GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.api import deps
from app.models.vehicle import CostCategory
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/cost-categories")
async def list_cost_categories(
visibility: Optional[str] = Query(None, description="Szűrés láthatóságra: b2c, b2b, both, internal"),
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user)
):
"""
Költségkategóriák lekérése hierarchikus struktúrában.
Opcionálisan szűrhető visibility alapján (pl. ha a frontend csak a 'b2c' és 'both' kategóriákat akarja).
"""
stmt = select(CostCategory).order_by(CostCategory.name)
if visibility:
# Ha 'b2c' van megadva, akkor a 'b2c' ÉS 'both' láthatóságú kategóriákat adjuk vissza
if visibility == "b2c":
stmt = stmt.where(CostCategory.visibility.in_(["b2c", "both"]))
elif visibility == "b2b":
stmt = stmt.where(CostCategory.visibility.in_(["b2b", "both"]))
else:
stmt = stmt.where(CostCategory.visibility == visibility)
result = await db.execute(stmt)
categories = result.scalars().all()
# Hierarchikus struktúra építése
category_map = {}
root_categories = []
for cat in categories:
cat_dict = {
"id": cat.id,
"code": cat.code,
"name": cat.name,
"description": cat.description,
"parent_id": cat.parent_id,
"visibility": cat.visibility,
"accounting_code": cat.accounting_code,
"is_system": cat.is_system,
"children": [],
}
category_map[cat.id] = cat_dict
for cat in categories:
cat_dict = category_map[cat.id]
if cat.parent_id and cat.parent_id in category_map:
category_map[cat.parent_id]["children"].append(cat_dict)
else:
root_categories.append(cat_dict)
return root_categories

View File

@@ -1,4 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/evidence.py
import logging
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text
@@ -6,18 +7,25 @@ from app.api.deps import get_db, get_current_user
from app.models.identity import User
from app.models import Asset # JAVÍTVA: Asset modell
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/scan-registration")
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
stmt_limit = text("SELECT (value->>:plan)::int FROM system.system_parameters WHERE key = 'VEHICLE_LIMIT'")
res = await db.execute(stmt_limit, {"plan": current_user.subscription_plan or "free"})
max_allowed = res.scalar() or 1
plan_key = (current_user.subscription_plan or "free").lower()
res = await db.execute(stmt_limit, {"plan": plan_key})
max_allowed = max(res.scalar() or 1, 1)
stmt_count = select(func.count(Asset.id)).where(Asset.owner_organization_id == current_user.scope_id)
stmt_count = select(func.count(Asset.id)).where(
Asset.owner_org_id == current_user.scope_id,
Asset.status == "active"
)
count = (await db.execute(stmt_count)).scalar() or 0
if count >= max_allowed:
logger.error(f"QUOTA BLOCK TRIGGERED - User Person ID: {current_user.person_id}, Active Count: {count}, Limit: {max_allowed}")
raise HTTPException(status_code=403, detail=f"Limit túllépés: {max_allowed} jármű engedélyezett.")
# OCR hívás helye...

View File

@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.api.deps import get_db, get_current_user
from app.models import Asset, AssetCost, SystemParameter
from app.models import Asset, AssetCost, OrganizationMember, SystemParameter
from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime
@@ -53,13 +53,30 @@ async def create_expense(
detail=f"DRAFT_LIMIT_REACHED: Draft vehicles are limited to {limit} expenses. This asset already has {expense_count} expenses."
)
# Determine organization_id from asset (required by AssetCost model)
organization_id = asset.current_organization_id or asset.owner_org_id
# Determine organization_id: prefer explicit from payload, fallback to asset fields
organization_id = expense.organization_id or asset.current_organization_id or asset.owner_org_id
if not organization_id:
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
# Map cost_type to cost_category (AssetCost uses cost_category)
cost_category = expense.cost_type
# B2C fallback: if the asset is owned by a person (not an org),
# try to find the current user's default organization
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.is_verified == True
).limit(1)
org_result = await db.execute(org_stmt)
org_member = org_result.scalar_one_or_none()
if org_member:
organization_id = org_member.organization_id
else:
# Last resort: any membership (even unverified)
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id
).limit(1)
org_result = await db.execute(org_stmt)
org_member = org_result.scalar_one_or_none()
if org_member:
organization_id = org_member.organization_id
else:
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
data = expense.data.copy() if expense.data else {}
@@ -72,7 +89,7 @@ async def create_expense(
new_cost = AssetCost(
asset_id=expense.asset_id,
organization_id=organization_id,
cost_category=cost_category,
category_id=expense.category_id,
amount_net=expense.amount_local,
currency=expense.currency_local,
date=expense.date,
@@ -88,7 +105,7 @@ async def create_expense(
"status": "success",
"id": new_cost.id,
"asset_id": new_cost.asset_id,
"cost_category": new_cost.cost_category,
"category_id": new_cost.category_id,
"amount_net": new_cost.amount_net,
"date": new_cost.date
}

View File

@@ -4,6 +4,7 @@ from .vehicle_definitions import (
VehicleType,
FeatureDefinition,
ModelFeatureMap,
BodyTypeDictionary,
)
from .vehicle import (
@@ -60,4 +61,5 @@ __all__ = [
"LogSeverity",
# --- EXPORT LISTA KIEGÉSZÍTÉSE ---
"MotorcycleSpecs",
"BodyTypeDictionary",
]

View File

@@ -244,7 +244,7 @@ class AssetCost(Base):
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=False)
cost_category: Mapped[str] = mapped_column(String(50), index=True)
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False)
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="HUF")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -253,6 +253,7 @@ class AssetCost(Base):
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
organization: Mapped["Organization"] = relationship("Organization")
category: Mapped["CostCategory"] = relationship("CostCategory")
class VehicleLogbook(Base):

View File

@@ -12,10 +12,10 @@ from sqlalchemy.sql import func
from app.database import Base
class LogSeverity(str, enum.Enum):
info = "info"
warning = "warning"
critical = "critical"
emergency = "emergency"
info = "INFO"
warning = "WARNING"
critical = "CRITICAL"
emergency = "EMERGENCY"
class AuditLog(Base):

View File

@@ -35,6 +35,8 @@ class CostCategory(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'")
accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())

View File

@@ -154,3 +154,19 @@ class ModelFeatureMap(Base):
model_definition: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="feature_maps")
feature: Mapped["FeatureDefinition"] = relationship("FeatureDefinition", back_populates="model_maps")
class BodyTypeDictionary(Base):
""" Szabványos karosszéria-típus szótár (BodyType Dictionary).
A szabad szöveges body_type mező normalizálásához.
"""
__tablename__ = "dict_body_types"
__table_args__ = (
UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
{"schema": "vehicle"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name_hu: Mapped[str] = mapped_column(String(100), nullable=False)

View File

@@ -19,7 +19,8 @@ class AssetCostBase(BaseModel):
class AssetCostCreate(AssetCostBase):
asset_id: UUID
organization_id: int
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
category_id: int # FK a CostCategory táblához
class AssetCostResponse(AssetCostBase):
id: UUID

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Seed script to update CostCategory visibility and accounting_code values.
Also drops the old 'cost_category' column from asset_costs table.
Usage: docker compose exec sf_api python -m app.scripts.seed_cost_category_visibility
"""
import asyncio
import logging
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from app.core.config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Visibility rules:
# - FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, OTHER -> 'both'
# - FINANCE, ADMIN -> 'b2b'
VISIBILITY_MAP = {
"FUEL": "both",
"MAINTENANCE": "both",
"SERVICE": "both",
"TIRE": "both",
"INSURANCE": "both",
"PARKING": "both",
"TOLL": "both",
"OTHER": "both",
"FINANCE": "b2b",
"ADMIN": "b2b",
}
async def main():
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# 1. Update visibility for existing categories
logger.info("🔄 Updating visibility for existing cost categories...")
for code, visibility in VISIBILITY_MAP.items():
result = await session.execute(
text("""
UPDATE vehicle.cost_categories
SET visibility = :visibility
WHERE code = :code AND (visibility IS NULL OR visibility = 'both')
"""),
{"code": code, "visibility": visibility}
)
if result.rowcount > 0:
logger.info(f"{code} -> {visibility}")
# 2. Set default visibility for any categories without it
result = await session.execute(
text("""
UPDATE vehicle.cost_categories
SET visibility = 'both'
WHERE visibility IS NULL
""")
)
if result.rowcount > 0:
logger.info(f" ✅ Set default 'both' for {result.rowcount} categories")
# 3. Drop the old cost_category column from asset_costs
logger.info("🔄 Dropping old 'cost_category' column from asset_costs...")
# Check if the column still exists
check = await session.execute(
text("""
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'vehicle'
AND table_name = 'asset_costs'
AND column_name = 'cost_category'
""")
)
if check.scalar():
await session.execute(
text("ALTER TABLE vehicle.asset_costs DROP COLUMN cost_category")
)
logger.info(" ✅ Dropped old 'cost_category' column from vehicle.asset_costs")
else:
logger.info(" ⏭️ Column 'cost_category' already removed")
await session.commit()
logger.info("✅ All updates completed successfully!")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Seed script to insert cost subcategories (hierarchy) under existing parent categories.
Usage:
docker compose exec sf_api python -m app.scripts.seed_cost_subcategories
Subcategories added:
- FUEL (ID=1): FUEL_PETROL (Benzin), FUEL_DIESEL (Dízel), FUEL_ELECTRIC (Elektromos töltés), FUEL_LPG (LPG)
- MAINTENANCE (ID=2): MAINT_OIL (Olajcsere), MAINT_SERVICE (Kötelező szerviz), MAINT_BRAKES (Fékcsere)
- FEES (ID=6): FEE_HIGHWAY (Autópálya matrica), FEE_STREET_PARKING (Közterületi parkolás), FEE_GARAGE (Parkolóház)
"""
import asyncio
import logging
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from app.core.config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Subcategory definitions: (parent_code, code, name, description, visibility)
SUBCATEGORIES = [
# FUEL (parent_id=1)
("FUEL", "FUEL_PETROL", "Benzin", "Benzin üzemanyag költség", "both"),
("FUEL", "FUEL_DIESEL", "Dízel", "Dízel üzemanyag költség", "both"),
("FUEL", "FUEL_ELECTRIC", "Elektromos töltés", "Elektromos jármű töltési költség", "both"),
("FUEL", "FUEL_LPG", "LPG", "LPG (autógáz) üzemanyag költség", "both"),
# MAINTENANCE (parent_id=2)
("MAINTENANCE", "MAINT_SERVICE", "Kötelező szerviz", "Gyártó által előírt időszakos szerviz", "both"),
("MAINTENANCE", "MAINT_OIL", "Olajcsere", "Motorolaj és szűrő csere", "both"),
("MAINTENANCE", "MAINT_BRAKES", "Fékcsere", "Fékbetét, féktárcsa csere", "both"),
# FEES (parent_id=6)
("FEES", "FEE_HIGHWAY", "Autópálya matrica", "Országos autópálya használati díj", "both"),
("FEES", "FEE_STREET_PARKING", "Közterületi parkolás", "Utcai parkolóóra / parkolási díj", "both"),
("FEES", "FEE_GARAGE", "Parkolóház", "Mélygarázs vagy parkolóház díja", "both"),
]
async def main():
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Fetch parent categories map
result = await session.execute(
text("SELECT id, code FROM vehicle.cost_categories WHERE parent_id IS NULL")
)
parent_map = {row.code: row.id for row in result.fetchall()}
logger.info(f"Found parent categories: {parent_map}")
# Fetch existing codes to avoid duplicates
result = await session.execute(
text("SELECT code FROM vehicle.cost_categories")
)
existing_codes = {row.code for row in result.fetchall()}
inserted_count = 0
skipped_count = 0
for parent_code, code, name, description, visibility in SUBCATEGORIES:
if code in existing_codes:
logger.info(f" ⏭️ SKIP (already exists): {code}")
skipped_count += 1
continue
parent_id = parent_map.get(parent_code)
if parent_id is None:
logger.warning(f" ⚠️ Parent code '{parent_code}' not found, skipping {code}")
skipped_count += 1
continue
await session.execute(
text("""
INSERT INTO vehicle.cost_categories (code, name, description, parent_id, visibility, is_system, created_at, updated_at)
VALUES (:code, :name, :description, :parent_id, :visibility, true, NOW(), NOW())
"""),
{
"code": code,
"name": name,
"description": description,
"parent_id": parent_id,
"visibility": visibility,
}
)
logger.info(f" ✅ INSERTED: {code} -> {parent_code} (parent_id={parent_id})")
inserted_count += 1
await session.commit()
logger.info(f"✅ Done! Inserted={inserted_count}, Skipped={skipped_count}")
# Verify the tree structure
result = await session.execute(
text("""
SELECT p.code AS parent_code, p.name AS parent_name,
c.id, c.code, c.name, c.parent_id
FROM vehicle.cost_categories c
JOIN vehicle.cost_categories p ON c.parent_id = p.id
ORDER BY p.id, c.id
""")
)
rows = result.fetchall()
logger.info("\n=== HIERARCHY TREE ===")
for row in rows:
logger.info(f" {row.parent_code:15s} -> {row.code:20s} ({row.name})")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import uuid
from typing import List, Optional, Dict, Any, TYPE_CHECKING
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, distinct
from sqlalchemy.orm import selectinload
@@ -62,10 +62,21 @@ class AssetService:
allowed_limit = await AssetService.get_user_vehicle_limit(db, user_id, target_org_id)
# Csak aktív járművek számítanak a limitbe (draft-ok nem)
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == target_org_id,
Asset.status == "active"
)
# FIX: Count only the CURRENT USER's active vehicles, not ALL vehicles in the org
if target_org_id is not None:
# Organization mode: count vehicles owned by this user within the org
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == target_org_id,
Asset.owner_person_id == user.person_id,
Asset.status == "active"
)
else:
# Personal mode: count vehicles owned personally by this user
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id.is_(None),
Asset.owner_person_id == user.person_id,
Asset.status == "active"
)
current_count = (await db.execute(count_stmt)).scalar()
# Determine status based on data completeness (use Pydantic validator's logic)
@@ -86,7 +97,15 @@ class AssetService:
else:
status = "active"
# SAFETY: Ensure limit is at least 1 — no registered user should ever have 0 limit
allowed_limit = max(allowed_limit or 1, 1)
logger.info(
f"Limit check for user {user_id} (org={target_org_id}): "
f"current_count={current_count}, allowed_limit={allowed_limit}, status={status}"
)
if current_count >= allowed_limit and status == "active":
logger.error(f"QUOTA BLOCK TRIGGERED - User Person ID: {user.person_id}, Active Count: {current_count}, Limit: {allowed_limit}")
raise ValueError(f"Limit túllépés! A csomagod {allowed_limit} aktív autót engedélyez.")
# 2. LÉTEZIK-E MÁR A JÁRMŰ? (csak ha van VIN)
@@ -400,7 +419,11 @@ class AssetService:
# Get user-specific limit (based on role or subscription plan)
user_limit = limits.get(user_role)
if user_limit is None:
user_limit = limits.get(subscription_plan.lower(), 100)
# FIX: If role not found (e.g. "user" not in dict), try subscription plan
user_limit = limits.get(subscription_plan.lower())
if user_limit is None:
# FIX: Ultimate fallback - safe default for free/basic users
user_limit = limits.get("free", 1)
# Get organization-specific limit (if configured)
org_limit = None
@@ -431,6 +454,98 @@ class AssetService:
# Fallback to a reasonable default
return 100
@staticmethod
async def archive_vehicle(
db: AsyncSession,
asset_id: uuid.UUID,
user_id: int,
final_mileage: int,
archive_reason: str,
) -> Asset:
"""
Strict Soft Delete — Jármű biztonságos kivezetése a garázsból.
A művelet:
1. Frissíti a current_mileage értékét a megadott final_mileage-ra
2. Állítja: status = 'archived'
3. Nullázza az owner_person_id és owner_org_id mezőket
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
5. Naplózza a biztonsági auditba
"""
from app.models.identity import User
from sqlalchemy.orm import selectinload
# 1. Lekérjük a járművet — eager load catalog reláció a MissingGreenlet hiba elkerülésére
stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog))
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Jármű nem található")
# 2. Jogosultság ellenőrzése — csak a tulajdonos vagy admin archiválhat
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="Felhasználó nem található")
is_owner = (asset.owner_person_id == user.person_id)
is_admin = user.role in ("admin", "superadmin") if hasattr(user, 'role') else False
if not is_owner and not is_admin:
raise HTTPException(
status_code=403,
detail="Nincs jogosultságod a jármű eltávolításához"
)
# 3. Már archiválva van?
if asset.status == "archived":
raise HTTPException(status_code=400, detail="A jármű már archiválva van")
# 4. Mentjük az archive_info metaadatokat
now_iso = datetime.now(timezone.utc).isoformat()
equipment = dict(asset.individual_equipment or {})
equipment['archive_info'] = {
'reason': archive_reason,
'archived_at': now_iso,
'previous_owner_person_id': asset.owner_person_id,
'previous_owner_org_id': asset.owner_org_id,
'final_mileage': final_mileage,
}
# 5. Frissítjük a jármű adatait
asset.current_mileage = final_mileage
asset.status = "archived"
asset.owner_person_id = None
asset.owner_org_id = None
asset.individual_equipment = equipment
asset.updated_at = datetime.now(timezone.utc)
# 6. Biztonsági audit naplózás
await security_service.log_event(
db,
user_id=user_id,
action="VEHICLE_ARCHIVED",
severity=LogSeverity.warning,
target_type="Asset",
target_id=str(asset.id),
new_data={
"reason": archive_reason,
"final_mileage": final_mileage,
"archived_at": now_iso,
}
)
await db.commit()
await db.refresh(asset)
logger.info(
f"Vehicle {asset.id} ({asset.license_plate}) archived by user {user_id}. "
f"Reason: {archive_reason}, final_mileage: {final_mileage}"
)
return asset
@staticmethod
async def _award_first_car_badge(db: AsyncSession, user_id: int, org_id: int):
"""

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
BodyTypeDictionary seed script.
Populates the vehicle.dict_body_types table with standardized body types.
Run inside the sf_api container:
docker compose exec sf_api python3 /app/backend/scripts/seed_body_types.py
"""
import asyncio
import sys
import os
# Ensure the backend directory is on the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from app.database import AsyncSessionLocal, engine, Base
from app.models.vehicle.vehicle_definitions import BodyTypeDictionary
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
# ── Standardized Body Type Data ──
BODY_TYPES = [
# Personal (Személyautó)
{"vehicle_class": "personal", "code": "sedan", "name_hu": "Szedán"},
{"vehicle_class": "personal", "code": "hatchback", "name_hu": "Kompakt"},
{"vehicle_class": "personal", "code": "estate", "name_hu": "Kombi"},
{"vehicle_class": "personal", "code": "suv", "name_hu": "Városi Terepjáró"},
{"vehicle_class": "personal", "code": "coupe", "name_hu": "Kupé"},
{"vehicle_class": "personal", "code": "cabrio", "name_hu": "Kabrió"},
{"vehicle_class": "personal", "code": "mpv", "name_hu": "Egyterű"},
# Motorcycle (Motorkerékpár)
{"vehicle_class": "motorcycle", "code": "naked", "name_hu": "Naked Bike"},
{"vehicle_class": "motorcycle", "code": "sport", "name_hu": "Sport"},
{"vehicle_class": "motorcycle", "code": "touring", "name_hu": "Túra"},
{"vehicle_class": "motorcycle", "code": "enduro", "name_hu": "Enduró / Kaland"},
{"vehicle_class": "motorcycle", "code": "cruiser", "name_hu": "Cruiser"},
{"vehicle_class": "motorcycle", "code": "scooter", "name_hu": "Robogó"},
# Light Commercial (Kisteher)
{"vehicle_class": "light_commercial", "code": "van", "name_hu": "Furgon"},
{"vehicle_class": "light_commercial", "code": "pickup", "name_hu": "Pickup"},
{"vehicle_class": "light_commercial", "code": "chassis", "name_hu": "Alvázas"},
]
async def seed():
print("🌱 Seeding BodyTypeDictionary...")
async with AsyncSessionLocal() as session:
# Check if data already exists
result = await session.execute(select(BodyTypeDictionary).limit(1))
existing = result.scalar_one_or_none()
if existing:
print("⚠️ BodyTypeDictionary already has data. Skipping seed.")
return
for bt in BODY_TYPES:
entry = BodyTypeDictionary(
vehicle_class=bt["vehicle_class"],
code=bt["code"],
name_hu=bt["name_hu"],
)
session.add(entry)
await session.commit()
print(f"✅ Seeded {len(BODY_TYPES)} body types successfully!")
if __name__ == "__main__":
asyncio.run(seed())

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Seed script to update CostCategory visibility and accounting_code values.
Also drops the old 'cost_category' column from asset_costs table.
Usage: docker compose exec sf_api python3 /app/backend/scripts/seed_cost_category_visibility.py
"""
import asyncio
import logging
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from app.core.config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Visibility rules:
# - FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, OTHER -> 'both'
# - FINANCE, ADMIN -> 'b2b'
VISIBILITY_MAP = {
"FUEL": "both",
"MAINTENANCE": "both",
"SERVICE": "both",
"TIRE": "both",
"INSURANCE": "both",
"PARKING": "both",
"TOLL": "both",
"OTHER": "both",
"FINANCE": "b2b",
"ADMIN": "b2b",
}
async def main():
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# 1. Update visibility for existing categories
logger.info("🔄 Updating visibility for existing cost categories...")
for code, visibility in VISIBILITY_MAP.items():
result = await session.execute(
text("""
UPDATE vehicle.cost_categories
SET visibility = :visibility
WHERE code = :code AND (visibility IS NULL OR visibility = 'both')
"""),
{"code": code, "visibility": visibility}
)
if result.rowcount > 0:
logger.info(f"{code} -> {visibility}")
# 2. Set default visibility for any categories without it
result = await session.execute(
text("""
UPDATE vehicle.cost_categories
SET visibility = 'both'
WHERE visibility IS NULL
""")
)
if result.rowcount > 0:
logger.info(f" ✅ Set default 'both' for {result.rowcount} categories")
# 3. Drop the old cost_category column from asset_costs
logger.info("🔄 Dropping old 'cost_category' column from asset_costs...")
# Check if the column still exists
check = await session.execute(
text("""
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'vehicle'
AND table_name = 'asset_costs'
AND column_name = 'cost_category'
""")
)
if check.scalar():
await session.execute(
text("ALTER TABLE vehicle.asset_costs DROP COLUMN cost_category")
)
logger.info(" ✅ Dropped old 'cost_category' column from vehicle.asset_costs")
else:
logger.info(" ⏭️ Column 'cost_category' already removed")
await session.commit()
logger.info("✅ All updates completed successfully!")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,145 @@
# 📊 Adatbázis Katalógus & Szótár Felderítés
**Dátum:** 2026-06-11
**Vizsgált rendszer:** Service Finder Backend (PostgreSQL + SQLAlchemy)
**Cél:** Karosszéria/Kivitel (Body Type / Trim) funkció előfeltérképezése
---
## 1. SQL Adatbázis Felfedezés
### 1.1 Katalógus-táblák a `vehicle` sémában
Az `information_schema.tables` lekérdezés alapján az összes releváns tábla a **`vehicle`** sémában található. Nincs külön `catalog` vagy `dictionary` séma.
| # | Tábla | Típus | Sorok száma | Állapot |
|---|-------|-------|-------------|---------|
| 1 | `vehicle.vehicle_model_definitions` | BASE TABLE | **345,400** | ✅ Feltöltve (MDM master) |
| 2 | `vehicle.vehicle_catalog` | BASE TABLE | **1,596,564** | ✅ Feltöltve (variánsok) |
| 3 | `vehicle.vehicle_types` | BASE TABLE | **1** | ❌ Csak 1 rekord (`car`) |
| 4 | `vehicle.feature_definitions` | BASE TABLE | **16** | ⚠️ Csak 16 felszereltség |
| 5 | `vehicle.model_feature_maps` | BASE TABLE | **0** | ❌ Üres kapcsolótábla |
| 6 | `vehicle.catalog_discovery` | BASE TABLE | **172,525** | ✅ Feltöltve (robot várólista) |
| 7 | `vehicle.gb_catalog_discovery` | BASE TABLE | **0** | ❌ Üres (UK piac) |
### 1.2 Body Típusok (Karosszéria) Állapota
- **152 különböző `body_type` érték** létezik az adatbázisban
- **4,050 rekordnál** (~1.17%) hiányzik a body_type
- **Probléma:** Az értékek **nem normalizáltak** — ugyanaz a karosszéria többféleképpen van tárolva:
- `"SUV"` vs `"SUV, Crossover"` vs `"SUV, Fastback"`
- `"SEDAN"` vs `"Sedan"` vs `"sedan"`
- `"CONVERTIBLE"` vs `"Cabriolet"` vs `"cabriolet"`
- Hollandi nyelvű RDW értékek: `"hatchback"`, `"stationwagen"`, `"cabriolet"` stb.
### 1.3 Trim Szintek (Kivitelek) Állapota
- **22,375 különböző `trim_level` érték**
- **187,159 rekordnál** (~54%) hiányzik a trim_level
- Az értékek többsége kódolt (#BK, #LE, #LF stb.) — nem emészthető emberi szemmel
### 1.4 Vehicle Típusok
- Csak **1 rekord**: `code='car'`, `name='Személyautó'`
- A `motorcycle`, `truck`, `bus` stb. típusok nincsenek feltöltve
### 1.5 Vehicle Class
4 érték található: `car`, `motorcycle`, `other`, `truck`
---
## 2. SQLAlchemy Modellek Állapota
### 2.1 A `vehicle_definitions.py` modelljei
| Modell | Tábla | Oszlopok | Létezik? |
|--------|-------|----------|----------|
| `VehicleType` (`vehicle_definitions.py:17`) | `vehicle.vehicle_types` | `id, code, name, icon, units` | ✅ Van |
| `FeatureDefinition` (`vehicle_definitions.py:33`) | `vehicle.feature_definitions` | `id, vehicle_type_id, code, name, category` | ✅ Van |
| `VehicleModelDefinition` (`vehicle_definitions.py:48`) | `vehicle.vehicle_model_definitions` | `id, make, marketing_name, body_type, trim_level, vehicle_class, fuel_type...` | ✅ Van (MDM) |
| `ModelFeatureMap` (`vehicle_definitions.py:145`) | `vehicle.model_feature_maps` | `id, model_definition_id, feature_id, is_standard` | ✅ Van |
### 2.2 A `asset.py` modelljei
| Modell | Tábla | Oszlopok | Létezik? |
|--------|-------|----------|----------|
| `AssetCatalog` (`asset.py:14`) | `vehicle.vehicle_catalog` | `id, make, model, generation, year_from, year_to, fuel_type...` | ✅ Van |
| `Asset` (`asset.py:69`) | `vehicle.assets` | `id, vin, brand, model, trim_level, vehicle_class...` | ✅ Van |
| `CatalogDiscovery` (`asset.py:402`) | `vehicle.catalog_discovery` | `id, make, model, vehicle_class, market, model_year, status` | ✅ Van |
| `GbCatalogDiscovery` (`vehicle.py:195`) | `vehicle.gb_catalog_discovery` | `id, vrm, make, model, status` | ✅ Van |
### 2.3 Egyéb releváns modellek
| Fájl | Modell | Tábla |
|------|--------|-------|
| `external_reference.py:6` | `ExternalReferenceLibrary` | `vehicle.external_reference_library` |
| `motorcycle_specs.py:6` | `MotorcycleSpecs` | `vehicle.motorcycle_specs` |
| `reference_data.py:6` | `ReferenceLookup` | `vehicle.reference_lookup` |
### 2.4 ⚠️ Kritikus hiányosság: NINCS BodyType modell!
**Nincs önálló `BodyType` SQLAlchemy modell!** A `body_type` jelenleg egy szabad szöveges (`String(100)`) mező a `VehicleModelDefinition` osztályban (`vehicle_definitions.py:92`). Ez magyarázza a 152 nem normalizált értéket.
Ugyanez igaz a `trim_level`-re is (`vehicle_definitions.py:94`): szabad szöveges mező, nincs normalizált szótár mögötte.
---
## 3. API Végpontok Állapota
### 3.1 Meglévő katalógus végpontok (`catalog.py`)
| Végpont | Módszer | Leírás | Védett? |
|---------|---------|--------|---------|
| `/makes` (`catalog.py:11`) | GET | Márkák listázása (opcionális vehicle_class szűrő) | ✅ Igen |
| `/models` (`catalog.py:21`) | GET | Modellek listázása adott márkához | ✅ Igen |
| `/generations` (`catalog.py:38`) | GET | Generációk/évjáratok listázása | ✅ Igen |
| `/engines` (`catalog.py:55`) | GET | Motorváltozatok és specifikációk | ✅ Igen |
**Minden végpont az `AssetService`-t használja** (`asset_service.py:339-385`), ami a `vehicle_model_definitions` táblából lekérdezéseket futtat.
### 3.2 NEM létező, de szükséges végpontok
A Karosszéria/Kivitel funkcióhoz az alábbi végpontok **hiányoznak**:
-`GET /body-types` — Karosszéria típusok listázása
-`GET /trims` — Kivitelek listázása (szűrhető márka/modell szerint)
-`GET /vehicle-classes` — Jármű osztályok listázása
-`POST /body-types` — Új karosszériatípus hozzáadása (admin)
-`POST /trims` — Új kivitel hozzáadása (admin)
### 3.3 Meglévő jármű végpontok (`vehicles.py`)
| Végpont | Módszer | Leírás |
|---------|---------|--------|
| `/{vehicle_id}/ratings` (`vehicles.py:22`) | POST | Értékelés küldése |
| `/{vehicle_id}/ratings` (`vehicles.py:94`) | GET | Értékelések lekérése |
---
## 4. Összefoglaló és Javaslatok
### 4.1 Adatbázis
**345,400 járműdefiníció** és **1.59M katalógus variáns** — a botok jól dolgoztak
⚠️ **`vehicle_types`** tábla majdnem üres (csak 1 rekord)
⚠️ **`body_type`** és **`trim_level`** nem normalizált, szabad szöveges mezők
⚠️ 152 féle body_type, nagy része duplikátum (kisbetű/nagybetű, holland/angol keverék)
### 4.2 Modellek
✅ Létezik `VehicleModelDefinition`, `VehicleType`, `FeatureDefinition`
**NINCS önálló `BodyType` modell** — ez a legnagyobb hiányosság
❌ NINCS önálló `TrimLevel` modell sem
### 4.3 API
✅ Négy katalógus végpont működik (`/makes`, `/models`, `/generations`, `/engines`)
**NINCS** `/body-types`, `/trims`, `/vehicle-classes` végpont
### 4.4 Ajánlott következő lépések
1. **`BodyType` modell létrehozása** — normalizált karosszériatípus szótár (id, code, name, icon, vehicle_class_id)
2. **`TrimLevel` modell létrehozása** — normalizált kivitel szótár (id, code, name)
3. **Adatmigráció:** A 152 meglévő body_type érték normalizálása és deduplikálása
4. **API végpontok:** `GET /body-types`, `GET /trims`, `GET /vehicle-classes`
5. **`vehicle_types` feltöltése** — a hiányzó járműtípusok (motorcycle, truck, bus stb.) hozzáadása

View File

@@ -0,0 +1,311 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-white w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-fade-in-up">
<!-- Header -->
<div class="flex items-center justify-between bg-slate-700 px-6 py-4">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<span>{{ isFeeMode ? '🅿️' : '' }}</span>
{{ isFeeMode ? 'Parkolás / Útdíj rögzítése' : 'További költség rögzítése' }}
</h3>
<button
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-colors"
@click="$emit('close')"
>
</button>
</div>
<!-- Form Body -->
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
<!-- Vehicle selector (read-only display) -->
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
</div>
<!-- Date -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Dátum</label>
<input
v-model="form.date"
type="date"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Amount (HUF) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg (HUF)</label>
<input
v-model.number="form.amount"
type="number"
min="0"
step="1"
required
placeholder="pl. 5000"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Cascade Category: Main Category -->
<div v-if="!isFeeMode">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Főkategória</label>
<select
v-model="form.mainCategory"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@change="onMainCategoryChange"
>
<option value="" disabled>Válassz kategóriát...</option>
<option
v-for="cat in mainCategories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
</select>
</div>
<!-- Cascade Category: Sub Category -->
<div v-if="!isFeeMode && subCategories.length > 0">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Alkategória</label>
<select
v-model="form.subCategory"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option value="" disabled>Válassz alkategóriát...</option>
<option
v-for="sub in subCategories"
:key="sub.id"
:value="sub.id"
>
{{ sub.name }}
</option>
</select>
</div>
<!-- Description / Notes -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Megjegyzés</label>
<textarea
v-model="form.description"
rows="3"
placeholder="Rövid leírás a költségről..."
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all resize-none"
/>
</div>
<!-- Recurring Toggle -->
<div v-if="!isFeeMode" class="flex items-center gap-3">
<input
id="is-recurring"
v-model="form.isRecurring"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20"
/>
<label for="is-recurring" class="text-sm text-slate-700">Rendszeres költség (havi ismétlődő)</label>
</div>
<!-- Error message -->
<div
v-if="errorMessage"
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700"
>
{{ errorMessage }}
</div>
<!-- Submit button -->
<button
type="submit"
:disabled="costStore.isSubmitting"
class="w-full rounded-xl bg-sf-accent py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<svg
v-if="costStore.isSubmitting"
class="h-4 w-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span v-else> Költség rögzítése</span>
</button>
</form>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useCostStore } from '../../stores/cost'
import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle'
const props = defineProps<{
isOpen: boolean
vehicle: Vehicle | null
/** If true, pre-fills as FEES category (parking/toll) */
isFeeMode?: boolean
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const costStore = useCostStore()
// ── Form State ──
const form = reactive({
date: new Date().toISOString().slice(0, 10),
amount: 0,
mainCategory: '',
subCategory: '',
description: '',
isRecurring: false,
})
const errorMessage = ref<string | null>(null)
// ── Category Cascade State ──
interface CategoryOption {
id: number
name: string
parent_id: number | null
}
const mainCategories = ref<CategoryOption[]>([])
const subCategories = ref<CategoryOption[]>([])
const vehicleLabel = computed(() => {
if (!props.vehicle) return 'Nincs kiválasztva jármű'
const plate = props.vehicle.license_plate || ''
const brand = props.vehicle.brand || ''
const model = props.vehicle.model || ''
return `${plate} (${brand} ${model})`.trim()
})
/**
* Fetch hierarchical categories from the dictionaries API.
*/
async function fetchCategories() {
try {
const res = await api.get('/dictionaries/cost-categories')
const allCategories = res.data as CategoryOption[]
// Split into main (parent_id === null) and sub categories
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
// Store all for sub-category filtering
window.__allCostCategories = allCategories
} catch (err) {
console.error('[ComplexExpenseModal] Failed to load categories:', err)
// Fallback: provide basic categories
mainCategories.value = [
{ id: 1, name: 'Üzemanyag', parent_id: null },
{ id: 2, name: 'Szerviz / Karbantartás', parent_id: null },
{ id: 3, name: 'Parkolás / Útdíj', parent_id: null },
{ id: 4, name: 'Biztosítás', parent_id: null },
{ id: 5, name: 'Adók / Illetékek', parent_id: null },
{ id: 6, name: 'Egyéb', parent_id: null },
]
}
}
function onMainCategoryChange() {
// Reset sub-category when main changes
form.subCategory = ''
subCategories.value = []
if (!form.mainCategory) return
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
subCategories.value = allCats.filter(
(c) => c.parent_id === Number(form.mainCategory)
)
}
async function handleSubmit() {
if (!props.vehicle) {
errorMessage.value = 'Nincs kiválasztva jármű.'
return
}
errorMessage.value = null
// Determine cost_type and category_id
let costType: string
let categoryId: number
if (props.isFeeMode) {
// Fee mode: pre-filled as FEES
costType = 'fee'
categoryId = 3 // Parkolás / Útdíj
} else {
// Complex mode: use selected sub-category or main category
costType = 'other'
categoryId = Number(form.subCategory || form.mainCategory) || 6
}
const payload = {
asset_id: props.vehicle.id,
// organization_id is omitted — backend auto-resolves from asset
category_id: categoryId,
cost_type: costType,
amount_local: form.amount,
currency_local: 'HUF' as const,
date: new Date(form.date).toISOString(),
mileage_at_cost: null,
description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'),
data: {
is_recurring: form.isRecurring,
category_id: categoryId,
main_category_id: form.mainCategory || null,
sub_category_id: form.subCategory || null,
},
}
const result = await costStore.addExpense(payload)
if (result) {
// Reset form
form.date = new Date().toISOString().slice(0, 10)
form.amount = 0
form.mainCategory = ''
form.subCategory = ''
form.description = ''
form.isRecurring = false
emit('saved')
} else {
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
}
}
onMounted(() => {
fetchCategories()
})
</script>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
</style>

View File

@@ -13,13 +13,23 @@
</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"
@click="handleAddNewClick"
:disabled="isCheckingQuota"
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 disabled:opacity-60 disabled:cursor-not-allowed"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
v-if="isCheckingQuota"
class="h-4 w-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<svg v-else 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
{{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
</button>
</div>
</div>
@@ -76,13 +86,23 @@
<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"
@click="handleAddNewClick"
:disabled="isCheckingQuota"
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 disabled:opacity-60 disabled:cursor-not-allowed"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
v-if="isCheckingQuota"
class="h-4 w-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<svg v-else 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
{{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
</button>
</div>
</template>
@@ -109,6 +129,8 @@
@edit="openEditFromDetail"
@set-primary="setPrimaryVehicle"
/>
<!-- Task 3: Hidden trigger for direct edit from DashboardView -->
<div style="display: none">{{ triggerEditFromParent }}</div>
</div>
</template>
@@ -116,30 +138,30 @@
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 api from '../../api/axios'
import VehicleFormModal from './VehicleFormModal.vue'
import VehicleDetailModal from '../vehicle/VehicleDetailModal.vue'
import VehicleCardStandard from '../vehicle/VehicleCardStandard.vue'
const props = defineProps<{
targetVehicleId?: string | null
editTargetVehicle?: VehicleData | null
}>()
const emit = defineEmits<{
'clear-edit-target': []
}>()
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.
* Vehicle list sourced exclusively from the backend API.
* Sorted by is_primary flag (favorite first), then alphabetically.
* No mock/fallback data is injected — if the API returns an empty list,
* the UI displays an empty garage.
*/
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]
return vehicleStore.sortedVehicles as unknown as VehicleData[]
})
// ── Carousel ref & scroll functions ──
@@ -183,6 +205,40 @@ async function openEditFromDetail(vehicle: VehicleData) {
// ── Modal state (Add) ──
const isAddFormOpen = ref(false)
const isCheckingQuota = ref(false)
/**
* Preemptive quota check before opening the add-vehicle modal.
* Calls GET /api/v1/assets/vehicles/quota-status.
* If the user has reached their limit, shows a blocking toast/alert instead of opening the form.
*/
async function handleAddNewClick() {
if (isCheckingQuota.value) return
isCheckingQuota.value = true
try {
const res = await api.get('/assets/vehicles/quota-status')
const { can_add, current_count, limit } = res.data
if (!can_add) {
// Block — show a prominent warning instead of opening the modal
alert(
`Elérted a csomagodhoz tartozó járműlimitet (${current_count}/${limit})! ` +
'Kérjük, válts magasabb csomagra az új jármű rögzítéséhez.'
)
return
}
// Quota OK — open the add form
isAddFormOpen.value = true
} catch (err: any) {
console.error('[PrivateVehicleManager] Quota check failed:', err)
// Fail open — if the API is unreachable, let the user proceed
isAddFormOpen.value = true
} finally {
isCheckingQuota.value = false
}
}
function onVehicleSaved() {
isAddFormOpen.value = false
@@ -220,6 +276,18 @@ watch(() => props.targetVehicleId, (newVal) => {
}
})
// ── Task 3: Watch for editTargetVehicle from parent (DashboardView) ──
const triggerEditFromParent = computed(() => {
if (props.editTargetVehicle) {
// Open the edit form directly with the vehicle data
editingVehicle.value = { ...props.editTargetVehicle } as VehicleData
isEditFormOpen.value = true
// Clear the prop so it doesn't re-trigger
emit('clear-edit-target')
}
return props.editTargetVehicle?.id || null
})
// ── Set Primary Vehicle ──
async function setPrimaryVehicle(vehicle: VehicleData) {
console.log('[PrivateVehicleManager] setPrimaryVehicle called for:', vehicle.id, vehicle.license_plate)

View File

@@ -0,0 +1,200 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-white w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-fade-in-up">
<!-- Header -->
<div class="flex items-center justify-between bg-slate-700 px-6 py-4">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<span></span> Tankolás rögzítése
</h3>
<button
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-colors"
@click="$emit('close')"
>
</button>
</div>
<!-- Form Body -->
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
<!-- Vehicle selector (read-only display of selected vehicle) -->
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
</div>
<!-- Date -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Dátum</label>
<input
v-model="form.date"
type="date"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Amount (HUF) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg (HUF)</label>
<input
v-model.number="form.amount"
type="number"
min="0"
step="1"
required
placeholder="pl. 15000"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Quantity (Liters/kWh) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Mennyiség (Liter / kWh)</label>
<input
v-model.number="form.quantity"
type="number"
min="0"
step="0.1"
required
placeholder="pl. 45.5"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Odometer -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Km óra állás</label>
<input
v-model.number="form.odometer"
type="number"
min="0"
step="1"
placeholder="pl. 123456"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Error message -->
<div
v-if="errorMessage"
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700"
>
{{ errorMessage }}
</div>
<!-- Submit button -->
<button
type="submit"
:disabled="costStore.isSubmitting"
class="w-full rounded-xl bg-sf-accent py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<svg
v-if="costStore.isSubmitting"
class="h-4 w-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span v-else> Tankolás rögzítése</span>
</button>
</form>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useCostStore } from '../../stores/cost'
import type { Vehicle } from '../../stores/vehicle'
const props = defineProps<{
isOpen: boolean
vehicle: Vehicle | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const costStore = useCostStore()
const form = reactive({
date: new Date().toISOString().slice(0, 10),
amount: 0,
quantity: 0,
odometer: 0,
})
const errorMessage = ref<string | null>(null)
const vehicleLabel = computed(() => {
if (!props.vehicle) return 'Nincs kiválasztva jármű'
const plate = props.vehicle.license_plate || ''
const brand = props.vehicle.brand || ''
const model = props.vehicle.model || ''
return `${plate} (${brand} ${model})`.trim()
})
async function handleSubmit() {
if (!props.vehicle) {
errorMessage.value = 'Nincs kiválasztva jármű.'
return
}
errorMessage.value = null
// Build payload: cost_type = 'fuel', category auto-set to FUEL
// organization_id is omitted — backend auto-resolves from asset
const payload = {
asset_id: props.vehicle.id,
category_id: 1, // FUEL category (default)
cost_type: 'fuel',
amount_local: form.amount,
currency_local: 'HUF' as const,
date: new Date(form.date).toISOString(),
mileage_at_cost: form.odometer > 0 ? form.odometer : null,
description: `Tankolás: ${form.quantity} L/kWh`,
data: {
quantity: form.quantity,
fuel_type: props.vehicle.fuel_type || null,
},
}
const result = await costStore.addExpense(payload)
if (result) {
// Reset form
form.date = new Date().toISOString().slice(0, 10)
form.amount = 0
form.quantity = 0
form.odometer = 0
emit('saved')
} else {
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
}
}
</script>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
</style>

View File

@@ -3,11 +3,11 @@
<div
v-if="isOpen"
class="fixed inset-0 z-[10001] 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 flex flex-col"
:class="{ 'animate-spin-shrink-out': isClosing }"
style="max-height: 90vh;"
>
<!-- Modal Header -->
@@ -22,7 +22,7 @@
<h3 class="text-lg font-bold text-slate-800">{{ vehicle ? 'Jármű szerkesztése' : 'Új jármű hozzáadása' }}</h3>
</div>
<button
@click="$emit('close')"
@click="handleClose"
class="text-slate-400 transition-colors duration-200 hover:text-red-500 cursor-pointer"
aria-label="Bezárás"
>
@@ -43,8 +43,9 @@
<!-- Step circle -->
<button
@click="goToStep(idx)"
class="flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all duration-200 cursor-pointer"
class="flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all duration-200"
:class="getStepCircleClass(idx)"
:disabled="isEditMode && idx === 0"
>
<span v-if="step > idx"></span>
<span v-else>{{ idx + 1 }}</span>
@@ -75,12 +76,16 @@
<button
v-for="vc in vehicleClasses"
:key="vc.value"
@click="form.vehicle_class = vc.value"
@click="!isEditMode && (form.vehicle_class = vc.value)"
type="button"
class="flex flex-col items-center gap-1 rounded-xl border-2 px-2 py-2 text-xs font-medium transition-all duration-200 cursor-pointer"
:class="form.vehicle_class === vc.value
? 'border-sf-accent bg-sf-accent/5 text-sf-accent shadow-sm'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'"
:disabled="isEditMode"
class="flex flex-col items-center gap-1 rounded-xl border-2 px-2 py-2 text-xs font-medium transition-all duration-200"
:class="[
form.vehicle_class === vc.value
? 'border-sf-accent bg-sf-accent/5 text-sf-accent shadow-sm'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50',
isEditMode ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
]"
>
<span class="text-lg">{{ vc.icon }}</span>
<span>{{ vc.label }}</span>
@@ -130,10 +135,98 @@
</div>
</div>
<!-- -->
<!-- ARCHIVE CONFIRMATION (shown instead of steps when active) -->
<!-- -->
<div v-if="showArchiveConfirm" class="space-y-6">
<div class="rounded-xl border border-red-200 bg-red-50 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-red-800">Jármű végleges eltávolítása</h3>
<p class="text-sm text-red-600">Ez a művelet nem vonható vissza! A jármű archiválásra kerül.</p>
</div>
</div>
<!-- License Plate Confirmation -->
<div class="mb-4">
<label class="mb-1.5 block text-sm font-medium text-red-700">
A megerősítéshez gépeld be a rendszámot: <strong>{{ vehicle?.license_plate }}</strong>
</label>
<input
v-model="archiveConfirmPlate"
type="text"
class="w-full rounded-xl border border-red-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 uppercase"
:placeholder="vehicle?.license_plate || 'Rendszám'"
/>
</div>
<!-- Final Mileage -->
<div class="mb-4">
<label class="mb-1.5 block text-sm font-medium text-red-700">
Utolsó km óra állás
</label>
<input
v-model.number="archiveFinalMileage"
type="number"
min="0"
class="w-full rounded-xl border border-red-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20"
placeholder="0"
/>
</div>
<!-- Archive Reason -->
<div class="mb-4">
<label class="mb-1.5 block text-sm font-medium text-red-700">
Eltávolítás oka <span class="text-red-500">*</span>
</label>
<select
v-model="archiveReason"
class="w-full rounded-xl border border-red-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 appearance-none cursor-pointer"
>
<option value="" disabled>Válassz okot</option>
<option value="Eladás">Eladás</option>
<option value="Gazdasági totálkár / Bontás">Gazdasági totálkár / Bontás</option>
<option value="Lízing/Bérlet lejárta">Lízing/Bérlet lejárta</option>
<option value="Téves rögzítés">Téves rögzítés</option>
<option value="Egyéb">Egyéb</option>
</select>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-3">
<button
@click="cancelArchive"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-600 transition-all hover:bg-slate-100 cursor-pointer"
>
Mégsem
</button>
<button
@click="confirmArchive"
:disabled="!isArchiveValid || isArchiving"
class="inline-flex items-center gap-2 rounded-xl bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<svg v-if="isArchiving" class="h-4 w-4 animate-spin" 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>
<svg v-else 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ isArchiving ? 'Eltávolítás...' : 'Végleges törlés' }}
</button>
</div>
</div>
</div>
<!-- -->
<!-- STEP 1: ALAPADATOK -->
<!-- -->
<div v-if="step === 1" class="space-y-4">
<div v-if="step === 1 && !showArchiveConfirm" class="space-y-4">
<!-- Edit Mode: License Plate & VIN (only shown when editing) -->
<div v-if="isEditMode" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
@@ -276,6 +369,26 @@
placeholder="0"
/>
</div>
<!-- DANGER ZONE (Edit Mode only) -->
<div v-if="isEditMode" class="mt-8 rounded-xl border-2 border-red-200 bg-red-50/50 p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="h-5 w-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h4 class="text-sm font-bold text-red-700 uppercase tracking-wider"> Veszélyzóna</h4>
</div>
<p class="text-xs text-red-600 mb-3">A jármű végleges eltávolítása a garázsból. Ez a művelet nem vonható vissza!</p>
<button
@click="openArchiveConfirm"
class="inline-flex items-center gap-2 rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-red-700 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Jármű eltávolítása
</button>
</div>
</div>
<!-- -->
@@ -313,7 +426,7 @@
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Drive Type -->
<!-- Drive Type (DYNAMIC: motorcycle vs car/commercial options) -->
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">Hajtáslánc</label>
<select
@@ -321,9 +434,12 @@
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="">Válassz</option>
<option value="fwd">FWD (Elsőkerék)</option>
<option value="rwd">RWD (Hátsókerék)</option>
<option value="awd">AWD (Összkerék)</option>
<option v-if="form.vehicle_class === 'motorcycle'" value="chain">Lánc</option>
<option v-if="form.vehicle_class === 'motorcycle'" value="shaft">Kardán</option>
<option v-if="form.vehicle_class === 'motorcycle'" value="belt">Szíj</option>
<option v-if="form.vehicle_class !== 'motorcycle'" value="fwd">FWD (Elsőkerék)</option>
<option v-if="form.vehicle_class !== 'motorcycle'" value="rwd">RWD (Hátsókerék)</option>
<option v-if="form.vehicle_class !== 'motorcycle'" value="awd">AWD (Összkerék)</option>
</select>
</div>
@@ -335,10 +451,13 @@
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="">Válassz</option>
<option value="manual">Manuális</option>
<option value="automatic">Automata</option>
<option value="cvt">CVT</option>
<option value="dct">DCT</option>
<option v-if="form.vehicle_class === 'motorcycle'" value="manual_sequential">Kézi (Szekvenciális)</option>
<option v-if="form.vehicle_class === 'motorcycle'" value="automatic">Automata</option>
<option v-if="form.vehicle_class === 'motorcycle'" value="cvt">CVT</option>
<option v-if="form.vehicle_class !== 'motorcycle'" value="manual">Manuális</option>
<option v-if="form.vehicle_class !== 'motorcycle'" value="automatic">Automata</option>
<option v-if="form.vehicle_class !== 'motorcycle'" value="cvt">CVT</option>
<option v-if="form.vehicle_class !== 'motorcycle'" value="dct">DCT</option>
</select>
</div>
</div>
@@ -363,39 +482,20 @@
<!-- Seat Count -->
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">Ülések száma</label>
<select
v-model="form.seat_count"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="">Válassz</option>
<option :value="2">2</option>
<option :value="4">4</option>
<option :value="5">5</option>
<option :value="7">7</option>
<option :value="8">8</option>
<option :value="9">9</option>
</select>
</div>
</div>
<!-- Motorcycle-specific: Drivetrain (chain/shaft/belt) -->
<div v-if="form.vehicle_class === 'motorcycle'" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">Hajtáslánc (Motor)</label>
<select
v-model="form.mcycle_drivetrain"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="">Válassz</option>
<option value="chain">Lánc</option>
<option value="shaft">Kardán</option>
<option value="belt">Szíj</option>
</select>
<input
v-model.number="form.seat_count"
type="number"
min="1"
max="9"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
placeholder="Pl. 3, 5, 7"
/>
</div>
</div>
</div>
<!-- -->
<!-- STEP 3: FELSZERELTSÉG / JSONB -->
<!-- STEP 3: FELSZERELTSÉG / EXTRÁK -->
<!-- -->
<div v-if="step === 3" class="space-y-4">
<!-- Color -->
@@ -439,29 +539,13 @@
<div v-if="form.vehicle_class === 'motorcycle'" class="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<h4 class="text-sm font-bold text-slate-700">🏍 Motor extrák</h4>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment.plexi" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Plexi</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment.oldaldoboz" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Oldaldoboz</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment.hatsodoboz" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Hátsódoboz</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment.kormanyvegtukor" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Kormányvégtükör</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment.telefontolto" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Telefontöltő</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment.egyedi_kipufogo" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Egyedi kipufogó</span>
<label
v-for="extra in motorcycleExtras"
:key="extra.key"
class="flex items-center gap-2 cursor-pointer"
>
<input type="checkbox" v-model="form.individual_equipment[extra.key]" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">{{ extra.label }}</span>
</label>
</div>
</div>
@@ -470,26 +554,21 @@
<div v-if="['heavy_commercial', 'light_commercial'].includes(form.vehicle_class)" class="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<h4 class="text-sm font-bold text-slate-700">🚛 Haszongépjármű extrák</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Tachográf</label>
<select v-model="form.individual_equipment.tachograf" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
<option value="">Válassz</option>
<option value="van">Van</option>
<option value="nincs">Nincs</option>
</select>
<div v-for="extra in commercialExtras" :key="extra.key">
<template v-if="extra.type === 'select'">
<label class="mb-1 block text-sm font-medium text-slate-700">{{ extra.label }}</label>
<select v-model="form.individual_equipment[extra.key]" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
<option value="">Válassz</option>
<option v-for="opt in extra.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</template>
<template v-else-if="extra.type === 'checkbox'">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment[extra.key]" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">{{ extra.label }}</span>
</label>
</template>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Emelőhátfal</label>
<select v-model="form.individual_equipment.emelohatfal" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
<option value="">Válassz</option>
<option value="van">Van</option>
<option value="nincs">Nincs</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer col-span-2">
<input type="checkbox" v-model="form.individual_equipment.halofulke" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Hálófülke</span>
</label>
</div>
</div>
@@ -497,17 +576,13 @@
<div v-if="form.vehicle_class === 'personal'" class="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
<h4 class="text-sm font-bold text-slate-700">🚗 Személyautó extrák</h4>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment.vonohorog" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Vonóhorog</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment.isofix" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Isofix</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="form.individual_equipment.napfenyteto" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">Napfénytető</span>
<label
v-for="extra in personalCarExtras"
:key="extra.key"
class="flex items-center gap-2 cursor-pointer"
>
<input type="checkbox" v-model="form.individual_equipment[extra.key]" class="rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20" />
<span class="text-sm text-slate-700">{{ extra.label }}</span>
</label>
</div>
</div>
@@ -544,8 +619,8 @@
</div>
</div>
<!-- DYNAMIC WIZARD FOOTER -->
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4 bg-slate-50 shrink-0">
<!-- DYNAMIC WIZARD FOOTER (hidden during archive confirmation) -->
<div v-if="!showArchiveConfirm" class="flex items-center justify-between border-t border-slate-200 px-6 py-4 bg-slate-50 shrink-0">
<!-- Left side: navigation buttons -->
<div class="flex items-center gap-2">
<!-- Vissza (visible when step > 0, but hidden in edit mode on step 1 to prevent going back to step 0) -->
@@ -584,15 +659,15 @@
<div class="flex items-center gap-2">
<!-- Mégsem (always visible) -->
<button
@click="$emit('close')"
@click="handleClose"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-600 transition-all hover:bg-slate-100 cursor-pointer"
>
Mégsem
</button>
<!-- Mentés (visible only on step 3) -->
<!-- Mentés (visible on step 3 OR when editing any step) -->
<button
v-if="step === 3"
v-if="step === 3 || isEditMode"
@click="handleSave"
:disabled="isSaving || !isValid"
class="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
@@ -610,15 +685,14 @@
</div>
</div>
</div>
</div>
</Teleport>
</Teleport>
<!-- Toast Notification (sibling root, outside modal Teleport) -->
<Teleport to="body">
<div
v-if="toastVisible"
class="fixed top-6 right-6 z-[99999] flex items-center gap-3 rounded-2xl px-5 py-4 shadow-2xl transition-all duration-300 animate-slide-in"
:class="toastType === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'"
<!-- Toast Notification (sibling root, outside modal Teleport) -->
<Teleport to="body">
<div
v-if="toastVisible"
class="fixed top-6 right-6 z-[99999] flex items-center gap-3 rounded-2xl px-5 py-4 shadow-2xl transition-all duration-300"
:class="toastType === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'"
>
<svg v-if="toastType === 'success'" class="h-5 w-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -637,11 +711,13 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useVehicleStore } from '../../stores/vehicle'
import { useAuthStore } from '../../stores/auth'
import api from '../../api/axios'
const vehicleStore = useVehicleStore()
const authStore = useAuthStore()
const props = defineProps<{
isOpen: boolean
@@ -693,6 +769,29 @@ const vehicleClasses = [
{ value: 'other', label: 'Egyéb', icon: '🔧' },
]
// ── Category-specific equipment constant arrays (Bug 3 fix) ──
const motorcycleExtras = [
{ key: 'plexi', label: 'Plexi' },
{ key: 'oldaldoboz', label: 'Oldaldoboz' },
{ key: 'hatsodoboz', label: 'Hátsódoboz' },
{ key: 'kormanyvegtukor', label: 'Kormányvégtükör' },
{ key: 'telefontolto', label: 'Telefontöltő' },
{ key: 'egyedi_kipufogo', label: 'Egyedi kipufogó' },
{ key: 'gyorsvalto', label: 'Gyorsváltó' },
]
const commercialExtras = [
{ key: 'tachograf', label: 'Tachográf', type: 'select', options: [{ value: 'van', label: 'Van' }, { value: 'nincs', label: 'Nincs' }] },
{ key: 'emelohatfal', label: 'Emelőhátfal', type: 'select', options: [{ value: 'van', label: 'Van' }, { value: 'nincs', label: 'Nincs' }] },
{ key: 'halofulke', label: 'Hálófülke', type: 'checkbox' },
]
const personalCarExtras = [
{ key: 'vonohorog', label: 'Vonóhorog' },
{ key: 'isofix', label: 'Isofix' },
{ key: 'napfenyteto', label: 'Napfénytető' },
]
// ── Normalization helper ──
function normalizeId(str: string): string {
return str.replace(/[\s-]/g, '').toUpperCase()
@@ -727,9 +826,6 @@ const form = reactive({
door_count: null as number | null,
seat_count: null as number | null,
// Motorcycle-specific (stored in individual_equipment)
mcycle_drivetrain: '',
// STEP 3: Equipment / JSONB
color: '',
ac_type: '',
@@ -740,6 +836,65 @@ const form = reactive({
})
const isSaving = ref(false)
const isClosing = ref(false)
// ── Archive (Soft Delete) state ──
const showArchiveConfirm = ref(false)
const archiveConfirmPlate = ref('')
const archiveFinalMileage = ref<number | null>(null)
const archiveReason = ref('')
const isArchiving = ref(false)
function openArchiveConfirm() {
showArchiveConfirm.value = true
// Üres mezők — a felhasználónak kell kitöltenie
archiveConfirmPlate.value = ''
archiveFinalMileage.value = null
archiveReason.value = ''
}
function cancelArchive() {
showArchiveConfirm.value = false
archiveConfirmPlate.value = ''
archiveFinalMileage.value = null
archiveReason.value = ''
}
const isArchiveValid = computed(() => {
const plate = props.vehicle?.license_plate || ''
return (
archiveConfirmPlate.value.trim().toUpperCase() === plate.trim().toUpperCase() &&
archiveFinalMileage.value !== null &&
archiveFinalMileage.value > 0 &&
archiveReason.value.trim().length > 0
)
})
async function confirmArchive() {
if (!isArchiveValid.value || !props.vehicle?.id) return
isArchiving.value = true
try {
const success = await vehicleStore.archiveVehicle(
props.vehicle.id,
archiveFinalMileage.value,
archiveReason.value
)
if (success) {
showToast('✅ Jármű sikeresen eltávolítva a garázsból', 'success')
showArchiveConfirm.value = false
// Close the modal and refresh the vehicle list
handleClose()
vehicleStore.fetchVehicles()
}
} catch (err: any) {
const errorMsg = err?.response?.data?.detail || err?.message || 'Ismeretlen hiba történt az eltávolítás során.'
showToast(errorMsg, 'error')
} finally {
isArchiving.value = false
}
}
// ── Dirty tracking ──
const isDirty = ref(false)
@@ -752,47 +907,23 @@ watch(
{ deep: true }
)
// ── 🦎 CHAMELEON: Dynamic Trim Levels based on vehicle_class ──
const trimLevelMap: Record<string, { value: string; label: string }[]> = {
personal: [
{ value: 'sedan', label: 'Sedan' },
{ value: 'combi', label: 'Kombi' },
{ value: 'suv', label: 'SUV' },
{ value: 'hatchback', label: 'Ferdehátú' },
{ value: 'cabrio', label: 'Cabrio' },
{ value: 'coupe', label: 'Coupé' },
],
motorcycle: [
{ value: 'enduro', label: 'Enduro' },
{ value: 'sport', label: 'Sport' },
{ value: 'naked', label: 'Naked' },
{ value: 'chopper', label: 'Chopper' },
{ value: 'cruiser', label: 'Cruiser' },
{ value: 'touring', label: 'Touring' },
{ value: 'scooter', label: 'Robogó' },
],
light_commercial: [
{ value: 'box', label: 'Dobozos' },
{ value: 'open_platform', label: 'Nyitott platós' },
{ value: 'tarpaulin', label: 'Ponyvás' },
{ value: 'refrigerated', label: 'Hűtős' },
],
heavy_commercial: [
{ value: 'box', label: 'Dobozos' },
{ value: 'open_platform', label: 'Nyitott platós' },
{ value: 'tarpaulin', label: 'Ponyvás' },
{ value: 'refrigerated', label: 'Hűtős' },
{ value: 'tractor', label: 'Nyergesvontató' },
],
machinery: [
{ value: 'forklift', label: 'Targonca' },
{ value: 'excavator', label: 'Markoló' },
{ value: 'tractor', label: 'Traktor' },
],
}
// ── 🦎 BODY TYPE DICTIONARY: Loaded from API ──
const bodyTypeDictionary = ref<{ id: number; vehicle_class: string; code: string; name_hu: string }[]>([])
// Fetch body types from the API on mount
onMounted(async () => {
try {
const res = await api.get('/catalog/body-types')
bodyTypeDictionary.value = res.data || []
} catch (err) {
console.error('[VehicleFormModal] Failed to load body types:', err)
}
})
const availableTrimLevels = computed(() => {
return trimLevelMap[form.vehicle_class] || []
return bodyTypeDictionary.value
.filter(bt => bt.vehicle_class === form.vehicle_class)
.map(bt => ({ value: bt.code, label: bt.name_hu }))
})
// ── Show door & seat for vehicles with a cabin ──
@@ -809,7 +940,9 @@ const showAcField = computed(() => {
watch(
() => form.vehicle_class,
(newClass) => {
const validValues = trimLevelMap[newClass]?.map(o => o.value) || []
const validValues = bodyTypeDictionary.value
.filter(bt => bt.vehicle_class === newClass)
.map(bt => bt.code)
if (form.trim_level && !validValues.includes(form.trim_level)) {
form.trim_level = ''
}
@@ -820,23 +953,19 @@ watch(
const isStepValid = computed(() => {
switch (step.value) {
case 0:
// Step 0: license_plate and vehicle_class required
return (
form.license_plate.trim().length >= 2 &&
form.vehicle_class.trim().length > 0
)
case 1:
// Step 1: brand, model, fuel_type required
return (
form.brand.trim().length > 0 &&
form.model.trim().length > 0 &&
form.fuel_type.trim().length > 0
)
case 2:
// Step 2: all optional
return true
case 3:
// Step 3: all optional
return true
default:
return false
@@ -857,7 +986,6 @@ const isValid = computed(() => {
// ── Step Navigation ──
async function nextStep() {
if (step.value === 0) {
// Step 0 → Step 1: Live DB check first
const canProceed = await checkVehicleExists()
if (!canProceed) return
}
@@ -869,7 +997,8 @@ function prevStep() {
}
function goToStep(idx: number) {
// Allow going back to any previous step, but only forward if step is valid
// 🚫 BLOCK navigation to step 0 (Identifikáció) in edit mode
if (isEditMode.value && idx === 0) return
if (idx <= step.value) {
step.value = idx
} else if (isStepValid.value) {
@@ -879,6 +1008,10 @@ function goToStep(idx: number) {
// ── Helpers ──
function getStepCircleClass(idx: number) {
// 🚫 Step 0 is locked in edit mode — show muted styling
if (isEditMode.value && idx === 0) {
return 'bg-slate-200 text-slate-400 opacity-50 cursor-not-allowed'
}
if (step.value > idx) return 'bg-emerald-500 text-white'
if (step.value === idx) return 'bg-sf-accent text-white ring-2 ring-sf-accent/30'
return 'bg-slate-200 text-slate-500'
@@ -933,7 +1066,6 @@ function resetForm() {
form.transmission_type = ''
form.door_count = null
form.seat_count = null
form.mcycle_drivetrain = ''
form.color = ''
form.ac_type = ''
form.notes = ''
@@ -954,7 +1086,6 @@ async function checkVehicleExists(): Promise<boolean> {
isChecking.value = true
try {
// Build params: license_plate is required, vin is optional
const params: Record<string, string> = { license_plate: plate }
const vin = normalizeId(form.vin || '')
if (vin) {
@@ -970,7 +1101,6 @@ async function checkVehicleExists(): Promise<boolean> {
return true
} catch (err: any) {
console.error('[VehicleFormModal] checkVehicleExists error:', err)
// If the check fails, allow proceeding (degraded UX)
return true
} finally {
isChecking.value = false
@@ -993,11 +1123,27 @@ function checkDuplicateLicensePlate(plate: string): boolean {
return false
}
// ── Close Handler ──
function handleClose() {
isClosing.value = true
setTimeout(() => {
if (isEditMode.value && props.vehicle) {
resetForm()
} else {
resetForm()
}
emit('close')
// Reset closing state after emit so next open works cleanly
setTimeout(() => {
isClosing.value = false
}, 50)
}, 300)
}
// ── Save Handler ──
async function handleSave() {
if (!isValid.value) return
// Frontend Duplicate Validation (CREATE mode + EDIT mode when plate changed)
const isDuplicate = checkDuplicateLicensePlate(form.license_plate)
if (isDuplicate) {
showToast('Ez a rendszám már szerepel a járműveid között!', 'error')
@@ -1007,7 +1153,6 @@ async function handleSave() {
isSaving.value = true
try {
// Build payload matching AssetCreate schema
const payload: Record<string, any> = {
license_plate: normalizeId(form.license_plate),
brand: form.brand.trim(),
@@ -1016,7 +1161,6 @@ async function handleSave() {
fuel_type: form.fuel_type,
}
// If brand or model changed manually, disconnect the old catalog reference
if (props.vehicle?.id) {
const brandChanged = form.brand.trim() !== (props.vehicle.brand || '')
const modelChanged = form.model.trim() !== (props.vehicle.model || '')
@@ -1042,12 +1186,9 @@ async function handleSave() {
if (form.ac_type) extraEquipment.ac_type = form.ac_type
if (form.notes) extraEquipment.notes = form.notes
if (form.name) extraEquipment.name = form.name
// Save custom_vehicle_class when 'other' is selected
if (form.vehicle_class === 'other' && form.custom_vehicle_class) {
extraEquipment.custom_vehicle_class = form.custom_vehicle_class.trim()
}
// 🦎 CHAMELEON: Category-specific fields → individual_equipment
if (form.mcycle_drivetrain) extraEquipment.mcycle_drivetrain = form.mcycle_drivetrain
// Merge category-specific individual_equipment (motorcycle, commercial, personal extras)
if (form.individual_equipment && Object.keys(form.individual_equipment).length > 0) {
Object.assign(extraEquipment, form.individual_equipment)
@@ -1056,13 +1197,24 @@ async function handleSave() {
payload.individual_equipment = extraEquipment
}
// ── 🛡️ KETTŐS VÉDELEM: Csendes szervezet hozzárendelés ──
// A felhasználó tudta nélkül csatoljuk az organization_id-t a payloadhoz,
// hogy soha ne kerüljön NULL értékkel az adatbázisba.
// Prioritás: active_organization_id > első elérhető szervezet
const silentOrgId =
authStore.user?.active_organization_id ||
authStore.myOrganizations[0]?.organization_id ||
null
if (silentOrgId !== null && !payload.organization_id) {
payload.organization_id = silentOrgId
console.log('[VehicleFormModal] 🛡️ Silent org assignment:', silentOrgId)
}
let savedVehicle: any
if (props.vehicle?.id) {
// EDIT mode: call updateVehicle
savedVehicle = await vehicleStore.updateVehicle(props.vehicle.id, payload)
} else {
// CREATE mode: call createVehicle
savedVehicle = await vehicleStore.createVehicle(payload)
}
@@ -1087,10 +1239,8 @@ watch(
(newVal) => {
if (newVal) {
if (props.vehicle) {
// EDIT mode: skip step 0, go directly to step 1 (Basic Data)
step.value = 1
} else {
// CREATE mode: reset everything, start at step 0
resetForm()
}
}
@@ -1117,7 +1267,6 @@ watch(
form.door_count = newVal.door_count || null
form.trim_level = newVal.trim_level || ''
form.seat_count = newVal.seat_count || null
form.mcycle_drivetrain = newVal.individual_equipment?.mcycle_drivetrain || ''
form.color = newVal.individual_equipment?.color || newVal.color || ''
form.ac_type = newVal.individual_equipment?.ac_type || ''
form.notes = newVal.individual_equipment?.notes || newVal.notes || ''
@@ -1131,7 +1280,6 @@ watch(
delete form.individual_equipment.notes
delete form.individual_equipment.name
delete form.individual_equipment.custom_vehicle_class
delete form.individual_equipment.mcycle_drivetrain
isDirty.value = false
} else {
resetForm()
@@ -1156,4 +1304,28 @@ watch(
.animate-slide-in {
animation: slideIn 0.3s ease-out forwards;
}
@keyframes spinShrinkOut {
from {
transform: scale(1) rotate(0deg);
opacity: 1;
}
to {
transform: scale(0.7) rotate(10deg);
opacity: 0;
}
}
.animate-spin-shrink-out {
animation: spinShrinkOut 0.3s ease-in forwards;
}
/* ── Hide number input spinners ── */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,11 @@
>
<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"
class="relative w-full max-w-3xl mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-scale-in"
>
<!-- Header -->
<!--
HEADER License Plate Hero + Vehicle Identity
-->
<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">
@@ -50,71 +52,374 @@
</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"
/>
<!--
LICENSE PLATE HERO
-->
<div class="flex justify-center pt-6 pb-4">
<VehiclePlateBadge
:license-plate="vehicle?.license_plate || ''"
:country-code="vehicle?.countryCode || vehicle?.country_code"
size="lg"
/>
</div>
<!--
3-TAB NAVIGATION
-->
<div class="px-6">
<div class="flex border-b border-slate-200 gap-1">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
class="flex items-center gap-2 px-4 py-3 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer"
:class="activeTab === tab.id
? 'border-sf-accent text-sf-accent'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'"
>
<span class="text-base">{{ tab.icon }}</span>
<span>{{ tab.label }}</span>
</button>
</div>
</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>
<!--
TAB CONTENT
-->
<div class="p-6 max-h-[55vh] overflow-y-auto">
<!-- TAB 1: Alapadatok (Physical State) -->
<div v-if="activeTab === 'basics'" class="space-y-4">
<!-- Info Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- 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">{{ engineDisplay || '—' }}</p>
<p v-if="vehicle?.power_kw" class="text-xs text-slate-500 mt-0.5">{{ vehicle.power_kw }} kW ({{ hpDisplay }})</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>
<!-- Body / Karosszéria -->
<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">Karosszéria</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.vehicle_class || vehicle?.body_type || '—' }}</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>
<!-- VIN / Alvázszám -->
<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">Alvázszám (VIN)</p>
<p class="text-sm font-bold text-slate-800 font-mono tracking-wider">{{ vehicle?.vin || '—' }}</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>
<!-- Current 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">Jelenlegi 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>
<!-- Transmission -->
<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">Váltó</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.transmission_type || '—' }}</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>
<!-- Drive Type -->
<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">Hajtás</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.drive_type || '—' }}</p>
</div>
<!-- Fuel Type -->
<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">Üzemanyag</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.fuel_type || '—' }}</p>
</div>
<!-- Trim Level / Felszereltség -->
<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">Felszereltség</p>
<p class="text-sm font-bold text-slate-800">{{ vehicle?.trim_level || '—' }}</p>
</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>
<!-- Vehicle Condition Real Trust Score -->
<div class="rounded-xl border border-slate-200 bg-gradient-to-r from-slate-50 to-white p-5 mt-2">
<div class="flex items-center justify-between mb-3">
<p class="text-sm font-bold text-slate-700">Gépjármű Állapot</p>
<span
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-bold"
:class="trustScoreColorClass"
>
{{ trustScoreDisplay }}
</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-slate-200">
<div
class="h-full rounded-full transition-all duration-1000"
:class="trustScoreBarColorClass"
:style="{ width: trustScorePercent + '%' }"
/>
</div>
<div class="flex justify-between mt-1.5 text-[10px] text-slate-400">
<span>Kopó alkatrészek</span>
<span>Karosszéria</span>
<span>Motor & Erőátvitel</span>
<span>Elektronika</span>
</div>
</div>
</div>
<!-- TAB 2: Pénzügyek / TCO -->
<div v-if="activeTab === 'finance'" class="space-y-5">
<!-- Annual Total Cost Hero -->
<div class="rounded-2xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-6 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Éves Összköltség (TCO)</p>
<p class="text-4xl font-extrabold text-slate-800">1,245,000 Ft</p>
<p class="text-xs text-slate-400 mt-1">~ 3,411 Ft / nap</p>
</div>
<!-- Task 5: Freemium Logic -->
<!-- Free users: simple clean table -->
<template v-if="!isPremium">
<div class="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-slate-100 border-b border-slate-200">
<th class="text-left px-4 py-3 font-semibold text-slate-600">Költségtípus</th>
<th class="text-right px-4 py-3 font-semibold text-slate-600">Összeg</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Biztosítás (KGFB + Casco)</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">320,000 Ft</td>
</tr>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Gépjárműadó</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">45,000 Ft</td>
</tr>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Műszaki vizsga</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">18,000 Ft</td>
</tr>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Tankolás (üzemanyag)</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">520,000 Ft</td>
</tr>
<tr class="border-b border-slate-200">
<td class="px-4 py-2.5 text-slate-600">Szerviz & Karbantartás</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">210,000 Ft</td>
</tr>
<tr>
<td class="px-4 py-2.5 text-slate-600">Parkolás & Útdíj</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">132,000 Ft</td>
</tr>
</tbody>
<tfoot>
<tr class="bg-slate-100 border-t border-slate-200">
<td class="px-4 py-3 font-bold text-slate-700">Összesen</td>
<td class="px-4 py-3 text-right font-bold text-slate-800">1,245,000 Ft</td>
</tr>
</tfoot>
</table>
</div>
</template>
<!-- Premium users: detailed breakdown with charts -->
<template v-if="isPremium">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Fixed Costs -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center gap-2 mb-3">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600 text-sm">🔒</span>
<p class="text-sm font-bold text-slate-700">Állandó költségek</p>
</div>
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Biztosítás (KGFB + Casco)</span>
<span class="text-sm font-bold text-slate-700">320,000 Ft</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Gépjárműadó</span>
<span class="text-sm font-bold text-slate-700">45,000 Ft</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Műszaki vizsga</span>
<span class="text-sm font-bold text-slate-700">18,000 Ft</span>
</div>
<div class="border-t border-slate-200 pt-2 flex items-center justify-between">
<span class="text-xs font-bold text-slate-600">Állandó összesen</span>
<span class="text-sm font-bold text-slate-800">383,000 Ft</span>
</div>
</div>
</div>
<!-- Running Costs -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center gap-2 mb-3">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600 text-sm"></span>
<p class="text-sm font-bold text-slate-700">Folyó költségek</p>
</div>
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Tankolás (üzemanyag)</span>
<span class="text-sm font-bold text-slate-700">520,000 Ft</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Szerviz & Karbantartás</span>
<span class="text-sm font-bold text-slate-700">210,000 Ft</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-500">Parkolás & Útdíj</span>
<span class="text-sm font-bold text-slate-700">132,000 Ft</span>
</div>
<div class="border-t border-slate-200 pt-2 flex items-center justify-between">
<span class="text-xs font-bold text-slate-600">Folyó összesen</span>
<span class="text-sm font-bold text-slate-800">862,000 Ft</span>
</div>
</div>
</div>
</div>
<!-- Premium: Cost distribution chart placeholder -->
<div class="rounded-xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-5">
<p class="text-sm font-bold text-slate-700 mb-3">Költségeloszlás (éves)</p>
<div class="space-y-2">
<div>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>Biztosítás</span>
<span>26%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div class="h-full rounded-full bg-blue-500" style="width: 26%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>Üzemanyag</span>
<span>42%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div class="h-full rounded-full bg-emerald-500" style="width: 42%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>Szerviz</span>
<span>17%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div class="h-full rounded-full bg-amber-500" style="width: 17%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>Egyéb</span>
<span>15%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div class="h-full rounded-full bg-purple-500" style="width: 15%"></div>
</div>
</div>
</div>
</div>
</template>
<!-- TCO per km hint (visible to all) -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500">
<span class="font-semibold text-slate-700">TCO / km:</span> ~ 62 Ft/km
<span class="text-slate-300 mx-2">|</span>
<span class="font-semibold text-slate-700">Havi átlag:</span> ~ 103,750 Ft
</p>
</div>
</div>
<!-- TAB 3: Figyelmeztetések & Időpontok -->
<div v-if="activeTab === 'alerts'" class="space-y-4">
<!-- Task 6: Statistics moved here from Tab 2 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Fogyasztás</p>
<p class="text-2xl font-extrabold text-slate-800">
{{ vehicle?.fuel_type === 'electric' ? '—' : '8.2' }} <span class="text-sm font-semibold text-slate-500">{{ vehicle?.fuel_type === 'electric' ? 'kWh/100km' : 'l/100km' }}</span>
</p>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Km Költség</p>
<p class="text-2xl font-extrabold text-slate-800">62 <span class="text-sm font-semibold text-slate-500">Ft/km</span></p>
</div>
</div>
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Idővonal Közelgő események</p>
<div class="space-y-3">
<!-- Alert 1: Insurance renewal -->
<div class="flex items-start gap-4 rounded-xl border border-amber-200 bg-amber-50/50 p-4">
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-600 text-base shrink-0">📋</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-slate-800">Kötelező biztosítás fordulónap</p>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-[10px] font-bold text-amber-700 uppercase">30 nap</span>
</div>
<p class="text-xs text-slate-500 mt-0.5">Lemondási határidő: 2026. 07. 12. Jelenlegi díj: 85,000 Ft/év</p>
<div class="flex gap-2 mt-2">
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-600"> Lemondási határidő</span>
</div>
</div>
</div>
<!-- Alert 2: MOT / Műszaki vizsga -->
<div class="flex items-start gap-4 rounded-xl border border-red-200 bg-red-50/50 p-4">
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-red-100 text-red-600 text-base shrink-0">🔧</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-slate-800">Műszaki vizsga lejárata</p>
<span class="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-[10px] font-bold text-red-700 uppercase">14 nap</span>
</div>
<p class="text-xs text-slate-500 mt-0.5">Lejárat: 2026. 06. 26. Hátralévő napok: 14</p>
<div class="flex gap-2 mt-2">
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-600">🔴 Sürgős</span>
</div>
</div>
</div>
<!-- Alert 3: Next service -->
<div class="flex items-start gap-4 rounded-xl border border-blue-200 bg-blue-50/50 p-4">
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-blue-100 text-blue-600 text-base shrink-0"></span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-slate-800">Következő szerviz</p>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase">45 nap</span>
</div>
<p class="text-xs text-slate-500 mt-0.5">
{{ vehicle?.current_mileage
? `Jelenleg: ${vehicle.current_mileage.toLocaleString()} km — Következő: ${(vehicle.current_mileage + 15000).toLocaleString()} km`
: '15,000 km vagy 1 év' }}
</p>
<div class="flex gap-2 mt-2">
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-600">📅 Tervezett</span>
</div>
</div>
</div>
<!-- Alert 4: Seasonal tire change -->
<div class="flex items-start gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-200 text-slate-500 text-base shrink-0">🛞</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-slate-800">Szezonális gumicsere</p>
<span class="inline-flex items-center rounded-full bg-slate-200 px-2.5 py-0.5 text-[10px] font-bold text-slate-600 uppercase">90 nap</span>
</div>
<p class="text-xs text-slate-500 mt-0.5">Téli Nyári gumi: 2026. 09. 10. (ajánlott időszak)</p>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<!--
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')"
@@ -122,7 +427,9 @@
>
Bezárás
</button>
<!-- Task 1: Szerkesztés gomb csak az Alapadatok fülön látható -->
<button
v-if="activeTab === 'basics'"
@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"
>
@@ -138,8 +445,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, watch } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import { useAuthStore } from '../../stores/auth'
import VehiclePlateBadge from './VehiclePlateBadge.vue'
const props = defineProps<{
@@ -153,16 +461,87 @@ const emit = defineEmits<{
'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'
const authStore = useAuthStore()
// ── Tab state ──
const activeTab = ref<'basics' | 'finance' | 'alerts'>('basics')
const tabs = [
{ id: 'basics' as const, label: 'Alapadatok', icon: '📋' },
{ id: 'finance' as const, label: 'Pénzügyek (TCO)', icon: '💰' },
{ id: 'alerts' as const, label: 'Figyelmeztetések', icon: '🔔' },
]
// ── Task 2: Reset activeTab to 'basics' every time modal opens ──
watch(() => props.isOpen, (newVal) => {
if (newVal) {
activeTab.value = 'basics'
}
})
// ── Task 4: Trust Score computed helpers ──
const trustScorePercent = computed(() => {
const v = props.vehicle
if (!v) return 0
// Use condition_score from backend (0-100), fallback to a derived value
if (v.condition_score !== undefined && v.condition_score !== null) {
return Math.round(Math.min(100, Math.max(0, v.condition_score)))
}
// Fallback: derive from is_verified and status
if (v.is_verified && v.status === 'active') return 85
if (v.status === 'active') return 65
return 40
})
const trustScoreLabel = computed(() => {
const pct = trustScorePercent.value
if (pct >= 90) return 'Kiváló'
if (pct >= 75) return 'Jó'
if (pct >= 55) return 'Átlagos'
if (pct >= 35) return 'Gyenge'
return 'Kritikus'
})
const trustScoreDisplay = computed(() => {
return `${trustScorePercent.value}% (${trustScoreLabel.value})`
})
const trustScoreColorClass = computed(() => {
const pct = trustScorePercent.value
if (pct >= 75) return 'bg-emerald-100 text-emerald-700'
if (pct >= 55) return 'bg-amber-100 text-amber-700'
if (pct >= 35) return 'bg-orange-100 text-orange-700'
return 'bg-red-100 text-red-700'
})
const trustScoreBarColorClass = computed(() => {
const pct = trustScorePercent.value
if (pct >= 75) return 'bg-gradient-to-r from-emerald-400 to-emerald-600'
if (pct >= 55) return 'bg-gradient-to-r from-amber-400 to-amber-600'
if (pct >= 35) return 'bg-gradient-to-r from-orange-400 to-orange-600'
return 'bg-gradient-to-r from-red-400 to-red-600'
})
// ── Task 5: Freemium check ──
const isPremium = computed(() => {
return authStore.user?.subscription_plan === 'premium' || authStore.user?.subscription_plan === 'enterprise'
})
// ── Computed helpers ──
const engineDisplay = computed(() => {
const v = props.vehicle
if (!v) return '—'
if (v.engine) return v.engine
const parts: string[] = []
if (v.engine_capacity) parts.push(`${(v.engine_capacity / 1000).toFixed(1)}L`)
if (v.fuel_type) parts.push(v.fuel_type)
return parts.length > 0 ? parts.join(' ') : '—'
})
const hpDisplay = computed(() => {
const kw = props.vehicle?.power_kw
if (!kw) return '—'
return `${Math.round(kw * 1.341)} LE`
})
</script>

View File

@@ -1,50 +0,0 @@
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',
},
]

104
frontend/src/stores/cost.ts Normal file
View File

@@ -0,0 +1,104 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api/axios'
/**
* Payload shape for creating a new expense via POST /expenses/
*/
export interface AddExpensePayload {
asset_id: string
organization_id?: number | null // Auto-resolved by backend from asset if omitted
category_id: number
cost_type: string
amount_local: number
currency_local?: string
date: string
mileage_at_cost?: number | null
description?: string | null
data?: Record<string, any>
}
/**
* Response shape from POST /expenses/
*/
export interface ExpenseResponse {
status: string
id: string
asset_id: string
cost_category: string
amount_net: number
date: string
}
export const useCostStore = defineStore('cost', () => {
// ── State ──────────────────────────────────────────────────────────
const isSubmitting = ref(false)
const lastError = ref<string | null>(null)
const lastSuccess = ref<ExpenseResponse | null>(null)
// ── Actions ────────────────────────────────────────────────────────
/**
* Add a new expense (fuel, fee, or other cost) via POST /expenses/.
* Used by SimpleFuelModal, ComplexExpenseModal, and the dashboard cost card.
*/
async function addExpense(payload: AddExpensePayload): Promise<ExpenseResponse | null> {
isSubmitting.value = true
lastError.value = null
lastSuccess.value = null
try {
// Build request body — omit organization_id if not provided (backend resolves it)
const body: Record<string, any> = {
asset_id: payload.asset_id,
category_id: payload.category_id,
cost_type: payload.cost_type,
amount_local: payload.amount_local,
currency_local: payload.currency_local || 'HUF',
date: payload.date,
mileage_at_cost: payload.mileage_at_cost ?? null,
description: payload.description || null,
data: payload.data || {},
}
if (payload.organization_id != null) {
body.organization_id = payload.organization_id
}
const res = await api.post('/expenses/', body)
const responseData = res.data as ExpenseResponse
lastSuccess.value = responseData
console.log('[CostStore] Expense created successfully:', responseData)
return responseData
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Nem sikerült rögzíteni a költséget. Ellenőrizd a kapcsolatot.'
lastError.value = message
console.error('[CostStore] addExpense error:', err)
return null
} finally {
isSubmitting.value = false
}
}
/**
* Clear the last error and success state.
*/
function resetState() {
lastError.value = null
lastSuccess.value = null
}
return {
// State
isSubmitting,
lastError,
lastSuccess,
// Actions
addExpense,
resetState,
}
})

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import api from '../api/axios'
/**
@@ -44,6 +44,10 @@ export interface Vehicle {
// Primary vehicle flag (from backend individual_equipment JSONB)
is_primary: boolean
// Organization association (from backend AssetResponse)
current_organization_id: number | null
owner_organization_id: number | null
}
export const useVehicleStore = defineStore('vehicle', () => {
@@ -52,6 +56,24 @@ export const useVehicleStore = defineStore('vehicle', () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
// ── Getters ────────────────────────────────────────────────────────
/**
* Sorted vehicles: primary/favorite vehicles first, then alphabetical by brand/model.
* Used across all vehicle list displays (dashboard card, manager, etc.).
*/
const sortedVehicles = computed<Vehicle[]>(() => {
return [...vehicles.value].sort((a, b) => {
// Primary vehicles first
if (a.is_primary && !b.is_primary) return -1
if (!a.is_primary && b.is_primary) return 1
// Then alphabetical by brand + model
const nameA = `${a.brand || ''} ${a.model || ''} ${a.license_plate || ''}`.trim().toLowerCase()
const nameB = `${b.brand || ''} ${b.model || ''} ${b.license_plate || ''}`.trim().toLowerCase()
return nameA.localeCompare(nameB)
})
})
// ── Actions ────────────────────────────────────────────────────────
/**
@@ -168,16 +190,58 @@ export const useVehicleStore = defineStore('vehicle', () => {
console.log('[VehicleStore] setPrimaryVehicle:', vehicleId)
}
/**
* Archive (soft delete) a vehicle via POST /assets/vehicles/{id}/archive.
* On success, removes the vehicle from the local state.
*/
async function archiveVehicle(id: string, finalMileage: number, archiveReason: string): Promise<boolean> {
isLoading.value = true
error.value = null
try {
await api.post(`/assets/vehicles/${id}/archive`, {
final_mileage: finalMileage,
archive_reason: archiveReason,
})
// Remove the vehicle from local state
vehicles.value = vehicles.value.filter(v => v.id !== id)
return true
} catch (err: any) {
if (err.response) {
console.error('[VehicleStore] archiveVehicle error response:', {
status: err.response.status,
data: err.response.data,
})
} else {
console.error('[VehicleStore] archiveVehicle 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 eltávolítani a járművet.'
error.value = message
throw err
} finally {
isLoading.value = false
}
}
return {
// State
vehicles,
isLoading,
error,
// Getters
sortedVehicles,
// Actions
fetchVehicles,
createVehicle,
updateVehicle,
setPrimaryVehicle,
archiveVehicle,
}
})

View File

@@ -62,7 +62,7 @@
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
<span class="ml-auto text-xs text-white/60">{{ displayVehicles.length }} {{ t('dashboard.vehicles') }}</span>
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
@@ -72,7 +72,7 @@
:key="vehicle.id"
:vehicle="vehicle"
@click="openCard('vehicles')"
@plate-click="openCard('vehicles', vehicle.license_plate)"
@plate-click="openVehicleDetail(vehicle)"
/>
<!-- Empty state (only when BOTH real and mock are empty) -->
<div
@@ -89,49 +89,67 @@
</div>
<!--
Card 2: 💰 Costs & TCO (Költségek & Analitika)
Card 2: 💰 Költségek & Gyorsműveletek (Action Center)
-->
<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="openCard('financials')"
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"
>
<!-- 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.fuelCost') }} & TCO</span>
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2">
<span class="text-white font-bold text-sm tracking-wide">💰 Költségek & Gyorsműveletek</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Monthly cost summary -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider font-semibold">{{ t('dashboard.monthlyScore') }}</p>
<p class="text-2xl font-extrabold text-slate-800 mt-1"> 2,450</p>
</div>
<!-- Stat rows -->
<div class="space-y-1.5">
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
<span class="text-slate-500">{{ t('dashboard.totalKm') }}</span>
<span class="font-semibold text-slate-800">12,430 km</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
<span class="text-slate-500">{{ t('dashboard.fuelCost') }}</span>
<span class="font-semibold text-slate-800"> 0.42</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
<span class="text-slate-500">{{ t('dashboard.serviceCost') }}</span>
<span class="font-semibold text-slate-800"> 1.28</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
<span class="text-slate-500">{{ t('dashboard.co2Savings') }}</span>
<span class="font-semibold text-emerald-600">-340 kg</span>
</div>
</div>
<!-- Vehicle Selector Dropdown -->
<div class="mb-3">
<label class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1 block">Jármű</label>
<select
v-model="selectedActionVehicleId"
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option
v-for="v in vehicleStore.sortedVehicles"
:key="v.id"
:value="v.id"
>
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
</option>
</select>
</div>
<!-- Action Buttons -->
<div class="flex-1 flex flex-col gap-2.5 justify-center">
<!-- Tankolás (Big, prominent) -->
<button
@click="openFuelModal"
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
>
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg"></span>
<span>Tankolás rögzítése</span>
<span class="ml-auto text-white/60 text-xs"></span>
</button>
<!-- 🅿 Parkolás / Útdíj (Medium) -->
<button
@click="openFeeModal"
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🅿</span>
<span>Parkolás / Útdíj</span>
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
<!-- További költségek (Medium) -->
<button
@click="openComplexModal"
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base"></span>
<span>További költségek</span>
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
</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('menu.finance') }}
</button>
</div>
<!--
@@ -297,7 +315,12 @@
<!-- Dynamic content area -->
<div class="flex-1 p-8 overflow-y-auto text-slate-800">
<!-- Private Vehicle Manager -->
<PrivateVehicleManager v-if="activeCard === 'vehicles'" :target-vehicle-id="selectedTargetId" />
<PrivateVehicleManager
v-if="activeCard === 'vehicles'"
:target-vehicle-id="selectedTargetId"
:edit-target-vehicle="editTargetVehicle"
@clear-edit-target="editTargetVehicle = null"
/>
<!-- Fallback for other cards -->
<template v-if="activeCard && activeCard !== 'vehicles'">
@@ -312,17 +335,55 @@
</div>
</div>
</Teleport>
<!--
Cost Action Modals (Teleported to body)
-->
<SimpleFuelModal
:is-open="isFuelModalOpen"
:vehicle="selectedActionVehicle"
@close="isFuelModalOpen = false"
@saved="onModalSaved"
/>
<ComplexExpenseModal
:is-open="isFeeModalOpen"
:vehicle="selectedActionVehicle"
:is-fee-mode="true"
@close="isFeeModalOpen = false"
@saved="onModalSaved"
/>
<ComplexExpenseModal
:is-open="isComplexModalOpen"
:vehicle="selectedActionVehicle"
:is-fee-mode="false"
@close="isComplexModalOpen = false"
@saved="onModalSaved"
/>
<!--
Vehicle Detail Modal (Digital Twin 3-Tab Deep Link)
-->
<VehicleDetailModal
:is-open="isVehicleDetailOpen"
:vehicle="detailVehicle"
@close="closeVehicleDetail"
@edit="handleEditFromDetail"
@set-primary="handleSetPrimaryFromDetail"
/>
</div><!-- end min-h-screen -->
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle'
import type { VehicleData } from '../types/vehicle'
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
import VehicleCardCompact from '../components/vehicle/VehicleCardCompact.vue'
import { mockVehicles } from '../data/mockVehicles'
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue'
import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue'
const { t } = useI18n()
@@ -340,24 +401,83 @@ const openCard = (cardId: string, targetId: string | null = null) => {
selectedTargetId.value = targetId
}
// ── Vehicle Detail Modal (Deep Link from plate click) ──
const isVehicleDetailOpen = ref(false)
const detailVehicle = ref<VehicleData | null>(null)
// ── Task 3: Edit target for direct VehicleFormModal open ──
const editTargetVehicle = ref<VehicleData | null>(null)
function openVehicleDetail(vehicle: VehicleData) {
detailVehicle.value = vehicle
isVehicleDetailOpen.value = true
}
function closeVehicleDetail() {
isVehicleDetailOpen.value = false
detailVehicle.value = null
}
function handleEditFromDetail(vehicle: VehicleData) {
closeVehicleDetail()
// Task 3: Open the vehicles card AND pass the vehicle to edit directly
editTargetVehicle.value = vehicle
activeCard.value = 'vehicles'
}
function handleSetPrimaryFromDetail(vehicle: VehicleData) {
vehicleStore.setPrimaryVehicle(vehicle.id)
}
/**
* 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).
* Display vehicles from the backend API only.
* Sorted by is_primary flag (favorite first), then alphabetically.
* Shows all real vehicles; no mock/fallback data is injected.
*/
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]
return vehicleStore.sortedVehicles
})
// ── Cost Card Action Center State ──
const selectedActionVehicleId = ref<string>('')
const isFuelModalOpen = ref(false)
const isFeeModalOpen = ref(false)
const isComplexModalOpen = ref(false)
/** The currently selected vehicle object for the action modals */
const selectedActionVehicle = computed(() => {
if (!selectedActionVehicleId.value) return null
return vehicleStore.vehicles.find(v => v.id === selectedActionVehicleId.value) || null
})
/** Auto-select the first (primary/sorted) vehicle on load */
function autoSelectVehicle() {
if (vehicleStore.sortedVehicles.length > 0 && !selectedActionVehicleId.value) {
selectedActionVehicleId.value = vehicleStore.sortedVehicles[0].id
}
}
function openFuelModal() {
if (!selectedActionVehicle.value) return
isFuelModalOpen.value = true
}
function openFeeModal() {
if (!selectedActionVehicle.value) return
isFeeModalOpen.value = true
}
function openComplexModal() {
if (!selectedActionVehicle.value) return
isComplexModalOpen.value = true
}
function onModalSaved() {
isFuelModalOpen.value = false
isFeeModalOpen.value = false
isComplexModalOpen.value = false
}
// ── Placeholder notifications ──
const notifications = ref([
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
@@ -378,6 +498,11 @@ onMounted(() => {
vehicleStore.fetchVehicles()
authStore.fetchMyOrganizations()
})
// Watch for vehicles to load, then auto-select
watch(() => vehicleStore.vehicles.length, () => {
autoSelectVehicle()
}, { immediate: true })
</script>
<style scoped>