egyedi jármű szerkesztés előtti mentés
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
]
|
||||
68
backend/app/api/v1/endpoints/dictionaries.py
Normal file
68
backend/app/api/v1/endpoints/dictionaries.py
Normal 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
|
||||
@@ -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...
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
93
backend/app/scripts/seed_cost_category_visibility.py
Normal file
93
backend/app/scripts/seed_cost_category_visibility.py
Normal 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())
|
||||
114
backend/app/scripts/seed_cost_subcategories.py
Normal file
114
backend/app/scripts/seed_cost_subcategories.py
Normal 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())
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
70
backend/scripts/seed_body_types.py
Normal file
70
backend/scripts/seed_body_types.py
Normal 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())
|
||||
93
backend/scripts/seed_cost_category_visibility.py
Normal file
93
backend/scripts/seed_cost_category_visibility.py
Normal 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())
|
||||
145
docs/database_catalog_reconnaissance_2026-06-11.md
Normal file
145
docs/database_catalog_reconnaissance_2026-06-11.md
Normal 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
|
||||
311
frontend/src/components/dashboard/ComplexExpenseModal.vue
Normal file
311
frontend/src/components/dashboard/ComplexExpenseModal.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
200
frontend/src/components/dashboard/SimpleFuelModal.vue
Normal file
200
frontend/src/components/dashboard/SimpleFuelModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
1159
frontend/src/components/dashboard/VehicleFormModal_original.vue
Normal file
1159
frontend/src/components/dashboard/VehicleFormModal_original.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
|
||||
@@ -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
104
frontend/src/stores/cost.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user