admin frontend elkezdése, járművek tisztázása, frontend fejleszts

This commit is contained in:
Roo
2026-06-15 18:52:38 +00:00
parent ef8df9608c
commit 213ba3b0f1
80 changed files with 11615 additions and 2304 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
# 🌍 GLOBAL SYSTEM RULES & WORKFLOW (Minden módra érvényes!)
## 🔐 TESZTELÉSI BELÉPÉSI ADATOK (KÖTELEZŐ)
A rendszer teszteléséhez az alábbi belépési adatokat használd:
- **E-mail:** admin@profibot.hu
- **Jelszó:** Admin123!
- **Admin felület:** `/admin` végpontokon keresztül
Te a Service Finder projekt egy specifikus AI ágense vagy. Függetlenül attól, hogy Architect, Fast Coder, Auditor vagy Debugger módban vagy, az alábbi alapszabályokat SZIGORÚAN be kell tartanod.
## 🛡️ 1. KRITIKUS ADATBÁZIS BIZTONSÁG (DATA SAFETY)

View File

@@ -4,7 +4,8 @@ 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, dictionaries
gamification, translations, users, reports, dictionaries,
admin_packages, admin_services
)
api_router = APIRouter()
@@ -29,4 +30,6 @@ 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"])
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])

View File

@@ -1,12 +1,14 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py
from fastapi import APIRouter, Depends, HTTPException, status, Body, BackgroundTasks
from fastapi import APIRouter, Depends, HTTPException, status, Body, BackgroundTasks, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, delete
from sqlalchemy import select, func, text, delete, or_
from sqlalchemy.orm import selectinload
from typing import List, Any, Dict, Optional
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from app.api import deps
from app.models.identity import User, UserRole # JAVÍTVA: Központi import
from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
from app.models.identity.address import Address
from app.models.system import SystemParameter, ParameterScope
from app.services.system_service import system_service
# JAVÍTVA: Security audit modellek
@@ -16,7 +18,6 @@ from app.models import PendingAction, ActionStatus
from app.services.security_service import security_service
from app.services.translation_service import TranslationService
from app.services.odometer_service import OdometerService
from pydantic import BaseModel, Field
from typing import Optional as Opt
@@ -29,19 +30,10 @@ class ConfigUpdate(BaseModel):
router = APIRouter()
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
""" Csak Admin vagy Superadmin. """
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sentinel jogosultság szükséges!"
)
return current_user
@router.get("/health-monitor", tags=["Sentinel Monitoring"])
async def get_system_health(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
admin: User = Depends(deps.get_current_admin)
):
stats = {}
@@ -71,7 +63,7 @@ async def get_system_health(
@router.get("/pending-actions", response_model=List[Any], tags=["Sentinel Security"])
async def list_pending_actions(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
admin: User = Depends(deps.get_current_admin)
):
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
result = await db.execute(stmt)
@@ -81,7 +73,7 @@ async def list_pending_actions(
async def approve_action(
action_id: int,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
admin: User = Depends(deps.get_current_admin)
):
try:
await security_service.approve_action(db, admin.id, action_id)
@@ -91,8 +83,8 @@ async def approve_action(
@router.get("/parameters", tags=["Dynamic Configuration"])
async def list_all_parameters(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(deps.get_current_admin)
):
result = await db.execute(select(SystemParameter))
return result.scalars().all()
@@ -101,7 +93,7 @@ async def list_all_parameters(
async def set_parameter(
config: ConfigUpdate,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
admin: User = Depends(deps.get_current_admin)
):
query = text("""
INSERT INTO system.system_parameters (key, value, scope_level, scope_id, category, last_modified_by)
@@ -132,7 +124,7 @@ async def get_scoped_parameter(
region_id: Optional[str] = None,
country_code: Optional[str] = None,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
admin: User = Depends(deps.get_current_admin)
):
"""
Hierarchikus paraméterlekérdezés a következő prioritással:
@@ -151,92 +143,12 @@ async def get_scoped_parameter(
@router.post("/translations/sync", tags=["System Utilities"])
async def sync_translations_to_json(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
admin: User = Depends(deps.get_current_admin)
):
await TranslationService.export_to_json(db)
return {"message": "JSON fájlok frissítve."}
# ==================== SMART ODOMETER ADMIN API ====================
class OdometerStatsResponse(BaseModel):
vehicle_id: int
last_recorded_odometer: int
last_recorded_date: datetime
daily_avg_distance: float
estimated_current_odometer: float
confidence_score: float
manual_override_avg: Opt[float]
is_confidence_high: bool = Field(..., description="True ha confidence_score >= threshold")
class ManualOverrideRequest(BaseModel):
daily_avg: Opt[float] = Field(None, description="Napi átlagos kilométer (km/nap). Ha null, törli a manuális beállítást.")
@router.get("/odometer/{vehicle_id}", tags=["Smart Odometer"])
async def get_odometer_stats(
vehicle_id: int,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
"""
Jármű kilométeróra statisztikáinak lekérése.
A rendszer automatikusan frissíti a statisztikákat, ha szükséges.
"""
# Frissítjük a statisztikákat
odometer_state = await OdometerService.update_vehicle_stats(db, vehicle_id)
if not odometer_state:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jármű nem található ID: {vehicle_id}"
)
# Confidence threshold lekérése
confidence_threshold = await OdometerService.get_system_param(
db, 'ODOMETER_CONFIDENCE_THRESHOLD', 0.5
)
return OdometerStatsResponse(
vehicle_id=odometer_state.vehicle_id,
last_recorded_odometer=odometer_state.last_recorded_odometer,
last_recorded_date=odometer_state.last_recorded_date,
daily_avg_distance=float(odometer_state.daily_avg_distance),
estimated_current_odometer=float(odometer_state.estimated_current_odometer),
confidence_score=odometer_state.confidence_score,
manual_override_avg=float(odometer_state.manual_override_avg) if odometer_state.manual_override_avg else None,
is_confidence_high=odometer_state.confidence_score >= confidence_threshold
)
@router.patch("/odometer/{vehicle_id}", tags=["Smart Odometer"])
async def set_odometer_manual_override(
vehicle_id: int,
request: ManualOverrideRequest,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
"""
Adminisztrátori manuális átlag beállítása a kilométeróra becsléshez.
Ha a user csal vagy hibás az adat, az admin ezzel felülírhatja az automatikus számítást.
"""
odometer_state = await OdometerService.set_manual_override(
db, vehicle_id, request.daily_avg
)
if not odometer_state:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jármű nem található ID: {vehicle_id}"
)
action = "beállítva" if request.daily_avg is not None else "törölve"
return {
"status": "success",
"message": f"Manuális átlag {action}: {request.daily_avg} km/nap",
"vehicle_id": vehicle_id,
"manual_override_avg": odometer_state.manual_override_avg
}
@router.get("/ping", tags=["Admin Test"])
async def admin_ping(
current_user: User = Depends(deps.get_current_admin)
@@ -551,4 +463,275 @@ async def apply_gamification_penalty(
"penalty_level": penalty.penalty_level,
"new_level": new_level,
"reason": penalty.reason
}
# ==================== USER MANAGEMENT (EPIC 10: Admin Frontend) ====================
class BulkActionRequest(BaseModel):
"""Csoportos művelet kérés sémája."""
user_ids: List[int] = Field(..., min_length=1, description="Felhasználók ID listája")
action: str = Field(..., pattern="^(ban|unban|soft_delete|restore|hard_delete)$", description="Végrehajtandó művelet")
@router.get("/users", tags=["User Management"])
async def list_users(
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
search_term: Optional[str] = Query(None, description="Keresőszó a célzott kereséshez"),
search_category: str = Query('all', description="Keresési kategória: email, name, phone, id, address, all"),
role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"),
is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"),
is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"),
db: AsyncSession = Depends(deps.get_db),
current_admin: User = Depends(deps.get_current_admin),
):
"""
Felhasználók listázása lapozással és célzott kereséssel.
Támogatott keresési kategóriák (search_category):
- **email**: Keresés e-mail címben (ILIKE)
- **name**: Keresés Person vezeték- VAGY keresztnévben (ILIKE)
- **phone**: Keresés Person telefonszámában (ILIKE)
- **id**: Pontos egyezés User ID alapján
- **address**: Keresés címmezőkben (postal_code, city, street, house_number)
NINCS automatikus debounce keresés! A keresés csak explicit paraméterrel indul.
"""
# Alap lekérdezés: User + Person (outerjoin) + Address (outerjoin)
# KRITIKUS: selectinload helyett explicit outerjoin + contains_eager kell,
# mert a selectinload KÜLÖN query-ben tölti be a kapcsolódó adatokat,
# így a WHERE feltételek a Person/Address oszlopokra NEM működnének!
from sqlalchemy.orm import contains_eager, joinedload
from app.models.identity.address import GeoPostalCode
query = (
select(User)
.outerjoin(Person, User.person_id == Person.id)
.outerjoin(Address, Person.address_id == Address.id)
.outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)
.options(
contains_eager(User.person),
contains_eager(User.person, Person.address),
)
)
# Célzott keresés - csak akkor alkalmazzuk, ha van search_term
if search_term:
if search_category == 'all':
query = query.where(
or_(
User.email.ilike(f"%{search_term}%"),
Person.first_name.ilike(f"%{search_term}%"),
Person.last_name.ilike(f"%{search_term}%"),
Person.phone.ilike(f"%{search_term}%")
)
)
elif search_category == 'email':
query = query.where(User.email.ilike(f"%{search_term}%"))
elif search_category == 'name':
query = query.where(
or_(
Person.first_name.ilike(f"%{search_term}%"),
Person.last_name.ilike(f"%{search_term}%")
)
)
elif search_category == 'phone':
query = query.where(Person.phone.ilike(f"%{search_term}%"))
elif search_category == 'id':
try:
user_id = int(search_term)
query = query.where(User.id == user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Az 'id' keresési kategóriához numerikus érték szükséges."
)
elif search_category == 'address':
# FIGYELEM: Address.zip és Address.city PYTHON property-k (a GeoPostalCode kapcsolatból)
# Az SQL lekérdezésben a GeoPostalCode tábla oszlopait kell használni!
query = query.where(
or_(
GeoPostalCode.zip_code.ilike(f"%{search_term}%"),
GeoPostalCode.city.ilike(f"%{search_term}%"),
Address.street_name.ilike(f"%{search_term}%"),
Address.house_number.ilike(f"%{search_term}%")
)
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Érvénytelen keresési kategória: {search_category}. Érvényes: email, name, phone, id, address, all"
)
if role:
try:
role_enum = UserRole(role)
query = query.where(User.role == role_enum)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Érvénytelen szerepkör: {role}. Érvényes értékek: {[r.value for r in UserRole]}"
)
if is_active is not None:
query = query.where(User.is_active == is_active)
if is_deleted is not None:
query = query.where(User.is_deleted == is_deleted)
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = query.order_by(User.id.desc()).offset(skip).limit(limit)
result = await db.execute(query)
# Unique users (outerjoin may duplicate)
seen_ids = set()
unique_users = []
for row in result.unique().scalars().all():
if row.id not in seen_ids:
seen_ids.add(row.id)
unique_users.append(row)
user_list = []
for u in unique_users:
person = u.person
address = person.address if person else None
# Cím összefűzése
address_str = None
if address:
parts = []
if address.zip:
parts.append(str(address.zip))
if address.city:
parts.append(str(address.city))
if address.street_name:
parts.append(str(address.street_name))
if address.house_number:
parts.append(str(address.house_number))
address_str = ', '.join(parts) if parts else None
user_list.append({
"id": u.id,
"email": u.email,
"role": u.role.value if hasattr(u.role, "value") else str(u.role),
"is_active": u.is_active,
"is_deleted": u.is_deleted,
"subscription_plan": u.subscription_plan,
"region_code": u.region_code,
"preferred_language": u.preferred_language,
"created_at": u.created_at.isoformat() if u.created_at else None,
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
# Person adatok
"first_name": person.first_name if person else None,
"last_name": person.last_name if person else None,
"phone": person.phone if person else None,
# Címadatok
"postal_code": str(address.zip) if address and address.zip else None,
"city": address.city if address and address.city else None,
"street": address.street_name if address and address.street_name else None,
"house_number": address.house_number if address and address.house_number else None,
"address": address_str,
})
return {
"total": total,
"skip": skip,
"limit": limit,
"users": user_list,
}
@router.post("/users/bulk-action", tags=["User Management"])
async def bulk_user_action(
request: BulkActionRequest,
db: AsyncSession = Depends(deps.get_db),
current_admin: User = Depends(deps.get_current_admin),
):
"""
Csoportos műveletek felhasználókon.
Támogatott akciók:
- **ban**: Felhasználó tiltása (is_active=False). Rang >= 50 (Moderator) szükséges.
- **unban**: Tiltás feloldása (is_active=True). Rang >= 90 (Admin) szükséges.
- **soft_delete**: Lágy törlés (is_deleted=True, deleted_at=now). Rang >= 90 (Admin) szükséges.
- **restore**: Lágy törlés visszaállítása (is_deleted=False, deleted_at=None). Rang >= 90 (Admin) szükséges.
- **hard_delete**: Végleges törlés az adatbázisból. Rang >= 100 (Superadmin) szükséges!
"""
role_key = current_admin.role.value.upper() if hasattr(current_admin.role, "value") else str(current_admin.role).upper()
from app.core.security import DEFAULT_RANK_MAP
admin_rank = DEFAULT_RANK_MAP.get(role_key, 0)
if request.action == "hard_delete":
if admin_rank < 100:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Végleges törléshez Superadmin jogosultság szükséges (rank >= 100)."
)
elif request.action in ("soft_delete", "restore", "unban"):
if admin_rank < 90:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Ehhez a művelethez Admin jogosultság szükséges (rank >= 90)."
)
elif request.action in ("ban",):
if admin_rank < 50:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tiltáshoz Moderator jogosultság szükséges (rank >= 50)."
)
stmt = select(User).where(User.id.in_(request.user_ids))
result = await db.execute(stmt)
users = result.scalars().all()
if not users:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Egy felhasználó sem található a megadott ID-kkel."
)
found_ids = {u.id for u in users}
missing_ids = [uid for uid in request.user_ids if uid not in found_ids]
now = datetime.now(timezone.utc)
affected_count = 0
for user in users:
if user.role == UserRole.superadmin and user.id != current_admin.id:
continue
if request.action == "ban":
user.is_active = False
elif request.action == "unban":
user.is_active = True
elif request.action == "soft_delete":
user.is_deleted = True
user.deleted_at = now
elif request.action == "restore":
user.is_deleted = False
user.deleted_at = None
elif request.action == "hard_delete":
await db.delete(user)
affected_count += 1
audit_log = SecurityAuditLog(
user_id=current_admin.id,
action=f"bulk_{request.action}",
details=f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}",
is_critical=(request.action in ("hard_delete", "ban")),
ip_address="admin_api"
)
db.add(audit_log)
await db.commit()
return {
"status": "success",
"action": request.action,
"affected_count": affected_count,
"total_requested": len(request.user_ids),
"missing_ids": missing_ids if missing_ids else None,
"message": f"{request.action} művelet végrehajtva {affected_count} felhasználón."
}

View File

@@ -0,0 +1,319 @@
"""
📦 Admin Package Manager API
SubscriptionTier csomagok adminisztrációs végpontjai.
Minden végpont védve van a Depends(get_current_admin) dependency-vel.
Végpontok:
GET /admin/packages/ — Listázza az összes csomagot
POST /admin/packages/ — Új csomag létrehozása
PATCH /admin/packages/{id} — Csomag frissítése (pl. rules JSONB, név)
DELETE /admin/packages/{id} — Csomag logikai törlése (lifecycle.is_public=false)
"""
from __future__ import annotations
import json
import logging
from datetime import date
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Date, cast, func, select, update, delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import deps
from app.db.session import get_db
from app.models.core_logic import SubscriptionTier
from app.models.identity import User
from app.schemas.subscription import (
SubscriptionTierCreate,
SubscriptionTierListResponse,
SubscriptionTierResponse,
SubscriptionTierUpdate,
SubscriptionRulesModel,
)
logger = logging.getLogger("admin-packages")
router = APIRouter()
# =============================================================================
# GET / — Listázza az összes SubscriptionTier rekordot
# =============================================================================
@router.get(
"",
response_model=SubscriptionTierListResponse,
summary="Csomagok listázása",
description="Visszaadja az összes előfizetési csomagot (SubscriptionTier).",
)
async def list_packages(
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
limit: int = Query(100, ge=1, le=500, description="Maximum visszaadott rekordok száma"),
include_hidden: bool = Query(
False,
description="Ha True, a nem publikus (lifecycle.is_public=false) csomagokat is visszaadja",
),
type_filter: Optional[str] = Query(
None,
description="Szűrés típus szerint: 'private' vagy 'corporate' (rules->>'type')",
),
date_from: Optional[str] = Query(
None,
description="Szűrés kezdő dátumtól (available_from a lifecycle-ben, ISO formátum: YYYY-MM-DD)",
),
date_until: Optional[str] = Query(
None,
description="Szűrés záró dátumig (available_until a lifecycle-ben, ISO formátum: YYYY-MM-DD)",
),
db: AsyncSession = Depends(get_db),
admin: User = Depends(deps.get_current_admin),
) -> SubscriptionTierListResponse:
"""
Összes előfizetési csomag listázása.
Alapértelmezetten csak a publikus csomagokat adja vissza
(ahol a rules->'lifecycle'->>'is_public' != 'false').
Az `include_hidden=True` paraméterrel az összes rekord lekérhető.
Szűrési lehetőségek:
- `type_filter`: 'private' vagy 'corporate' a rules->>'type' mező alapján
- `date_from`: Csak azokat a csomagokat adja vissza, amelyek available_from >= date_from
- `date_until`: Csak azokat a csomagokat adja vissza, amelyek available_until <= date_until
"""
stmt = select(SubscriptionTier).order_by(SubscriptionTier.id)
if not include_hidden:
# Csak a publikus csomagok (ahol nincs lifecycle.is_public=false)
stmt = stmt.where(
~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"])
)
# ── Típus szűrő (rules->>'type') ──
if type_filter:
type_filter_lower = type_filter.strip().lower()
if type_filter_lower in ("private", "corporate"):
stmt = stmt.where(
SubscriptionTier.rules["type"].as_string() == type_filter_lower
)
# ── Dátum szűrők (lifecycle JSONB mezők) ──
# A JSONB mezőkben tárolt dátumokat cast(Date)-té alakítjuk a helyes összehasonlításhoz
if date_from:
stmt = stmt.where(
cast(SubscriptionTier.rules["lifecycle"]["available_from"].as_string(), Date) >= date_from
)
if date_until:
stmt = stmt.where(
cast(SubscriptionTier.rules["lifecycle"]["available_until"].as_string(), Date) <= date_until
)
# Teljes számolás
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# Lapozás
stmt = stmt.offset(skip).limit(limit)
result = await db.execute(stmt)
tiers = result.scalars().all()
return SubscriptionTierListResponse(
total=total,
tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers],
)
# =============================================================================
# POST / — Új csomag létrehozása
# =============================================================================
@router.post(
"",
response_model=SubscriptionTierResponse,
status_code=status.HTTP_201_CREATED,
summary="Új csomag létrehozása",
description="Létrehoz egy új előfizetési csomagot a megadott rules JSONB-vel.",
)
async def create_package(
payload: SubscriptionTierCreate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(deps.get_current_admin),
) -> SubscriptionTierResponse:
"""
Új előfizetési csomag létrehozása.
- A `name` mező egyedi kell legyen (unique constraint).
- A `rules` mezőben a teljes SubscriptionRulesModel struktúrát kell megadni.
- Alapértelmezetten a lifecycle.is_public = True lesz beállítva.
"""
# Ellenőrizzük, hogy létezik-e már ilyen nevű csomag
existing = await db.execute(
select(SubscriptionTier).where(SubscriptionTier.name == payload.name)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Már létezik csomag ezzel a névvel: '{payload.name}'",
)
# Alapértelmezett lifecycle beállítás, ha nincs megadva
rules_dict = payload.rules.model_dump()
if rules_dict.get("lifecycle") is None:
rules_dict["lifecycle"] = {"is_public": True}
tier = SubscriptionTier(
name=payload.name,
rules=rules_dict,
is_custom=payload.is_custom,
)
db.add(tier)
await db.commit()
await db.refresh(tier)
logger.info(
f"Admin {admin.id} ({admin.email}) created subscription tier: "
f"name={tier.name}, id={tier.id}"
)
return SubscriptionTierResponse.model_validate(tier)
# =============================================================================
# PATCH /{tier_id} — Csomag frissítése
# =============================================================================
@router.patch(
"/{tier_id}",
response_model=SubscriptionTierResponse,
summary="Csomag frissítése",
description="Frissíti egy meglévő csomag adatait (név, rules JSONB, is_custom).",
)
async def update_package(
tier_id: int,
payload: SubscriptionTierUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(deps.get_current_admin),
) -> SubscriptionTierResponse:
"""
Meglévő előfizetési csomag részleges frissítése.
- Csak a megadott mezők frissülnek (PATCH szemantika).
- A `rules` mezőben a teljes SubscriptionRulesModel-t át kell adni,
ha módosítani szeretnéd (a JSONB oszlop teljes egészében lecserélődik).
- A lifecycle.is_public false-ra állításával a csomag elrejthető.
"""
# Betöltjük a meglévő rekordot
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
result = await db.execute(stmt)
tier = result.scalar_one_or_none()
if not tier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Csomag nem található ID-vel: {tier_id}",
)
# Részleges frissítés
update_data = payload.model_dump(exclude_unset=True)
if "name" in update_data and update_data["name"] is not None:
# Ellenőrizzük az egyediséget (kivéve, ha ugyanaz a név)
if update_data["name"] != tier.name:
existing = await db.execute(
select(SubscriptionTier).where(
SubscriptionTier.name == update_data["name"],
SubscriptionTier.id != tier_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Már létezik csomag ezzel a névvel: '{update_data['name']}'",
)
tier.name = update_data["name"]
if "rules" in update_data and update_data["rules"] is not None:
# A meglévő rules-ból megtartjuk a lifecycle-t, ha nem adtak újat
new_rules = update_data["rules"]
# Biztosítjuk a JSON kompatibilis szerializációt (model_dump(mode='json') már megtörtént)
if isinstance(new_rules, dict) and new_rules.get("lifecycle") is None and tier.rules.get("lifecycle"):
new_rules["lifecycle"] = tier.rules["lifecycle"]
tier.rules = new_rules
if "is_custom" in update_data and update_data["is_custom"] is not None:
tier.is_custom = update_data["is_custom"]
await db.commit()
await db.refresh(tier)
logger.info(
f"Admin {admin.id} ({admin.email}) updated subscription tier: "
f"id={tier.id}, name={tier.name}"
)
return SubscriptionTierResponse.model_validate(tier)
# =============================================================================
# DELETE /{tier_id} — Csomag logikai törlése (soft-delete)
# =============================================================================
@router.delete(
"/{tier_id}",
status_code=status.HTTP_200_OK,
summary="Csomag logikai törlése",
description="Nem törli fizikailag, csak a lifecycle.is_public = false értékre állítja.",
)
async def delete_package(
tier_id: int,
db: AsyncSession = Depends(get_db),
admin: User = Depends(deps.get_current_admin),
) -> Dict[str, Any]:
"""
Csomag logikai törlése (soft-delete / kivezetés).
A rekord NEM kerül törlésre az adatbázisból!
Ehelyett a `rules` JSONB `lifecycle.is_public` mezője `false`-ra áll,
így a csomag eltűnik a frontend listákból, de a meglévő előfizetések
továbbra is hivatkozhatnak rá.
Ha a csomag már nem publikus, akkor is 200 OK-val tér vissza
(idempotens művelet).
"""
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
result = await db.execute(stmt)
tier = result.scalar_one_or_none()
if not tier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Csomag nem található ID-vel: {tier_id}",
)
# Logikai törlés: lifecycle.is_public = false
current_rules = dict(tier.rules) if tier.rules else {}
if "lifecycle" not in current_rules:
current_rules["lifecycle"] = {}
current_rules["lifecycle"]["is_public"] = False
tier.rules = current_rules
await db.commit()
await db.refresh(tier)
logger.info(
f"Admin {admin.id} ({admin.email}) deactivated subscription tier: "
f"id={tier.id}, name={tier.name}"
)
return {
"status": "success",
"message": f"Csomag '{tier.name}' (ID: {tier.id}) inaktíválva.",
"tier_id": tier.id,
"is_public": False,
}

View File

@@ -0,0 +1,110 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_services.py
"""
Admin Service Catalog CRUD végpontok.
Prefix: /admin/services
Védve: Depends(get_current_admin)
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List
from app.api import deps
from app.models.identity import User
from app.models.core_logic import ServiceCatalog
from app.schemas.service_catalog import (
ServiceCatalogCreate,
ServiceCatalogUpdate,
ServiceCatalogResponse,
)
router = APIRouter()
@router.get("", response_model=List[ServiceCatalogResponse], tags=["Admin Service Catalog"])
async def list_service_catalog(
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
is_active: bool | None = Query(None, description="Szűrés aktív/inaktív státuszra"),
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(deps.get_current_admin),
):
"""
Szolgáltatás katalógus bejegyzések listázása lapozással.
"""
query = select(ServiceCatalog)
if is_active is not None:
query = query.where(ServiceCatalog.is_active == is_active)
# Teljes darabszám
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = query.order_by(ServiceCatalog.id.desc()).offset(skip).limit(limit)
result = await db.execute(query)
items = result.scalars().all()
return items
@router.post("", response_model=ServiceCatalogResponse, status_code=status.HTTP_201_CREATED, tags=["Admin Service Catalog"])
async def create_service_catalog(
data: ServiceCatalogCreate,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(deps.get_current_admin),
):
"""
Új szolgáltatás katalógus bejegyzés létrehozása.
"""
# Ellenőrizzük, hogy a service_code már létezik-e
existing = await db.execute(
select(ServiceCatalog).where(ServiceCatalog.service_code == data.service_code)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Service code '{data.service_code}' already exists.",
)
entry = ServiceCatalog(
service_code=data.service_code,
name=data.name,
description=data.description,
credit_cost=data.credit_cost,
is_active=data.is_active,
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return entry
@router.patch("/{service_id}", response_model=ServiceCatalogResponse, tags=["Admin Service Catalog"])
async def update_service_catalog(
service_id: int,
data: ServiceCatalogUpdate,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(deps.get_current_admin),
):
"""
Meglévő szolgáltatás katalógus bejegyzés módosítása.
"""
result = await db.execute(select(ServiceCatalog).where(ServiceCatalog.id == service_id))
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service catalog entry with ID {service_id} not found.",
)
# Csak a megadott mezőket frissítjük
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(entry, field, value)
await db.commit()
await db.refresh(entry)
return entry

View File

@@ -18,6 +18,8 @@ from app.services.asset_service import AssetService
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
logger = logging.getLogger(__name__)
class ArchiveVehicleRequest(BaseModel):
"""Payload a jármű archiválásához (Soft Delete)."""
@@ -26,8 +28,6 @@ class ArchiveVehicleRequest(BaseModel):
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/vehicles/quota-status")
async def get_vehicle_quota_status(
@@ -249,13 +249,36 @@ async def list_asset_costs(
"""Tételes költséglista lapozással (Pagination)."""
stmt = (
select(AssetCost)
.options(selectinload(AssetCost.category))
.where(AssetCost.asset_id == asset_id)
.order_by(desc(AssetCost.date))
.offset(skip)
.limit(limit)
)
res = await db.execute(stmt)
return res.scalars().all()
costs = res.scalars().all()
# Map DB models to response, enriching with category info and data fields
result = []
for cost in costs:
cost_dict = {
"id": cost.id,
"asset_id": cost.asset_id,
"organization_id": cost.organization_id,
"amount_net": cost.amount_net,
"currency": cost.currency,
"date": cost.date,
"invoice_number": cost.invoice_number,
"data": cost.data or {},
"category_id": cost.category_id,
"category_name": cost.category.name if cost.category else None,
"category_code": cost.category.code if cost.category else None,
"description": (cost.data or {}).get("description"),
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
}
result.append(AssetCostResponse(**cost_dict))
return result
@router.get("/{asset_id}", response_model=AssetResponse)
@@ -373,7 +396,6 @@ async def create_or_claim_vehicle(
# 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)
@@ -405,7 +427,6 @@ async def create_or_claim_vehicle(
except HTTPException:
raise
except Exception as e:
logger = logging.getLogger(__name__)
logger.error(f"Vehicle creation error: {e}")
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor")
@@ -487,7 +508,6 @@ async def update_vehicle(
except Exception as e:
await db.rollback()
logger = logging.getLogger(__name__)
logger.error(f"Vehicle update error: {e}")
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor")
@@ -685,7 +705,6 @@ async def create_maintenance_record(
)
except Exception as e:
await db.rollback()
logger = logging.getLogger(__name__)
logger.error(f"Maintenance record creation error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -305,4 +305,45 @@ async def get_current_user_profile(
from app.schemas.user import UserResponse
from app.api.v1.endpoints.users import _build_user_response
response_data = _build_user_response(current_user)
return UserResponse.model_validate(response_data)
return UserResponse.model_validate(response_data)
# ── 30 NAPOS FIÓKVISSZAÁLLÍTÁS (RESTORE) ──
class RestoreRequestIn(BaseModel):
email: EmailStr = Field(..., description="A törölt fiók email címe")
class RestoreVerifyIn(BaseModel):
email: EmailStr = Field(..., description="A törölt fiók email címe")
code: str = Field(..., min_length=6, max_length=6, description="6-jegyű visszaállítási kód")
@router.post("/restore/request")
async def restore_account_request(
request: RestoreRequestIn,
db: AsyncSession = Depends(get_db),
):
"""
Fiók visszaállítási kód kérése.
Ha a fiók 30 napon belül lett törölve, 6-jegyű OTP kódot küld.
Ha 30 napon túl van, új regisztrációt engedélyez.
"""
result = await AuthService.request_account_restore(db, request.email)
return result
@router.post("/restore/verify")
async def restore_account_verify(
request: RestoreVerifyIn,
db: AsyncSession = Depends(get_db),
):
"""
Fiók visszaállítási kód ellenőrzése.
Sikeres ellenőrzés után visszaállítja a fiókot (is_active=True, is_deleted=False, deleted_at=None).
"""
result = await AuthService.verify_account_restore(db, request.email, request.code)
if result.get("status") != "success":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
)
return result

View File

@@ -100,4 +100,67 @@ async def list_body_types(
"name_hu": r.name_hu,
}
for r in rows
]
]
# ═══════════════════════════════════════════════════════════════════════════════
# NEW: Cascading Catalog Endpoints for Vehicle Wizard (Drill-down UI)
# ═══════════════════════════════════════════════════════════════════════════════
@router.get("/brands", response_model=List[str])
async def list_catalog_brands(
vehicle_class: Optional[str] = Query(None, description="Szűrés járműosztályra (pl. personal, motorcycle)"),
q: Optional[str] = Query(None, description="Keresési szöveg (autocomplete)"),
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user)
):
"""Márkák listája a vehicle_model_definitions táblából, autocomplete-hez.
GET /catalog/brands?class=personal&q=bmw
"""
return await AssetService.get_catalog_brands(db, vehicle_class, q)
@router.get("/brands/{brand}/models")
async def list_catalog_models(
brand: str,
vehicle_class: Optional[str] = Query(None, description="Szűrés járműosztályra"),
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user)
):
"""Adott márkához tartozó modellek listája.
GET /catalog/brands/BMW/models
"""
if not brand or brand.strip() == "":
return []
return await AssetService.get_catalog_models(db, brand, vehicle_class)
@router.get("/brands/{brand}/models/{model}/years")
async def list_catalog_years(
brand: str,
model: str,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user)
):
"""Adott márkához és modellhez tartozó elérhető évjáratok.
GET /catalog/brands/BMW/models/X5/years
"""
if not brand or not model or brand.strip() == "" or model.strip() == "":
return []
return await AssetService.get_catalog_years(db, brand, model)
@router.get("/brands/{brand}/models/{model}/trims")
async def list_catalog_trims(
brand: str,
model: str,
year: Optional[int] = Query(None, description="Szűrés évjáratra"),
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user)
):
"""Adott márkához, modellhez és opcionális évjárathoz tartozó felszereltségek/motorváltozatok.
GET /catalog/brands/BMW/models/X5/trims?year=2023
"""
if not brand or not model or brand.strip() == "" or model.strip() == "":
return []
return await AssetService.get_catalog_trims(db, brand, model, year)

View File

@@ -90,14 +90,19 @@ async def create_expense(
asset_id=expense.asset_id,
organization_id=organization_id,
category_id=expense.category_id,
amount_net=expense.amount_local,
currency=expense.currency_local,
amount_net=expense.amount_net,
currency=expense.currency,
date=expense.date,
invoice_number=data.get("invoice_number"),
data=data
)
db.add(new_cost)
# Update Asset.current_mileage if mileage_at_cost is higher
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
asset.current_mileage = expense.mileage_at_cost
await db.commit()
await db.refresh(new_cost)

View File

@@ -3,9 +3,10 @@ import os
import re
import uuid
import hashlib
import random
import logging
from typing import List
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -13,9 +14,11 @@ from sqlalchemy import select
from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch
from app.models.identity import User # JAVÍTVA: Központi Identity modell
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole
from app.models.identity import User, OneTimePassword # JAVÍTVA: Központi Identity modell
from app.core.config import settings
from app.services.security_service import security_service
from app.models import LogSeverity
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -275,6 +278,79 @@ async def update_organization_visual_settings(
)
return OrganizationResponse.model_validate(org)
@router.patch("/{org_id}", response_model=OrganizationResponse)
async def update_organization(
org_id: int,
update_data: OrganizationUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Szervezet adatainak részleges frissítése (általános PATCH).
Támogatja a cégadatok (name, full_name, tax_number, display_name),
cím adatok (address_zip, address_city, address_street_name, stb.),
valamint a visual_settings, language, default_currency mezők frissítését.
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
"""
# 1. Jogosultság ellenőrzése
stmt_member = select(OrganizationMember).where(
(OrganizationMember.organization_id == org_id) &
(OrganizationMember.user_id == current_user.id) &
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
)
member = (await db.execute(stmt_member)).scalar_one_or_none()
if not member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nincs jogosultságod a szervezet adatainak módosításához (csak OWNER/ADMIN)."
)
# 2. Szervezet lekérése
stmt_org = select(Organization).where(
Organization.id == org_id,
Organization.is_deleted == False
)
result = await db.execute(stmt_org)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Szervezet nem található."
)
# 3. JSONB merge visual_settings esetén
update_dict = update_data.model_dump(exclude_unset=True)
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
incoming_vs = update_dict["visual_settings"]
current_vs = dict(org.visual_settings) if org.visual_settings else {}
current_vs.update(incoming_vs)
org.visual_settings = current_vs
del update_dict["visual_settings"]
# 4. Többi mező frissítése
for field, value in update_dict.items():
if hasattr(org, field) and value is not None:
setattr(org, field, value)
try:
await db.commit()
await db.refresh(org)
except Exception as e:
await db.rollback()
logger.error(f"Hiba a szervezet frissítésekor (org_id={org_id}): {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Adatbázis hiba: {str(e)}"
)
return OrganizationResponse.model_validate(org)
# --- B2B MEGHÍVÓ LOGIKA ---
from pydantic import BaseModel, EmailStr
@@ -393,9 +469,274 @@ async def accept_invitation(
status="active"
)
db.add(new_member)
token_rec.is_used = True
await db.commit()
token_rec.is_used = True
await db.commit()
return {"status": "success", "message": "Meghívó sikeresen elfogadva."}
return {"status": "success", "message": "Meghívó sikeresen elfogadva."}
# ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
from app.models.identity import OneTimePassword
import random
class JoinRequestIn(BaseModel):
"""Csatlakozási kérelem egy céghez, amelynek van aktív adminja."""
message: Optional[str] = Field(None, description="Üzenet az adminnak")
class ClaimRequestIn(BaseModel):
"""Árva cég átvételi kérelem - email ellenőrzés."""
email: EmailStr = Field(..., description="A cég eredeti email címe")
class ClaimVerifyIn(BaseModel):
"""Árva cég átvételi kérelem - OTP kód ellenőrzés."""
email: EmailStr = Field(..., description="A cég eredeti email címe")
code: str = Field(..., min_length=6, max_length=6, description="6-jegyű megerősítő kód")
@router.post("/{org_id}/join-request")
async def request_join_organization(
org_id: int,
request: JoinRequestIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Csatlakozási kérelem egy szervezethez, amelynek van aktív adminja.
A kérelem 7 napig él, amíg az admin jóvá nem hagyja.
"""
# Ellenőrizzük, hogy létezik-e a szervezet
stmt = select(Organization).where(
Organization.id == org_id,
Organization.is_deleted == False
)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="Szervezet nem található.")
# Ellenőrizzük, hogy van-e aktív admin
stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id,
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN])
).join(User, OrganizationMember.user_id == User.id).where(User.is_active == True, User.is_deleted == False)
result = await db.execute(stmt)
active_admins = result.scalars().all()
if not active_admins:
raise HTTPException(
status_code=400,
detail="A szervezetnek nincs aktív adminja. Használja a /claim/request végpontot."
)
# Ellenőrizzük, hogy a felhasználó már nem tag-e
stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Már tagja vagy ennek a szervezetnek.")
# Létrehozzuk a pending tagot
new_member = OrganizationMember(
organization_id=org_id,
user_id=current_user.id,
role=OrgUserRole.DRIVER,
status="pending"
)
db.add(new_member)
# Naplózás
await security_service.log_event(
db, user_id=current_user.id, action="ORG_JOIN_REQUEST",
severity=LogSeverity.info, target_type="Organization", target_id=str(org_id),
new_data={"message": request.message or ""}
)
await db.commit()
return {
"status": "pending",
"message": "Csatlakozási kérelmed rögzítettük. Az admin jóváhagyására vársz."
}
@router.post("/{org_id}/claim/request")
async def claim_orphaned_organization_request(
org_id: int,
request: ClaimRequestIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Árva cég átvételi kérelem indítása.
Ellenőrzi, hogy a megadott email cím egyezik-e a cég eredeti email címével,
majd 6-jegyű OTP kódot küld ki.
"""
# Ellenőrizzük a szervezetet
stmt = select(Organization).where(
Organization.id == org_id,
Organization.is_deleted == False
)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="Szervezet nem található.")
# Ellenőrizzük, hogy tényleg árva-e (nincs aktív admin)
stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id,
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN])
).join(User, OrganizationMember.user_id == User.id).where(User.is_active == True, User.is_deleted == False)
result = await db.execute(stmt)
active_admins = result.scalars().all()
if active_admins:
raise HTTPException(
status_code=400,
detail="A szervezetnek van aktív adminja. Használja a /join-request végpontot."
)
# Ellenőrizzük az email címet - keressük a tulajdonos usert
# A tulajdonos email-jét kell ellenőrizni
owner_stmt = select(User).where(
User.id == org.owner_id,
User.email == request.email
)
owner_result = await db.execute(owner_stmt)
owner_user = owner_result.scalar_one_or_none()
if not owner_user:
# Lehet, hogy a tulajdonos törölve van, és az email át lett írva
# Keressük a deleted_ prefix-es email-ben
owner_stmt = select(User).where(
User.id == org.owner_id,
User.email.like(f"%{request.email}")
)
owner_result = await db.execute(owner_stmt)
owner_user = owner_result.scalar_one_or_none()
if not owner_user:
raise HTTPException(
status_code=400,
detail="Az email cím nem egyezik a cég tulajdonosának email címével."
)
# Generáljunk 6-jegyű OTP kódot
code = f"{random.randint(0, 999999):06d}"
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
otp = OneTimePassword(
email=request.email,
code=code,
otp_type="company_claim",
extra_data={"org_id": org_id, "user_id": current_user.id},
expires_at=expires_at
)
db.add(otp)
await db.commit()
# Szimulált email küldés
logger.info(f"=== CÉG ÁTVÉTELI OTP ===")
logger.info(f"Email: {request.email}")
logger.info(f"Kód: {code}")
logger.info(f"Lejárat: {expires_at}")
logger.info(f"=== A kódot email-ben kell elküldeni a cég tulajdonosának ===")
return {
"status": "otp_sent",
"message": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.",
"expires_at": expires_at.isoformat()
}
@router.post("/{org_id}/claim/verify")
async def claim_orphaned_organization_verify(
org_id: int,
request: ClaimVerifyIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Árva cég átvételének véglegesítése OTP kód alapján.
Sikeres ellenőrzés után a próbálkozó lesz az új admin.
"""
now = datetime.now(timezone.utc)
# Ellenőrizzük a szervezetet
stmt = select(Organization).where(
Organization.id == org_id,
Organization.is_deleted == False
)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="Szervezet nem található.")
# Ellenőrizzük az OTP kódot
otp_stmt = select(OneTimePassword).where(
OneTimePassword.email == request.email,
OneTimePassword.code == request.code,
OneTimePassword.otp_type == "company_claim",
OneTimePassword.is_used == False,
OneTimePassword.expires_at > now
).order_by(OneTimePassword.created_at.desc()).limit(1)
result = await db.execute(otp_stmt)
otp = result.scalar_one_or_none()
if not otp:
raise HTTPException(
status_code=400,
detail="Érvénytelen vagy lejárt kód."
)
# Ellenőrizzük, hogy az OTP ehhez a szervezethez tartozik-e
extra = otp.extra_data or {}
if extra.get("org_id") != org_id:
raise HTTPException(status_code=400, detail="A kód nem ehhez a szervezethez tartozik.")
# Mark OTP as used
otp.is_used = True
# Ellenőrizzük, hogy a felhasználó már nem tag-e
member_stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == current_user.id
)
result = await db.execute(member_stmt)
existing_member = result.scalar_one_or_none()
if existing_member:
# Frissítjük a szerepkört
existing_member.role = OrgUserRole.ADMIN
else:
# Új tag létrehozása ADMIN szerepkörrel
new_member = OrganizationMember(
organization_id=org_id,
user_id=current_user.id,
role=OrgUserRole.ADMIN,
status="active"
)
db.add(new_member)
# Átírjuk a tulajdonost (owner_id)
org.owner_id = current_user.id
# Naplózás
await security_service.log_event(
db, user_id=current_user.id, action="ORG_CLAIMED",
severity=LogSeverity.warning, target_type="Organization", target_id=str(org_id),
new_data={"previous_owner": str(extra.get("user_id")), "claim_type": "orphaned"}
)
await db.commit()
return {
"status": "success",
"message": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin."
}

View File

@@ -1,7 +1,7 @@
#/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/users.py
import copy
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select, or_
@@ -117,6 +117,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
"active_organization_id": active_org_id,
"preferred_language": user.preferred_language or "hu",
"person": person_data,
"is_last_admin": False, # Default, will be overridden in read_users_me
}
@@ -175,7 +176,10 @@ async def read_users_me(
active_org_id = org_row[0] if org_row else None
# Check if user is the last admin in any organization
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
response_data = _build_user_response(current_user, active_org_id)
response_data["is_last_admin"] = is_last_admin
return UserResponse.model_validate(response_data)
@@ -497,3 +501,35 @@ async def get_my_network(
level2=level2,
level3=level3
)
@router.delete("/me", status_code=200)
async def delete_my_account(
reason: Optional[str] = Query(None, description="Törlés oka (opcionális)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Saját fiók soft-delete.
Üzleti logika:
- Anonimizálja az e-mail címet (deleted_{id}_{timestamp}_{original_email})
- is_active = False, is_deleted = True, deleted_at = timestamp
- Person rekordot NEM bántja (megőrizzük a jövőbeli újraaktiváláshoz)
- Wallet és Gamification adatokat megőrzi (inaktív user miatt freeze)
- JWT token-ek a deleted_at middleware ellenőrzés miatt automatikusan érvénytelenülnek
"""
success = await AuthService.soft_delete_user(
db=db,
user_id=current_user.id,
reason=reason or "user_requested_self_delete",
actor_id=current_user.id
)
if not success:
raise HTTPException(
status_code=400,
detail="A felhasználó már törölve van."
)
return {"status": "ok", "message": "Fiók sikeresen törölve."}

View File

@@ -52,15 +52,22 @@ def generate_secure_slug(length: int = 16) -> str:
return ''.join(secrets.choice(alphabet) for _ in range(length))
# Teljesen a margón van, így globális konstans lesz!
# Lefedi a UserRole enum MIND a 10 értékét
DEFAULT_RANK_MAP = {
"SUPERADMIN": 100,
"ADMIN": 90,
"AUDITOR": 80,
"ORGANIZATION_OWNER": 70,
"REGION_ADMIN": 85,
"COUNTRY_ADMIN": 80,
"MODERATOR": 75,
"AUDITOR": 70,
"ORGANIZATION_OWNER": 65,
"ORGANIZATION_MANAGER": 60,
"ORGANIZATION_MEMBER": 50,
"SERVICE_PROVIDER": 40,
"SALES_AGENT": 30,
"FLEET_MANAGER": 25,
"PREMIUM_USER": 20,
"USER": 10,
"DRIVER": 5,
"GUEST": 0
}

View File

@@ -3,23 +3,23 @@
from app.database import Base
# 1. Alapvető identitás és szerepkörök
from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole
from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole, OneTimePassword
# 2. Földrajzi adatok és címek
# 2. Szervezeti felépítés (MUST be before Address/Rating due to FK references)
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
# 3. Földrajzi adatok és címek
from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
# 3. Jármű definíciók
# 4. Jármű definíciók
from .vehicle.vehicle_definitions import VehicleModelDefinition, VehicleType, FeatureDefinition, ModelFeatureMap
from .reference_data import ReferenceLookup
from .vehicle.vehicle import CostCategory, VehicleCost, GbCatalogDiscovery
from .vehicle.external_reference import ExternalReferenceLibrary
from .vehicle.external_reference_queue import ExternalReferenceQueue
# 4. Szervezeti felépítés
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
# 5. Eszközök és katalógusok
from .vehicle.asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetAssignment, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership
from .vehicle.asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetAssignment, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership, OdometerReading
# 6. Üzleti logika és előfizetések
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
@@ -61,7 +61,7 @@ __all__ = [
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole",
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery",
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution",
@@ -81,5 +81,5 @@ __all__ = [
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup",
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
"Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "VehicleCost", "ExternalReferenceLibrary", "ExternalReferenceQueue",
"GbCatalogDiscovery", "Season", "StagedVehicleData"
"GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword"
]

View File

@@ -41,6 +41,29 @@ class OrganizationSubscription(Base):
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class UserSubscription(Base):
"""
Felhasználók aktuális előfizetései és azok érvényessége.
Tábla: finance.user_subscriptions
"""
__tablename__ = "user_subscriptions"
__table_args__ = {"schema": "finance"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Kapcsolat a felhasználóval (identity séma)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
# Kapcsolat a csomaggal (system séma)
tier_id: Mapped[int] = mapped_column(Integer, ForeignKey("system.subscription_tiers.id"), nullable=False)
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
class CreditTransaction(Base):
"""
Kreditnapló (Pontok, kreditek vagy virtuális egyenleg követése).
@@ -73,4 +96,21 @@ class ServiceSpecialty(Base):
slug: Mapped[str] = mapped_column(String, unique=True, index=True)
# Kapcsolat az ős-szolgáltatással (Self-referential relationship)
parent: Mapped[Optional["ServiceSpecialty"]] = relationship("ServiceSpecialty", remote_side=[id], backref="children")
parent: Mapped[Optional["ServiceSpecialty"]] = relationship("ServiceSpecialty", remote_side=[id], backref="children")
class ServiceCatalog(Base):
"""
Szolgáltatás Katalógus (Service Catalog).
A rendszer által kínált szolgáltatások definíciói, pl. 'SRV_DATA_EXPORT'.
Tábla: system.service_catalog
"""
__tablename__ = "service_catalog"
__table_args__ = {"schema": "system"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
name: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[Optional[str]] = mapped_column(String)
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@@ -10,6 +10,7 @@ from .identity import (
UserRole,
Device,
UserDeviceLink,
OneTimePassword,
)
from .address import (
@@ -42,6 +43,7 @@ __all__ = [
"UserRole",
"Device",
"UserDeviceLink",
"OneTimePassword",
"Address",
"GeoPostalCode",
"GeoStreet",

View File

@@ -78,6 +78,9 @@ class Person(Base):
index=True
)
# === SOFT DELETE (structure only - never delete Person data) ===
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), nullable=False)
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
@@ -147,6 +150,9 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# === SOFT DELETE ===
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
@@ -214,19 +220,24 @@ class User(Base):
service_requests: Mapped[List["ServiceRequest"]] = relationship("ServiceRequest", back_populates="user", cascade="all, delete-orphan")
class Wallet(Base):
""" Felhasználói pénztárca. """
""" Felhasználói / Szervezeti pénztárca (B2B & B2C). """
__tablename__ = "wallets"
__table_args__ = {"schema": "identity"}
__table_args__ = (
# Biztosítjuk, hogy vagy user_id, vagy organization_id legyen kitöltve, de ne egyszerre
# Ezt ORM szinten is ellenőrizzük, de DB constraint is véd
{"schema": "identity"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), unique=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), unique=True, nullable=True)
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=True, index=True)
earned_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
purchased_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
service_coins: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
currency: Mapped[str] = mapped_column(String(3), default="HUF")
user: Mapped["User"] = relationship("User", back_populates="wallet")
user: Mapped[Optional["User"]] = relationship("User", back_populates="wallet")
active_vouchers: Mapped[List["ActiveVoucher"]] = relationship("ActiveVoucher", back_populates="wallet", cascade="all, delete-orphan")
class VerificationToken(Base):
@@ -306,6 +317,25 @@ class UserTrustProfile(Base):
user: Mapped["User"] = relationship("User", back_populates="trust_profile", uselist=False)
class OneTimePassword(Base):
"""
6-jegyű OTP (One Time Password) ideiglenes tárolása.
Típusok: 'restore' (fiók visszaállítás), 'company_claim' (cég átvétel)
Lejárati idő: 15 perc.
"""
__tablename__ = "one_time_passwords"
__table_args__ = {"schema": "identity"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
email: Mapped[str] = mapped_column(String, index=True, nullable=False)
code: Mapped[str] = mapped_column(String(6), nullable=False)
otp_type: Mapped[str] = mapped_column(String(30), nullable=False) # 'restore' or 'company_claim'
extra_data: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"), nullable=True)
is_used: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class Device(Base):
""" Ismert eszközök (Device Fingerprinting) a Device-Hub architektúrához. """
__tablename__ = "devices"

View File

@@ -93,6 +93,8 @@ class Organization(Base):
default=OrgType.individual
)
is_affiliate_partner: Mapped[bool] = mapped_column(Boolean, server_default=text('false'), default=False)
status: Mapped[str] = mapped_column(String(30), default="pending_verification")
# Soft delete: is_active=False és is_deleted=True esetén a cég 'törölt'

View File

@@ -10,7 +10,6 @@ from .vehicle_definitions import (
from .vehicle import (
CostCategory,
VehicleCost,
VehicleOdometerState,
VehicleUserRating,
GbCatalogDiscovery,
)
@@ -28,6 +27,7 @@ from .asset import (
ExchangeRate,
CatalogDiscovery,
VehicleOwnership,
OdometerReading,
)
from .history import AuditLog, LogSeverity
@@ -42,7 +42,6 @@ __all__ = [
"ModelFeatureMap",
"CostCategory",
"VehicleCost",
"VehicleOdometerState",
"VehicleUserRating",
"GbCatalogDiscovery",
"ExternalReferenceLibrary",
@@ -62,4 +61,5 @@ __all__ = [
# --- EXPORT LISTA KIEGÉSZÍTÉSE ---
"MotorcycleSpecs",
"BodyTypeDictionary",
"OdometerReading",
]

View File

@@ -4,7 +4,7 @@ import uuid
import enum
from datetime import datetime
from typing import List, Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, BigInteger, Integer, Float
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, CheckConstraint, BigInteger, Integer, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.sql import func
@@ -69,7 +69,13 @@ class RoofTypeEnum(str, enum.Enum):
class Asset(Base):
""" A fizikai eszköz (Digital Twin) - Minden adat itt fut össze. """
__tablename__ = "assets"
__table_args__ = {"schema": "vehicle"}
__table_args__ = (
CheckConstraint(
"vin IS NOT NULL OR license_plate IS NOT NULL",
name="ck_asset_vin_or_plate_required"
),
{"schema": "vehicle"}
)
# === IDENTIFICATION ===
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -147,6 +153,7 @@ class Asset(Base):
assignments: Mapped[List["AssetAssignment"]] = relationship("AssetAssignment", back_populates="asset")
ownership_history: Mapped[List["VehicleOwnership"]] = relationship("VehicleOwnership", back_populates="asset")
service_requests: Mapped[List["ServiceRequest"]] = relationship("ServiceRequest", back_populates="asset", cascade="all, delete-orphan")
odometer_readings: Mapped[List["OdometerReading"]] = relationship("OdometerReading", back_populates="asset", order_by="OdometerReading.recorded_at.desc()")
# --- COMPUTED PROPERTIES (for Pydantic schema compatibility) ---
@property
@@ -249,6 +256,7 @@ class AssetCost(Base):
currency: Mapped[str] = mapped_column(String(3), default="HUF")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True)
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
@@ -342,6 +350,23 @@ class AssetTelemetry(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="telemetry")
class OdometerReading(Base):
""" Km óra állás rögzítése (időbeli nyomonkövetés). """
__tablename__ = "odometer_readings"
__table_args__ = {"schema": "vehicle"}
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False, index=True)
reading: Mapped[int] = mapped_column(Integer, nullable=False) # km óra állás
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
source: Mapped[str] = mapped_column(String(30), default="manual") # manual, api, telemetry
cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True)
# Relationships
asset: Mapped["Asset"] = relationship("Asset", back_populates="odometer_readings")
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost")
class AssetAssignment(Base):
""" Eszköz-Szervezet összerendelés. """
__tablename__ = "asset_assignments"

View File

@@ -6,11 +6,11 @@ TCO (Total Cost of Ownership) alapmodelljei a 'vehicle' sémában.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Numeric, UniqueConstraint, Float, CheckConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Numeric, UniqueConstraint, CheckConstraint
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
@@ -22,7 +22,7 @@ class CostCategory(Base):
Rendszerkategóriák (is_system=True) nem törölhetők, csak felhasználói kategóriák.
"""
__tablename__ = "cost_categories"
__table_args__ = {"schema": "vehicle"}
__table_args__ = {"schema": "vehicle", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
parent_id: Mapped[Optional[int]] = mapped_column(
@@ -36,6 +36,7 @@ class CostCategory(Base):
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'")
min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'") # free, premium, enterprise
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())
@@ -108,36 +109,6 @@ class VehicleCost(Base):
return f"VehicleCost(id={self.id}, vehicle_id={self.vehicle_id}, amount={self.amount} {self.currency})"
class VehicleOdometerState(Base):
"""
Jármű kilométeróra állapotának és becslésének tárolása.
Adminisztrátor által paraméterezhető algoritmusokkal működik.
"""
__tablename__ = "vehicle_odometer_states"
__table_args__ = {"schema": "vehicle"}
vehicle_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("vehicle.vehicle_model_definitions.id", ondelete="CASCADE"),
primary_key=True,
nullable=False
)
last_recorded_odometer: Mapped[int] = mapped_column(Integer, nullable=False)
last_recorded_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
daily_avg_distance: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
estimated_current_odometer: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
confidence_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
manual_override_avg: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), 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())
# Kapcsolat a jármű definícióval
vehicle: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="odometer_state")
def __repr__(self) -> str:
return f"VehicleOdometerState(vehicle_id={self.vehicle_id}, estimated={self.estimated_current_odometer}, confidence={self.confidence_score})"
class VehicleUserRating(Base):
"""
Jármű értékelési rendszer - User -> Vehicle kapcsolat.
@@ -155,7 +126,7 @@ class VehicleUserRating(Base):
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
PG_UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
server_default=func.gen_random_uuid()

View File

@@ -12,7 +12,7 @@ from app.database import Base
# Típus ellenőrzés a körkörös importok elkerülésére
if TYPE_CHECKING:
from .asset import AssetCatalog
from .vehicle import VehicleCost, VehicleOdometerState, VehicleUserRating
from .vehicle import VehicleCost, VehicleUserRating
class VehicleType(Base):
""" Jármű kategóriák (pl. Személyautó, Motorkerékpár, Teherautó, Hajó) """
@@ -106,6 +106,10 @@ class VehicleModelDefinition(Base):
transmission_type: Mapped[Optional[str]] = mapped_column(String(50))
drive_type: Mapped[Optional[str]] = mapped_column(String(50))
# --- SZERVIZ INTERVALLUMOK ---
service_interval_km: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
service_interval_months: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# --- ÉLETCIKLUS ---
year_from: Mapped[int] = mapped_column(Integer, index=True, server_default=text("0")) # EZT IS BELETETTÜK A KULCSBA
year_to: Mapped[Optional[int]] = mapped_column(Integer, index=True)
@@ -139,7 +143,6 @@ class VehicleModelDefinition(Base):
ratings: Mapped[List["VehicleUserRating"]] = relationship("VehicleUserRating", back_populates="vehicle", cascade="all, delete-orphan")
costs: Mapped[List["VehicleCost"]] = relationship("VehicleCost", back_populates="vehicle", cascade="all, delete-orphan")
odometer_state: Mapped[Optional["VehicleOdometerState"]] = relationship("VehicleOdometerState", back_populates="vehicle", uselist=False, cascade="all, delete-orphan")
class ModelFeatureMap(Base):

View File

@@ -1,5 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset.py
from pydantic import BaseModel, ConfigDict, Field, validator
from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator
from typing import Optional, Dict, Any, List
from uuid import UUID
from datetime import datetime
@@ -113,8 +113,30 @@ class AssetResponse(BaseModel):
class AssetCreate(BaseModel):
""" Jármű létrehozásához szükséges adatok - Thick Digital Twin támogatással. """
# === CORE IDENTIFICATION (Required for status determination) ===
license_plate: str = Field(..., min_length=2, max_length=20, description="Rendszám")
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám (opcionális)")
# ── Pre-validator: convert empty strings to None for optional string fields ──
@validator('license_plate', 'vin', 'trim_level',
'cylinder_layout', 'transmission_type', 'drive_type',
'euro_classification', 'roof_type', 'audio_system_type',
'data_status',
pre=True, always=True)
def empty_str_to_none(cls, v):
"""Convert empty strings to None so Pydantic doesn't reject them with min_length."""
if v is not None and isinstance(v, str) and v.strip() == '':
return None
return v
# ── Root validator: ensure at least one of vin or license_plate is provided ──
@root_validator(skip_on_failure=True)
def validate_vin_or_plate(cls, values):
"""Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám."""
license_plate = values.get('license_plate')
vin = values.get('vin')
if not license_plate and not vin:
raise ValueError('Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám.')
return values
# === CLASSIFICATION (Optional, but affects status) ===
brand: Optional[str] = Field(None, max_length=100, description="Márka (ha nincs catalog_id)")
@@ -190,6 +212,35 @@ class AssetUpdate(BaseModel):
# === CORE IDENTIFICATION ===
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám")
# ── Pre-validator: convert empty strings to None for optional string fields ──
@validator('license_plate', 'vin', 'trim_level',
'cylinder_layout', 'transmission_type', 'drive_type',
'euro_classification', 'roof_type', 'audio_system_type',
pre=True, always=True)
def empty_str_to_none(cls, v):
"""Convert empty strings to None so Pydantic doesn't reject them with min_length."""
if v is not None and isinstance(v, str) and v.strip() == '':
return None
return v
# ── Model validator: ensure at least one of vin or license_plate is provided ──
@model_validator(mode='after')
def validate_vin_or_plate(self):
"""Update esetén: ha mindkét mezőt None-ra állítják explicit, az nem engedélyezett.
Működés:
- Ha egyik mező sincs a payload-ban (üres update) → engedélyezve (nincs változás)
- Ha mindkettő None explicit → hiba (nem lehet mindkettőt elvenni)
- Ha legalább az egyik meg van adva → OK
"""
# model_fields_set tartalmazza az explicit megadott mezőket
if 'license_plate' not in self.model_fields_set and 'vin' not in self.model_fields_set:
return self
if not self.license_plate and not self.vin:
raise ValueError('Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám.')
return self
# === CLASSIFICATION ===
brand: Optional[str] = Field(None, max_length=100, description="Márka")

View File

@@ -6,31 +6,30 @@ from decimal import Decimal
from uuid import UUID
class AssetCostBase(BaseModel):
cost_type: str # fuel, service, tax, insurance
amount_local: Decimal
currency_local: str = "HUF"
net_amount_local: Optional[Decimal] = None
vat_rate: Optional[Decimal] = Field(default=27.0)
"""Base schema — matches the AssetCost SQLAlchemy model fields."""
amount_net: Decimal
currency: str = "HUF"
date: datetime = Field(default_factory=datetime.now)
mileage_at_cost: Optional[int] = None
description: Optional[str] = None
data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták
invoice_number: Optional[str] = None
data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták
category_id: int # FK a CostCategory táblához
mileage_at_cost: Optional[int] = None # Stored inside data JSONB
description: Optional[str] = None # Stored inside data JSONB
class AssetCostCreate(AssetCostBase):
asset_id: UUID
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
category_id: int # FK a CostCategory táblához
class AssetCostResponse(AssetCostBase):
"""Response schema — matches DB columns from vehicle.asset_costs."""
id: UUID
asset_id: UUID
organization_id: int
driver_id: Optional[int] = None
# Pénzügyi dúsítás (Backend számolja)
amount_eur: Optional[Decimal] = None
exchange_rate_used: Optional[Decimal] = None
created_at: datetime
# Derived / enriched fields (not in DB model directly)
category_name: Optional[str] = None # Resolved from CostCategory relationship
category_code: Optional[str] = None # Resolved from CostCategory relationship
description: Optional[str] = None # Extracted from data['description']
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
model_config = ConfigDict(from_attributes=True)

View File

@@ -40,11 +40,26 @@ class CorpOnboardResponse(BaseModel):
class OrganizationUpdate(BaseModel):
"""PATCH séma a szervezet adatainak részleges frissítéséhez.
Támogatja a visual_settings JSONB mező biztonságos részleges frissítését."""
Támogatja a visual_settings JSONB mező biztonságos részleges frissítését,
valamint a cégadatok (név, adószám, cím) szerkesztését."""
visual_settings: Optional[dict] = None
display_name: Optional[str] = None
default_currency: Optional[str] = None
language: Optional[str] = None
# ── Cégadatok (Company details) ──
name: Optional[str] = None
full_name: Optional[str] = None
tax_number: Optional[str] = None
# ── Cím adatok (Address fields) ──
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
class OrganizationResponse(BaseModel):

View File

@@ -0,0 +1,33 @@
# /opt/docker/dev/service_finder/backend/app/schemas/service_catalog.py
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional
from datetime import datetime
class ServiceCatalogCreate(BaseModel):
"""Schema új szolgáltatás katalógus bejegyzés létrehozásához."""
service_code: str = Field(..., min_length=3, max_length=50, description="Egyedi szolgáltatás kód (pl. 'SRV_DATA_EXPORT')")
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltatás megjelenített neve")
description: Optional[str] = Field(None, max_length=1000, description="Részletes leírás")
credit_cost: int = Field(0, ge=0, description="Alapértelmezett kredit költség")
is_active: bool = Field(True, description="Aktív-e a szolgáltatás")
class ServiceCatalogUpdate(BaseModel):
"""Schema meglévő szolgáltatás katalógus bejegyzés módosításához."""
name: Optional[str] = Field(None, min_length=2, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
credit_cost: Optional[int] = Field(None, ge=0)
is_active: Optional[bool] = None
class ServiceCatalogResponse(BaseModel):
"""Schema szolgáltatás katalógus bejegyzés visszaadásához."""
id: int
service_code: str
name: str
description: Optional[str] = None
credit_cost: int
is_active: bool
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,143 @@
"""
📦 Subscription Tier Pydantic Schemas
A subscription_tiers JSONB rules mezőjének erősen tipizált Pydantic modelljei.
A rules struktúra a seed_packages.py-ban definiált formátumot követi:
{
"type": "private" | "corporate",
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
"entitlements": ["SRV_..."],
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
}
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ── Belső (embedded) modellek a rules JSONB struktúrához ──────────────────────
class PricingModel(BaseModel):
"""Árazási információk egy előfizetési csomaghoz."""
monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj (EUR)")
yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj (EUR)")
currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja")
credit_price: Optional[int] = Field(default=None, ge=0, description="Kreditár (havi kredit költség a csomaghoz)")
class AllowancesModel(BaseModel):
"""Korlátok és keretek a csomagban."""
max_vehicles: int = Field(..., ge=0, description="Maximális járműszám")
max_garages: int = Field(..., ge=0, description="Maximális garázsok száma")
monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma")
class LifecycleModel(BaseModel):
"""Életciklus állapotok egy előfizetési csomaghoz."""
is_public: bool = Field(default=True, description="Publikus-e a csomag (látszik-e a felhasználóknak)")
available_until: Optional[datetime] = Field(default=None, description="Meddig érhető el a csomag (None = nincs lejárat)")
class AffiliateModel(BaseModel):
"""Partnerprogram (affiliate) beállítások."""
commission_rate_percent: int = Field(..., ge=0, le=100, description="Jutalék mértéke százalékban")
referral_bonus_credits: int = Field(..., ge=0, description="Ajánlási bónusz kreditek száma")
class SubscriptionRulesModel(BaseModel):
"""
A subscription_tiers.rules JSONB oszlopának erősen tipizált modellje.
Fields:
type: "private" (magánszemély) vagy "corporate" (vállalati)
display_name: Emberi olvasásra szánt megjelenítési név (pl. "Privát Ingyenes")
pricing: Árazási adatok (havi/éves díj, pénznem)
allowances: Korlátok (max jármű, garázs, kredit)
entitlements: Jogosultságok listája (pl. SRV_DATA_EXPORT)
affiliate: Partnerprogram beállítások
lifecycle: Életciklus állapotok (opcionális, pl. is_public)
"""
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa")
display_name: Optional[str] = Field(
default=None,
description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')",
)
pricing: Optional[PricingModel] = Field(
default=None,
description="Árazási adatok (havi/éves díj, pénznem). Opcionális a hiányos seed adatok miatt."
)
allowances: Optional[AllowancesModel] = Field(
default=None,
description="Korlátok (max jármű, garázs, kredit). Opcionális a hiányos seed adatok miatt."
)
entitlements: List[str] = Field(default_factory=list, description="Jogosultságok (entitlement) listája")
affiliate: Optional[AffiliateModel] = Field(
default=None,
description="Partnerprogram beállítások. Opcionális a hiányos seed adatok miatt."
)
lifecycle: Optional[LifecycleModel] = Field(
default=None,
description="Életciklus állapotok (is_public, available_until)"
)
@field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str:
if v.lower() not in ("private", "corporate"):
raise ValueError("A 'type' mező csak 'private' vagy 'corporate' lehet.")
return v.lower()
# ── API Response / Request modellek ───────────────────────────────────────────
class SubscriptionTierResponse(BaseModel):
"""Teljes SubscriptionTier rekord visszaadása (GET /, GET /{id})."""
id: int
name: str
rules: SubscriptionRulesModel
is_custom: bool
model_config = ConfigDict(from_attributes=True)
class SubscriptionTierCreate(BaseModel):
"""Új előfizetési csomag létrehozása (POST /)."""
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
rules: SubscriptionRulesModel
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
if not v.strip():
raise ValueError("A csomag neve nem lehet üres.")
return v.strip().lower()
class SubscriptionTierUpdate(BaseModel):
"""Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id})."""
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
@field_validator("name")
@classmethod
def validate_name(cls, v: Optional[str]) -> Optional[str]:
if v is not None and not v.strip():
raise ValueError("A csomag neve nem lehet üres.")
return v.strip().lower() if v else v
class SubscriptionTierListResponse(BaseModel):
"""Csomagok listázásának válaszformátuma (GET /)."""
total: int
tiers: List[SubscriptionTierResponse]

View File

@@ -64,6 +64,8 @@ class UserResponse(UserBase):
preferred_language: str = "hu"
# Beágyazott személyes adatok (Person + Address)
person: Optional[PersonResponse] = None
# Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
is_last_admin: bool = False
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Hard Delete Cleanup Script — Cascade törlés User és kapcsolódó rekordokra.
Használat:
docker compose exec sf_api python3 /app/backend/app/scripts/cleanup_user.py --email %test%
Az --email paraméter részleges egyezést is támogat (SQL LIKE, pl. %test%@%.
A szkript tranzakcióban törli:
- User rekordot
- Person rekordot (ha a User volt az egyetlen kapcsolódó)
- Organization rekordot (ha a User volt az egyetlen owner)
- Session tokeneket, SocialAccount-okat, Wallet-eket, stb.
"""
import argparse
import logging
import sys
from sqlalchemy import select, delete, text
from sqlalchemy.orm import joinedload
# Projekt importok
from app.database import async_session_factory
from app.models.identity import User, Person, UserRole
from app.models.identity.identity import (
SocialAccount, Wallet, ActiveVoucher, VerificationToken,
UserDeviceLink, UserTrustProfile, OneTimePassword,
)
from app.models.gamification.gamification import UserStats
from app.models.marketplace.service_request import ServiceRequest
from app.models.social import ServiceReview
from app.models.vehicle.asset import VehicleOwnership
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("cleanup_user")
async def cleanup_user(email_pattern: str) -> None:
"""
Megkeresi a megadott email mintára illeszkedő usereket,
és cascade hard delete-et hajt végre rajtuk.
"""
async with async_session_factory() as db:
# 1. Keresés részleges email egyezéssel
stmt = (
select(User)
.where(User.email.ilike(email_pattern))
.options(
joinedload(User.person),
joinedload(User.stats),
joinedload(User.wallet),
joinedload(User.trust_profile),
joinedload(User.ownership_history),
joinedload(User.social_accounts),
joinedload(User.service_reviews),
joinedload(User.service_requests),
)
)
result = await db.execute(stmt)
users = result.unique().scalars().all()
if not users:
logger.warning("❌ Nem található user a '%s' mintára.", email_pattern)
return
logger.info("🔍 Talált userek (%d db):", len(users))
for u in users:
logger.info(" - ID=%d, Email=%s, Role=%s", u.id, u.email, u.role.value if hasattr(u.role, 'value') else u.role)
# 2. Tranzakció indítása
async with db.begin():
for user in users:
user_id = user.id
email = user.email
logger.info("🗑️ Törlés: User ID=%d, Email=%s", user_id, email)
# --- Cascade törlés ---
# UserDeviceLink
await db.execute(
delete(UserDeviceLink).where(UserDeviceLink.user_id == user_id)
)
logger.info(" ✓ UserDeviceLink törölve")
# VerificationToken
await db.execute(
delete(VerificationToken).where(VerificationToken.user_id == user_id)
)
logger.info(" ✓ VerificationToken törölve")
# OneTimePassword
await db.execute(
delete(OneTimePassword).where(OneTimePassword.email == email)
)
logger.info(" ✓ OneTimePassword törölve")
# SocialAccount (cascade all, delete-orphan)
await db.execute(
delete(SocialAccount).where(SocialAccount.user_id == user_id)
)
logger.info(" ✓ SocialAccount törölve")
# UserTrustProfile (cascade all, delete-orphan)
await db.execute(
delete(UserTrustProfile).where(UserTrustProfile.user_id == user_id)
)
logger.info(" ✓ UserTrustProfile törölve")
# UserStats (gamification)
await db.execute(
delete(UserStats).where(UserStats.user_id == user_id)
)
logger.info(" ✓ UserStats törölve")
# Wallet + ActiveVoucher (cascade)
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id)
wallet_result = await db.execute(wallet_stmt)
wallet = wallet_result.scalar_one_or_none()
if wallet:
await db.execute(
delete(ActiveVoucher).where(ActiveVoucher.wallet_id == wallet.id)
)
await db.execute(
delete(Wallet).where(Wallet.id == wallet.id)
)
logger.info(" ✓ Wallet + ActiveVoucher törölve")
# ServiceReview
await db.execute(
delete(ServiceReview).where(ServiceReview.user_id == user_id)
)
logger.info(" ✓ ServiceReview törölve")
# ServiceRequest
await db.execute(
delete(ServiceRequest).where(ServiceRequest.user_id == user_id)
)
logger.info(" ✓ ServiceRequest törölve")
# VehicleOwnership
await db.execute(
delete(VehicleOwnership).where(VehicleOwnership.user_id == user_id)
)
logger.info(" ✓ VehicleOwnership törölve")
# Person rekord kezelése
person = user.person
if person:
# Ellenőrizzük, hogy más User nem hivatkozik-e erre a Person-ra
other_users_stmt = (
select(User)
.where(User.person_id == person.id, User.id != user_id)
.limit(1)
)
other_result = await db.execute(other_users_stmt)
other_user = other_result.scalar_one_or_none()
if not other_user:
# Nincs más User ehhez a Person-hoz -> törölhető
await db.execute(
delete(Person).where(Person.id == person.id)
)
logger.info(" ✓ Person ID=%d törölve (egyedüli kapcsolat)", person.id)
else:
logger.info(
" ⚠️ Person ID=%d MEGTARTVA (más User is hivatkozik rá: ID=%d)",
person.id, other_user.id
)
# Organization kezelése (ha a User volt az egyetlen owner)
from app.models.identity.organization import Organization
org_stmt = (
select(Organization)
.where(Organization.owner_id == user_id)
)
org_result = await db.execute(org_stmt)
owned_orgs = org_result.scalars().all()
for org in owned_orgs:
# Ellenőrizzük, hogy más User nem owner-e
other_owner_stmt = (
select(User)
.where(User.id != user_id, User.id.isnot(None))
.limit(1)
)
# Egyszerűbb: csak töröljük, ha nincs más owner
# (a gyakorlatban az Organization-nak lehet több tagja)
from app.models.identity.organization import OrganizationMember
member_stmt = (
select(OrganizationMember)
.where(
OrganizationMember.organization_id == org.id,
OrganizationMember.user_id != user_id,
)
.limit(1)
)
member_result = await db.execute(member_stmt)
other_member = member_result.scalar_one_or_none()
if not other_member:
await db.execute(
delete(Organization).where(Organization.id == org.id)
)
logger.info(" ✓ Organization ID=%d törölve (egyedüli owner)", org.id)
else:
logger.info(
" ⚠️ Organization ID=%d MEGTARTVA (van más tag)",
org.id
)
# VÉGÜL: Maga a User rekord törlése
await db.execute(
delete(User).where(User.id == user_id)
)
logger.info(" ✅ User ID=%d (%s) törölve.", user_id, email)
logger.info("🎉 Tranzakció sikeresen végrehajtva.")
def main():
parser = argparse.ArgumentParser(
description="Hard Delete Cleanup — User és kapcsolódó rekordok cascade törlése."
)
parser.add_argument(
"--email",
required=True,
help="Email minta (SQL LIKE, pl. %%test%% vagy teszt%%).",
)
args = parser.parse_args()
import asyncio
asyncio.run(cleanup_user(args.email))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""
📦 Nagy Előfizetés Migráció (Subscription Migration Script)
Cél:
A régi, denormalizált subscription_plan mezők (identity.users, fleet.organizations)
átvezetése a modern, normalizált subscription_tier + org_subscriptions / user_subscriptions
architektúrába.
Feladatok:
A) Létrehozza a corp_free_v1 csomagot, ha még nem létezik (0 áras, alap korlátokkal).
B) Létrehozza a user_subscriptions táblát (finance.user_subscriptions), ha még nem létezik.
C) Adatkonverzió: a meglévő subscription_plan string-eket átalakítja tier_id hivatkozásokká.
Használat:
docker compose exec sf_api python3 /app/backend/app/scripts/migrate_subscriptions.py
Architektúra:
- system.subscription_tiers: A csomagdefiníciók (SubscriptionTier modell)
- finance.org_subscriptions: Szervezeti előfizetések (OrganizationSubscription modell)
- finance.user_subscriptions: Felhasználói előfizetések (UserSubscription modell - ÚJ!)
"""
import asyncio
import logging
from datetime import datetime, timezone
from sqlalchemy import text, select, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import AsyncSessionLocal, Base
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("Migrate-Subscriptions")
# ---------------------------------------------------------------------------
# Tiers név -> ID mapping (feltöltés után)
# ---------------------------------------------------------------------------
TIER_MAP: dict[str, int] = {}
# ---------------------------------------------------------------------------
# A) corp_free_v1 definíció
# ---------------------------------------------------------------------------
CORP_FREE_V1 = {
"name": "corp_free_v1",
"rules": {
"type": "corporate",
"display_name": "Céges Ingyenes",
"pricing": {
"monthly_price": 0.0,
"yearly_price": 0.0,
"currency": "EUR",
"credit_price": 0,
},
"allowances": {
"max_vehicles": 3,
"max_garages": 1,
"monthly_free_credits": 0,
},
"entitlements": [],
"affiliate": {
"commission_rate_percent": 0,
"referral_bonus_credits": 0,
},
"lifecycle": {
"is_public": True,
},
},
"is_custom": False,
}
# ---------------------------------------------------------------------------
# Régi plan string -> tier név mapping
# ---------------------------------------------------------------------------
PLAN_TO_TIER: dict[str, str] = {
"FREE": "private_free_v1",
"free": "private_free_v1",
"PRO": "private_pro_v1",
"PREMIUM": "corp_premium_v1",
"ENTERPRISE": "corp_vip_v1",
}
# ---------------------------------------------------------------------------
# Segédfüggvények
# ---------------------------------------------------------------------------
async def _ensure_corp_free_v1(db) -> None:
"""A) Létrehozza a corp_free_v1 csomagot, ha még nem létezik."""
result = await db.execute(
select(SubscriptionTier).where(SubscriptionTier.name == "corp_free_v1")
)
existing = result.scalar_one_or_none()
if existing:
logger.info("✅ corp_free_v1 már létezik (ID=%d). Kihagyva.", existing.id)
TIER_MAP["corp_free_v1"] = existing.id
return
tier = SubscriptionTier(
name=CORP_FREE_V1["name"],
rules=CORP_FREE_V1["rules"],
is_custom=CORP_FREE_V1["is_custom"],
)
db.add(tier)
await db.commit()
await db.refresh(tier)
TIER_MAP["corp_free_v1"] = tier.id
logger.info("✅ corp_free_v1 létrehozva (ID=%d).", tier.id)
async def _load_tier_map(db) -> None:
"""Betölti az összes tier nevét és ID-ját a TIER_MAP-ba."""
result = await db.execute(select(SubscriptionTier))
tiers = result.scalars().all()
for t in tiers:
TIER_MAP[t.name] = t.id
logger.info("📋 Betöltött tier-ek: %s", {n: i for n, i in TIER_MAP.items()})
def _resolve_tier_id(plan: str | None) -> int | None:
"""Egy régi plan string-ből kikeresi a megfelelő tier ID-t."""
if not plan:
return None
tier_name = PLAN_TO_TIER.get(plan)
if not tier_name:
logger.warning("⚠️ Ismeretlen plan: '%s' -> private_free_v1 lesz.", plan)
tier_name = "private_free_v1"
return TIER_MAP.get(tier_name)
# ---------------------------------------------------------------------------
# B) UserSubscription tábla létrehozása (ha nem létezik)
# ---------------------------------------------------------------------------
async def _ensure_user_subscriptions_table(db) -> None:
"""Létrehozza a finance.user_subscriptions táblát, ha még nem létezik."""
result = await db.execute(
text(
"SELECT EXISTS (SELECT FROM information_schema.tables "
"WHERE table_schema = 'finance' AND table_name = 'user_subscriptions')"
)
)
exists = result.scalar()
if exists:
logger.info("✅ finance.user_subscriptions tábla már létezik.")
return
logger.info("🛠️ Létrehozom a finance.user_subscriptions táblát...")
await db.execute(
text("""
CREATE TABLE finance.user_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
tier_id INTEGER NOT NULL REFERENCES system.subscription_tiers(id),
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_until TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
)
""")
)
# Index a gyors lekérdezéshez
await db.execute(
text("""
CREATE INDEX idx_user_subscriptions_user_id
ON finance.user_subscriptions(user_id)
""")
)
await db.commit()
logger.info("✅ finance.user_subscriptions tábla létrehozva.")
# ---------------------------------------------------------------------------
# C) Adatkonverzió
# ---------------------------------------------------------------------------
async def _migrate_organizations(db) -> None:
"""Szervezetek subscription_plan mezőjének átvezetése org_subscriptions-ba."""
logger.info("=" * 60)
logger.info("Szervezetek migrálása...")
logger.info("=" * 60)
# 1. Lekérdezzük az összes szervezetet, aminek nincs még org_subscription-e
result = await db.execute(
text("""
SELECT o.id, o.subscription_plan
FROM fleet.organizations o
WHERE o.is_deleted = FALSE
AND NOT EXISTS (
SELECT 1 FROM finance.org_subscriptions os
WHERE os.org_id = o.id
)
ORDER BY o.id
""")
)
orgs = result.all()
logger.info("📊 %d szervezet vár migrálásra.", len(orgs))
inserted = 0
skipped = 0
for org in orgs:
tier_id = _resolve_tier_id(org.subscription_plan)
if tier_id is None:
logger.warning("⚠️ Org ID=%d: plan='%s' nem feloldható, kihagyva.", org.id, org.subscription_plan)
skipped += 1
continue
sub = OrganizationSubscription(
org_id=org.id,
tier_id=tier_id,
valid_from=datetime.now(timezone.utc),
valid_until=None,
is_active=True,
)
db.add(sub)
inserted += 1
await db.commit()
logger.info("%d org_subscription beszúrva. (%d kihagyva)", inserted, skipped)
async def _migrate_users(db) -> None:
"""Felhasználók subscription_plan mezőjének átvezetése user_subscriptions-ba."""
logger.info("=" * 60)
logger.info("Felhasználók migrálása...")
logger.info("=" * 60)
# 1. Lekérdezzük az összes aktív felhasználót, aminek nincs még user_subscription-e
result = await db.execute(
text("""
SELECT u.id, u.subscription_plan
FROM identity.users u
WHERE u.is_deleted = FALSE
AND NOT EXISTS (
SELECT 1 FROM finance.user_subscriptions us
WHERE us.user_id = u.id
)
ORDER BY u.id
""")
)
users = result.all()
logger.info("📊 %d felhasználó vár migrálásra.", len(users))
inserted = 0
skipped = 0
for user in users:
tier_id = _resolve_tier_id(user.subscription_plan)
if tier_id is None:
logger.warning("⚠️ User ID=%d: plan='%s' nem feloldható, kihagyva.", user.id, user.subscription_plan)
skipped += 1
continue
await db.execute(
text("""
INSERT INTO finance.user_subscriptions
(user_id, tier_id, valid_from, is_active, created_at)
VALUES
(:uid, :tid, NOW(), TRUE, NOW())
"""),
{"uid": user.id, "tid": tier_id},
)
inserted += 1
await db.commit()
logger.info("%d user_subscription beszúrva. (%d kihagyva)", inserted, skipped)
async def _verify_migration(db) -> None:
"""Ellenőrzi a migráció sikerességét."""
logger.info("=" * 60)
logger.info("🔍 Ellenőrzés")
logger.info("=" * 60)
# Tiers
result = await db.execute(
text("SELECT id, name FROM system.subscription_tiers ORDER BY id")
)
tiers = result.all()
logger.info("📋 Tiers (%d db):", len(tiers))
for t in tiers:
logger.info(" ID=%d name=%s", t.id, t.name)
# Org subscriptions
result = await db.execute(
text("""
SELECT COUNT(*) as cnt,
COALESCE(t.name, 'N/A') as tier_name
FROM finance.org_subscriptions os
LEFT JOIN system.subscription_tiers t ON t.id = os.tier_id
GROUP BY t.name
ORDER BY t.name
""")
)
rows = result.all()
total_org = sum(r.cnt for r in rows)
logger.info("📊 Org subscriptions (%d db):", total_org)
for r in rows:
logger.info(" %s: %d", r.tier_name, r.cnt)
# User subscriptions
result = await db.execute(
text("""
SELECT COUNT(*) as cnt,
COALESCE(t.name, 'N/A') as tier_name
FROM finance.user_subscriptions us
LEFT JOIN system.subscription_tiers t ON t.id = us.tier_id
GROUP BY t.name
ORDER BY t.name
""")
)
rows = result.all()
total_user = sum(r.cnt for r in rows)
logger.info("📊 User subscriptions (%d db):", total_user)
for r in rows:
logger.info(" %s: %d", r.tier_name, r.cnt)
logger.info("=" * 60)
logger.info("✅ Migráció befejeződött!")
logger.info(" Összes org subscription: %d", total_org)
logger.info(" Összes user subscription: %d", total_user)
logger.info("=" * 60)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
async def migrate():
logger.info("=" * 60)
logger.info("📦 Nagy Előfizetés Migráció")
logger.info("=" * 60)
async with AsyncSessionLocal() as db:
# A) corp_free_v1 létrehozása
logger.info("\n[1/5] corp_free_v1 csomag ellenőrzése...")
await _ensure_corp_free_v1(db)
# Tier map betöltése
logger.info("\n[2/5] Tier-ek betöltése...")
await _load_tier_map(db)
# B) user_subscriptions tábla létrehozása
logger.info("\n[3/5] user_subscriptions tábla ellenőrzése...")
await _ensure_user_subscriptions_table(db)
# C) Adatkonverzió
logger.info("\n[4/5] Adatkonverzió...")
await _migrate_organizations(db)
await _migrate_users(db)
# Ellenőrzés
logger.info("\n[5/5] Ellenőrzés...")
await _verify_migration(db)
if __name__ == "__main__":
asyncio.run(migrate())

View File

@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""
Seed szkript a jövőbiztos SaaS csomag-architektúra 1. fázisához.
Létrehozza a 6 alap subscription tier-t a system.subscription_tiers táblában.
Használat:
docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py
Architektúra:
A csomagok rules JSONB oszlopa egy szabványos struktúrát követ:
{
"type": "private" | "corporate",
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
"entitlements": ["SRV_..."],
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
}
"""
import asyncio
import json
import logging
from sqlalchemy import select, delete
from app.database import AsyncSessionLocal
from app.models.core_logic import SubscriptionTier
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("Seed-Packages")
# ---------------------------------------------------------------------------
# Csomagdefiníciók
# ---------------------------------------------------------------------------
PACKAGES = [
{
"name": "private_free_v1",
"rules": {
"type": "private",
"display_name": "Privát Ingyenes",
"pricing": {
"monthly_price": 0.0,
"yearly_price": 0.0,
"currency": "EUR",
"credit_price": 0,
},
"allowances": {
"max_vehicles": 1,
"max_garages": 1,
"monthly_free_credits": 0,
},
"entitlements": [],
"affiliate": {
"commission_rate_percent": 0,
"referral_bonus_credits": 0,
},
"lifecycle": {
"is_public": True,
},
},
"is_custom": False,
},
{
"name": "private_pro_v1",
"rules": {
"type": "private",
"display_name": "Privát Pro",
"pricing": {
"monthly_price": 4.99,
"yearly_price": 49.99,
"currency": "EUR",
"credit_price": 500,
},
"allowances": {
"max_vehicles": 3,
"max_garages": 1,
"monthly_free_credits": 50,
},
"entitlements": ["SRV_DATA_EXPORT"],
"affiliate": {
"commission_rate_percent": 10,
"referral_bonus_credits": 25,
},
"lifecycle": {
"is_public": True,
},
},
"is_custom": False,
},
{
"name": "private_vip_v1",
"rules": {
"type": "private",
"display_name": "Privát VIP",
"pricing": {
"monthly_price": 9.99,
"yearly_price": 99.99,
"currency": "EUR",
"credit_price": 1200,
},
"allowances": {
"max_vehicles": 10,
"max_garages": 1,
"monthly_free_credits": 150,
},
"entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD"],
"affiliate": {
"commission_rate_percent": 15,
"referral_bonus_credits": 50,
},
"lifecycle": {
"is_public": True,
},
},
"is_custom": False,
},
{
"name": "corp_premium_v1",
"rules": {
"type": "corporate",
"display_name": "Céges Prémium",
"pricing": {
"monthly_price": 29.99,
"yearly_price": 299.99,
"currency": "EUR",
"credit_price": 3000,
},
"allowances": {
"max_vehicles": 20,
"max_garages": 3,
"monthly_free_credits": 300,
},
"entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD"],
"affiliate": {
"commission_rate_percent": 15,
"referral_bonus_credits": 100,
},
"lifecycle": {
"is_public": True,
},
},
"is_custom": False,
},
{
"name": "corp_premium_plus_v1",
"rules": {
"type": "corporate",
"display_name": "Céges Prémium Plus",
"pricing": {
"monthly_price": 59.99,
"yearly_price": 599.99,
"currency": "EUR",
"credit_price": 6000,
},
"allowances": {
"max_vehicles": 50,
"max_garages": 10,
"monthly_free_credits": 800,
},
"entitlements": [
"SRV_DATA_EXPORT",
"SRV_AI_UPLOAD",
"SRV_ACCOUNTING_SYNC",
],
"affiliate": {
"commission_rate_percent": 20,
"referral_bonus_credits": 250,
},
"lifecycle": {
"is_public": True,
},
},
"is_custom": False,
},
{
"name": "corp_vip_v1",
"rules": {
"type": "corporate",
"display_name": "Céges VIP",
"pricing": {
"monthly_price": 149.99,
"yearly_price": 1499.99,
"currency": "EUR",
"credit_price": 15000,
},
"allowances": {
"max_vehicles": 200,
"max_garages": 50,
"monthly_free_credits": 2500,
},
"entitlements": [
"SRV_DATA_EXPORT",
"SRV_AI_UPLOAD",
"SRV_ACCOUNTING_SYNC",
"SRV_API_ACCESS",
],
"affiliate": {
"commission_rate_percent": 25,
"referral_bonus_credits": 500,
},
"lifecycle": {
"is_public": True,
},
},
"is_custom": False,
},
]
async def seed():
logger.info("=" * 60)
logger.info("Seed Packages - Advanced Subscription Tiers (Phase 1)")
logger.info("=" * 60)
async with AsyncSessionLocal() as db:
# 1. Töröljük a meglévő rekordokat (tiszta lap)
logger.info("Törlöm a meglévő subscription_tiers rekordokat...")
await db.execute(delete(SubscriptionTier))
await db.commit()
logger.info("✅ Meglévő rekordok törölve.")
# 2. Beszúrjuk a 6 új csomagot
inserted_count = 0
for pkg in PACKAGES:
tier = SubscriptionTier(
name=pkg["name"],
rules=pkg["rules"],
is_custom=pkg["is_custom"],
)
db.add(tier)
inserted_count += 1
logger.info(f" Hozzáadva: {pkg['name']}")
await db.commit()
logger.info(f"{inserted_count} csomag sikeresen beszúrva.")
# 3. Visszaigazolás: listázzuk ki az összes rekordot
logger.info("-" * 60)
logger.info("Visszaigazolás - system.subscription_tiers tartalma:")
logger.info("-" * 60)
result = await db.execute(
select(SubscriptionTier).order_by(SubscriptionTier.id)
)
tiers = result.scalars().all()
for tier in tiers:
rules_json = json.dumps(tier.rules, indent=2, ensure_ascii=False)
logger.info(f"\n ID: {tier.id}")
logger.info(f" Name: {tier.name}")
logger.info(f" Is Custom: {tier.is_custom}")
logger.info(f" Rules:\n{rules_json}")
logger.info(" " + "-" * 40)
logger.info(f"\n✅ Összesen {len(tiers)} aktív csomag a rendszerben.")
# 4. JSONB példa kiírása
if tiers:
sample = tiers[0]
logger.info("=" * 60)
logger.info("JSONB PÉLDA (private_free_v1):")
logger.info("=" * 60)
logger.info(json.dumps(sample.rules, indent=2, ensure_ascii=False))
if __name__ == "__main__":
asyncio.run(seed())

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Service Finder - ServiceCatalog Seed Script
=============================================
Aszinkron seed szkript a system.service_catalog tábla feltöltéséhez.
Törli a meglévő adatokat, majd beszúrja a 3 alap szolgáltatást.
Használat:
docker compose exec sf_api python3 /app/backend/app/scripts/seed_services.py
"""
import asyncio
import sys
from pathlib import Path
# Projekt gyökér hozzáadása a Python path-hoz
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy import text
from app.db.session import AsyncSessionLocal
from app.models.core_logic import ServiceCatalog
SERVICES = [
{
"service_code": "SRV_DATA_EXPORT",
"name": "Adat Export",
"description": "PDF és Excel adatexport funkciók",
"credit_cost": 0,
},
{
"service_code": "SRV_AI_UPLOAD",
"name": "AI Számlafeldolgozás",
"description": "Dokumentumok és számlák AI alapú beolvasása",
"credit_cost": 50,
},
{
"service_code": "SRV_DIGITAL_BOOK",
"name": "Digitális Szervizkönyv",
"description": "Idegen járművek szerviztörténetének lekérdezése",
"credit_cost": 150,
},
]
async def seed_services():
print("=" * 70)
print(" ServiceCatalog Seed Script")
print("=" * 70)
async with AsyncSessionLocal() as db:
# 1. Tábla tartalmának törlése
print("\n🧹 Törlés: system.service_catalog összes rekordja...")
await db.execute(text("DELETE FROM system.service_catalog"))
await db.commit()
print(" ✅ Törölve.")
# 2. Szolgáltatások beszúrása
print("\n📦 Szolgáltatások beszúrása:")
for svc in SERVICES:
stmt = text("""
INSERT INTO system.service_catalog (service_code, name, description, credit_cost, is_active)
VALUES (:service_code, :name, :description, :credit_cost, TRUE)
""")
await db.execute(stmt, svc)
print(f"{svc['service_code']:20s} | {svc['name']:25s} | {svc['credit_cost']:>4} kredit")
await db.commit()
print("\n ✅ Minden rekord beszúrva.")
# 3. Lekérdezés - bizonyíték
print("\n" + "=" * 70)
print(" 📋 LEKÉRDEZÉS - system.service_catalog tartalma")
print("=" * 70)
result = await db.execute(
text("SELECT id, service_code, name, description, credit_cost, is_active FROM system.service_catalog ORDER BY id")
)
rows = result.fetchall()
if not rows:
print("\n❌ NINCSENEK REKORDOK a táblában!")
else:
print(f"\n{'ID':>4} | {'Kód':20s} | {'Név':25s} | {'Leírás':45s} | {'Ár':>5s} | {'Aktív':>5s}")
print("-" * 110)
for row in rows:
print(f"{row.id:>4} | {row.service_code:20s} | {row.name:25s} | {(row.description or ''):45s} | {row.credit_cost:>5} | {'Igen' if row.is_active else 'Nem'}")
print("\n" + "=" * 70)
print(f" Összesen: {len(rows)} rekord a system.service_catalog táblában.")
print("=" * 70)
if __name__ == "__main__":
asyncio.run(seed_services())

View File

@@ -18,15 +18,7 @@ from app.database import Base
from app.core.config import settings
def dynamic_import_models():
models_dir = Path(__file__).parent.parent / "models"
for py_file in models_dir.glob("*.py"):
if py_file.name == "__init__.py": continue
module_name = f"app.models.{py_file.stem}"
try:
importlib.import_module(module_name)
print(f"✅ Imported {module_name}")
except Exception as e:
print(f"⚠️ Could not import {module_name}: {e}")
"""Modellek betöltése a Metadata feltöltéséhez. Egyszerű import az __init__.py-n keresztül."""
import app.models
print(f"📦 Total tables in Base.metadata: {len(Base.metadata.tables)}")

View File

@@ -228,12 +228,34 @@ class AssetService:
# --- MATCHER INTEGRÁCIÓ (HA NINCS CATALOG_ID DE VANNAK ADATOK) ---
if not new_asset.catalog_id and new_asset.brand and new_asset.model:
from app.services.asset_matcher_service import AssetMatcherService
from app.models.vehicle.asset import AssetCatalog
print(f"DEBUG: Matcher args: make={new_asset.brand}, model={new_asset.model}, year={new_asset.year_of_manufacture}")
matched_result = await AssetMatcherService.find_best_match(db, new_asset)
if matched_result:
matched_def, conf = matched_result
if matched_def:
new_asset.catalog_id = matched_def.id
# FIX: Asset.catalog_id FK references vehicle.vehicle_catalog.id (AssetCatalog),
# NOT vehicle.vehicle_model_definitions.id (VehicleModelDefinition).
# Look up or create the AssetCatalog entry for this definition.
catalog_stmt = select(AssetCatalog).where(
AssetCatalog.master_definition_id == matched_def.id
).limit(1)
catalog_entry = (await db.execute(catalog_stmt)).scalar_one_or_none()
if not catalog_entry:
# Create a new AssetCatalog entry from the matched definition
catalog_entry = AssetCatalog(
master_definition_id=matched_def.id,
make=matched_def.make,
model=matched_def.marketing_name,
year_from=matched_def.year_from,
year_to=matched_def.year_to,
fuel_type=matched_def.fuel_type,
power_kw=matched_def.power_kw if matched_def.power_kw and matched_def.power_kw > 0 else None,
engine_capacity=matched_def.engine_capacity if matched_def.engine_capacity and matched_def.engine_capacity > 0 else None,
)
db.add(catalog_entry)
await db.flush()
new_asset.catalog_id = catalog_entry.id
await AssetMatcherService.enrich_asset_from_definition(db, new_asset, matched_def, conf)
await db.flush()
@@ -348,9 +370,15 @@ class AssetService:
@staticmethod
async def get_models(db: AsyncSession, make: str, vehicle_class: str = None) -> List[str]:
"""Get all distinct models for a given make, optionally filtered by vehicle_class."""
"""Get all distinct models for a given make, optionally filtered by vehicle_class.
Fuzzy matching: removes spaces and lowercases both the query and stored values
so that 'CB 1000' matches 'CB1000'.
"""
stmt = select(distinct(VehicleModelDefinition.marketing_name)).where(
VehicleModelDefinition.make == make
func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(
f'%{make.replace(" ", "").lower()}%'
)
)
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
@@ -384,6 +412,78 @@ class AssetService:
engines = result.scalars().all()
return engines
# --- CASCADING CATALOG METHODS (for wizard autocomplete) ---
@staticmethod
async def get_catalog_brands(db: AsyncSession, vehicle_class: Optional[str] = None, query: Optional[str] = None) -> List[str]:
"""Get all distinct brands (makes) from vehicle_model_definitions, with optional class filter and search query.
Fuzzy matching: removes spaces and lowercases both the query and stored values
so that 'BMW' matches 'bmw' and 'CB 1000' matches 'CB1000'.
"""
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
if query:
clean_query = query.replace(' ', '').lower()
stmt = stmt.where(
func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(f'%{clean_query}%')
)
result = await db.execute(stmt)
brands = result.scalars().all()
return [b for b in brands if b]
@staticmethod
async def get_catalog_models(db: AsyncSession, brand: str, vehicle_class: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all distinct models for a given brand, returning id and marketing_name."""
stmt = select(
VehicleModelDefinition.id,
VehicleModelDefinition.marketing_name
).where(
VehicleModelDefinition.make == brand
).distinct(VehicleModelDefinition.marketing_name).order_by(VehicleModelDefinition.marketing_name)
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
result = await db.execute(stmt)
rows = result.all()
return [{"id": r.id, "name": r.marketing_name} for r in rows if r.marketing_name]
@staticmethod
async def get_catalog_years(db: AsyncSession, brand: str, model: str) -> List[int]:
"""Get all distinct years for a given brand and model."""
stmt = select(distinct(VehicleModelDefinition.year_from)).where(
VehicleModelDefinition.make == brand,
VehicleModelDefinition.marketing_name == model,
VehicleModelDefinition.year_from > 0
).order_by(VehicleModelDefinition.year_from.desc())
result = await db.execute(stmt)
years = result.scalars().all()
return [y for y in years if y]
@staticmethod
async def get_catalog_trims(db: AsyncSession, brand: str, model: str, year: Optional[int] = None) -> List[Dict[str, Any]]:
"""Get all trim/variant details for a given brand, model, and optional year."""
stmt = select(VehicleModelDefinition).where(
VehicleModelDefinition.make == brand,
VehicleModelDefinition.marketing_name == model
)
if year and year > 0:
stmt = stmt.where(VehicleModelDefinition.year_from == year)
stmt = stmt.order_by(VehicleModelDefinition.trim_level, VehicleModelDefinition.engine_capacity)
result = await db.execute(stmt)
rows = result.scalars().all()
return [
{
"id": r.id,
"trim_level": r.trim_level or '',
"engine_capacity": r.engine_capacity or 0,
"power_kw": r.power_kw or 0,
"fuel_type": r.fuel_type,
"year_from": r.year_from,
"body_type": r.body_type or '',
}
for r in rows
]
@staticmethod
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
"""
@@ -596,15 +696,13 @@ class AssetService:
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
awarded_at=datetime.utcnow()
earned_at=datetime.utcnow()
)
db.add(user_badge)
await db.flush()
logger = logging.getLogger(__name__)
logger.info(f"Awarded 'First Car' badge to user {user_id}")
except Exception as e:
logger = logging.getLogger(__name__)
logger.error(f"Error awarding first car badge: {e}")
# Don't raise the error - badge awarding shouldn't break vehicle creation

View File

@@ -9,8 +9,8 @@ from sqlalchemy.orm import joinedload
from fastapi.encoders import jsonable_encoder
from fastapi import HTTPException, status
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
from app.models import UserStats
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet, OneTimePassword
from app.models import UserStats, LogSeverity
from app.models.marketplace import Organization, OrganizationMember, OrgType, Branch
from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password, generate_secure_slug
@@ -146,7 +146,7 @@ class AuthService:
# Sentinel Audit Log
await security_service.log_event(
db, user_id=new_user.id, action="USER_REGISTER_LITE",
severity="INFO", target_type="User", target_id=str(new_user.id),
severity=LogSeverity.info, target_type="User", target_id=str(new_user.id),
new_data={"email": user_in.email}
)
@@ -285,8 +285,8 @@ class AuthService:
if kyc_in.region_code:
user.region_code = kyc_in.region_code
# Gamification XP jóváírás
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")
# Gamification XP jóváírás (commit=False, mert a külső metódus kezeli a tranzakciót)
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION", commit=False)
# P2P Referral XP a meghívónak (ha van referred_by_id)
if user.referred_by_id:
@@ -295,7 +295,8 @@ class AuthService:
db,
user_id=user.referred_by_id,
amount=int(p2p_xp),
reason="P2P_REFERRAL_SUCCESS"
reason="P2P_REFERRAL_SUCCESS",
commit=False
)
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
@@ -341,11 +342,15 @@ class AuthService:
user.is_active = True
# Activate person
person_stmt = select(Person).where(Person.user_id == user.id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
person.is_active = True
# Activate person - FIX: Use User.person_id instead of Person.user_id
# Person.user_id is a separate FK that may be NULL
if user.person_id:
person_stmt = select(Person).where(Person.id == user.person_id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
person.is_active = True
# Also set the back-reference so Person.user_id is consistent
person.user_id = user.id
await db.commit()
return user # Return the activated User object
@@ -379,20 +384,23 @@ class AuthService:
))
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
logger.info(f"Email küldés indítása (password reset) ide: {email}...")
result = await email_manager.send_email(
recipient=email, template_key="pwd_reset",
variables={"link": link}, lang=user.preferred_language
)
logger.info(f"Email küldés eredménye (password reset): {result}")
# Ha az email küldés hibát dobott, ne titkoljuk el
if result.get("status") == "error":
logger.error(f"Password reset email FAILED for {email}: {result.get('message')}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Email küldési hiba: {result.get('message')}"
# Mock email sending: log the reset link to console instead of sending real email
logger.info(f"*** MOCK EMAIL *** Password reset for {email}")
logger.info(f"*** MOCK EMAIL *** Reset link: {link}")
logger.info(f"*** MOCK EMAIL *** Token: {token_val}")
# Try to send real email, but don't crash if SMTP is unavailable
try:
result = await email_manager.send_email(
recipient=email, template_key="pwd_reset",
variables={"link": link}, lang=user.preferred_language
)
logger.info(f"Email küldés eredménye (password reset): {result}")
if result and result.get("status") == "error":
logger.warning(f"Password reset email sending failed (non-critical): {result.get('message')}")
except Exception as e:
logger.warning(f"Password reset email sending exception (non-critical): {e}")
await db.commit()
return "success"
@@ -424,7 +432,177 @@ class AuthService:
return True
except HTTPException:
raise
except: return False
except Exception as e:
logger.error(f"Password reset error: {e}")
return False
@staticmethod
async def _generate_otp_code() -> str:
"""Generate a random 6-digit OTP code."""
import random
return f"{random.randint(0, 999999):06d}"
@staticmethod
async def request_account_restore(db: AsyncSession, email: str):
"""
30 napos fiókvisszaállítás: OTP kérés.
Megkeresi a törölt felhasználót az eredeti email alapján.
Ha 30 napon belül van a törlés, generál 6-jegyű kódot.
Ha 30 napon TÚL van, jelzi, hogy új regisztráció szükséges.
"""
try:
# Search for deleted user by checking if email exists in deleted prefix pattern
stmt = select(User).where(
User.email.like(f"%{email}"),
User.is_deleted == True
)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return {"status": "not_found", "message": "Nem található törölt fiók ezzel az email címmel."}
# Check if within 30-day window
if user.deleted_at:
days_since_deletion = (datetime.now(timezone.utc) - user.deleted_at).days
else:
days_since_deletion = 0
if days_since_deletion > 30:
return {
"status": "expired",
"message": "A fiók véglegesen megsemmisült. Kérjük, regisztráljon újra ugyanazzal az email címmel.",
"can_re_register": True,
"original_email": email
}
# Generate 6-digit OTP code
code = await AuthService._generate_otp_code()
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
# Store OTP
otp = OneTimePassword(
email=email,
code=code,
otp_type="restore",
extra_data={"user_id": user.id},
expires_at=expires_at
)
db.add(otp)
await db.commit()
# Mock email sending: log the OTP code to console instead of sending real email
logger.info(f"*** MOCK EMAIL *** OTP Code for {email}: {code}")
logger.info(f"*** MOCK EMAIL *** Expires at: {expires_at.isoformat()}")
return {
"status": "otp_sent",
"message": "Egy 6-jegyű visszaállítási kódot küldtünk az email címére.",
"expires_at": expires_at.isoformat()
}
except Exception as e:
await db.rollback()
logger.error(f"Account restore request error for {email}: {e}")
return {"status": "error", "message": "Hiba történt a visszaállítási kód generálása során."}
@staticmethod
async def verify_account_restore(db: AsyncSession, email: str, code: str):
"""
OTP kód ellenőrzése és fiók visszaállítása.
"""
now = datetime.now(timezone.utc)
# Find valid OTP
stmt = select(OneTimePassword).where(
OneTimePassword.email == email,
OneTimePassword.code == code,
OneTimePassword.otp_type == "restore",
OneTimePassword.is_used == False,
OneTimePassword.expires_at > now
).order_by(OneTimePassword.created_at.desc()).limit(1)
result = await db.execute(stmt)
otp = result.scalar_one_or_none()
if not otp:
return {"status": "invalid", "message": "Érvénytelen vagy lejárt kód."}
# Mark OTP as used
otp.is_used = True
# Find the deleted user
user_id = otp.extra_data.get("user_id") if otp.extra_data else None
if not user_id:
return {"status": "error", "message": "Hiba a fiók azonosításában."}
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return {"status": "error", "message": "Felhasználó nem található."}
# Restore the account
user.is_deleted = False
user.is_active = True
user.deleted_at = None
# Restore original email (remove deleted_ prefix)
# The email format is: deleted_{id}_{timestamp}_{original_email}
if user.email.startswith("deleted_"):
parts = user.email.split("_", 3)
if len(parts) == 4:
user.email = parts[3]
await security_service.log_event(
db, user_id=user.id, action="USER_ACCOUNT_RESTORE",
severity=LogSeverity.info, target_type="User", target_id=str(user.id),
new_data={"restored_via": "otp", "email": user.email}
)
await db.commit()
return {"status": "success", "message": "Fiók sikeresen visszaállítva."}
@staticmethod
async def check_is_last_admin(db: AsyncSession, user_id: int) -> bool:
"""
Ellenőrzi, hogy a felhasználó az egyetlen aktív admin bármelyik szervezetében.
Visszaadja: is_last_admin: bool
"""
try:
from app.models.marketplace.organization import OrganizationMember, OrgUserRole
# Find organizations where user is ADMIN or OWNER
stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == user_id,
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER])
)
result = await db.execute(stmt)
org_ids = [row[0] for row in result.all()]
if not org_ids:
return False
# For each org, check if there are other active admins/owners
for org_id in org_ids:
stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id,
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER]),
OrganizationMember.user_id != user_id
)
result = await db.execute(stmt)
other_admins = result.scalars().all()
if not other_admins:
# This org has no other admin - user is last admin here
return True
return False
except HTTPException:
raise
except Exception as e:
logger.error(f"check_is_last_admin error: {e}")
return False
@staticmethod
async def resend_verification(db: AsyncSession, email: str):
@@ -479,20 +657,44 @@ class AuthService:
@staticmethod
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
""" Felhasználó törlése (Soft-Delete) auditálással. """
"""
Felhasználó törlése (Soft-Delete) auditálással, Person-preserving logikával.
Üzleti szabályok:
1. User: is_active=False, is_deleted=True, deleted_at=utcnow
2. Email átírás: deleted_{id}_{timestamp}_{original_email}
3. Person rekordot NEM bántja (megőrizzük a jövőbeli újraaktiváláshoz)
4. Wallet és Gamification adatok megőrzése (inaktív user miatt freeze)
5. Refresh token-ek érvénytelenítése (JWT-alapú, így a deleted_at middleware ellenőrzi)
"""
stmt = select(User).where(User.id == user_id)
user = (await db.execute(stmt)).scalar_one_or_none()
if not user or user.is_deleted: return False
if not user or user.is_deleted:
return False
# 1. Anonymize email with timestamp
old_email = user.email
user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
user.email = f"deleted_{user.id}_{timestamp}_{old_email}"
# 2. Set soft-delete flags
user.is_deleted = True
user.is_active = False
user.deleted_at = datetime.now(timezone.utc)
# 3. CRITICAL: Do NOT touch Person record
# Person.is_active stays as-is, Person data stays intact
# This allows future re-activation with the same identity
# 4. Security audit log with deleted_at info
await security_service.log_event(
db, user_id=actor_id, action="USER_SOFT_DELETE",
severity="WARNING", target_type="User", target_id=str(user_id),
new_data={"reason": reason}
severity=LogSeverity.warning, target_type="User", target_id=str(user_id),
new_data={
"reason": reason,
"deleted_at": str(user.deleted_at),
"person_preserved": True
}
)
await db.commit()
return True
return True

View File

@@ -18,28 +18,40 @@ class GamificationService:
"""
@staticmethod
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0):
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz. """
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True):
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz.
Args:
commit: If True (default), commits the transaction internally.
Set to False when called from within a larger transaction
(e.g., from AuthService.complete_kyc).
"""
service = GamificationService()
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason)
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit)
async def process_activity(
self,
db: AsyncSession,
user_id: int,
xp_amount: int,
social_amount: int,
reason: str,
is_penalty: bool = False
self,
db: AsyncSession,
user_id: int,
xp_amount: int,
social_amount: int,
reason: str,
is_penalty: bool = False,
commit: bool = True
):
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés. """
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés.
Args:
commit: If True (default), commits the transaction internally.
Set to False when called from within a larger transaction.
"""
try:
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
# Minden paraméter az admin felületről módosítható JSON-ként
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default={
"xp_logic": {"base_xp": 500, "exponent": 1.5},
"penalty_logic": {
"recovery_rate": 0.5,
"recovery_rate": 0.5,
"thresholds": {"level_1": 100, "level_2": 500, "level_3": 1000},
"multipliers": {"L0": 1.0, "L1": 0.5, "L2": 0.1, "L3": 0.0}
},
@@ -98,12 +110,15 @@ class GamificationService:
# 7. NAPLÓZÁS
db.add(PointsLedger(user_id=user_id, points=final_xp, reason=reason))
await db.commit()
await db.refresh(stats)
# Only commit if caller wants us to manage the transaction
if commit:
await db.commit()
await db.refresh(stats)
return stats
except Exception as e:
await db.rollback()
if commit:
await db.rollback()
logger.error(f"Gamification Error for user {user_id}: {e}")
raise e

View File

@@ -66,8 +66,10 @@ class MaintenanceService:
alerts_generated = 0
for asset, telemetry in result.all():
# A Robot 3 által feltöltött gyári adat (pl. 15.000 km)
interval = asset.factory_data.get("oil_change_km") if asset.factory_data else None
last_service_km = asset.factory_data.get("last_service_km", 0)
# A factory_data az AssetCatalog (vehicle_catalog) táblában van
catalog_data = asset.catalog.factory_data if asset.catalog else {}
interval = catalog_data.get("oil_change_km") if catalog_data else None
last_service_km = catalog_data.get("last_service_km", 0)
if interval and telemetry.current_mileage:
next_service_due = last_service_km + interval

View File

@@ -1,213 +1,15 @@
"""
Smart Odometer Service - Adminisztrátor által paraméterezhető kilométeróra becslés.
[DEPRECATED] Smart Odometer Service - VehicleOdometerState model removed.
A szolgáltatás a járművek kilométeróra állását becsüli a költségbejegyzések alapján,
figyelembe véve a rendszerparamétereket (ODOMETER_MIN_DAYS_FOR_AVG, ODOMETER_CONFIDENCE_THRESHOLD).
Ha az admin beállított manuális átlagot (manual_override_avg), akkor azt használja.
This service is deprecated. The VehicleOdometerState model has been removed
from the schema. Odometer tracking is now handled directly via the
OdometerReading table (audit trail) and Asset.current_mileage field.
The predictive odometer state estimation logic has been removed to keep
the entity model clean. Use OdometerReading records directly for
mileage audit trail purposes.
"""
from datetime import datetime, timedelta
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from sqlalchemy.orm import selectinload
from app.models.vehicle import VehicleOdometerState, VehicleCost
from app.models.system import SystemParameter
from app.models import VehicleModelDefinition
class OdometerService:
"""Kilométeróra becslési szolgáltatás adminisztrációs kontrollal."""
@staticmethod
async def get_system_param(db: AsyncSession, key: str, default_value):
"""Rendszerparaméter lekérése a system.system_parameters táblából."""
stmt = select(SystemParameter).where(
SystemParameter.key == key,
SystemParameter.scope_level == 'global',
SystemParameter.is_active == True
)
result = await db.execute(stmt)
param = result.scalars().first()
if param and 'value' in param.value:
return param.value['value']
return default_value
@staticmethod
async def update_vehicle_stats(db: AsyncSession, vehicle_id: int) -> Optional[VehicleOdometerState]:
"""
Frissíti a jármű kilométeróra statisztikáit.
Algoritmus:
1. Ha van manual_override_avg, használja azt.
2. Különben számol átlagot a vehicle.costs bejegyzésekből.
3. Figyelembe veszi az ODOMETER_MIN_DAYS_FOR_AVG paramétert.
4. Kiszámolja a confidence_score-t a minták száma alapján.
5. Frissíti vagy létrehozza a VehicleOdometerState rekordot.
"""
# Rendszerparaméterek lekérése
min_days = await OdometerService.get_system_param(db, 'ODOMETER_MIN_DAYS_FOR_AVG', 7)
confidence_threshold = await OdometerService.get_system_param(db, 'ODOMETER_CONFIDENCE_THRESHOLD', 0.5)
# Meglévő állapot lekérése
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
result = await db.execute(stmt)
odometer_state = result.scalars().first()
# Költségbejegyzések lekérése dátum és odometer szerint rendezve
cost_stmt = select(VehicleCost).where(
VehicleCost.vehicle_id == vehicle_id,
VehicleCost.odometer.isnot(None)
).order_by(VehicleCost.date.asc())
cost_result = await db.execute(cost_stmt)
costs = cost_result.scalars().all()
if not costs:
# Nincs adat, alapértelmezett értékek
if odometer_state:
odometer_state.daily_avg_distance = 0
odometer_state.confidence_score = 0
odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer
else:
# Jármű alapadatok lekérése
vehicle_stmt = select(VehicleModelDefinition).where(VehicleModelDefinition.id == vehicle_id)
vehicle_result = await db.execute(vehicle_stmt)
vehicle = vehicle_result.scalars().first()
if not vehicle:
return None
odometer_state = VehicleOdometerState(
vehicle_id=vehicle_id,
last_recorded_odometer=0,
last_recorded_date=datetime.now(),
daily_avg_distance=0,
estimated_current_odometer=0,
confidence_score=0,
manual_override_avg=None
)
db.add(odometer_state)
await db.commit()
await db.refresh(odometer_state)
return odometer_state
# Utolsó rögzített adatok
last_cost = costs[-1]
last_recorded_odometer = last_cost.odometer
last_recorded_date = last_cost.date
# Manuális átlag ellenőrzése
if odometer_state and odometer_state.manual_override_avg is not None:
daily_avg = float(odometer_state.manual_override_avg)
confidence = 1.0 # Manuális beállítás esetén teljes bizalom
else:
# Átlag számítása a költségbejegyzésekből
valid_pairs = []
for i in range(1, len(costs)):
prev = costs[i-1]
curr = costs[i]
days_diff = (curr.date - prev.date).days
km_diff = curr.odometer - prev.odometer
if days_diff >= min_days and km_diff > 0:
daily_avg = km_diff / days_diff
valid_pairs.append((daily_avg, days_diff))
if valid_pairs:
# Súlyozott átlag (hosszabb időszakok nagyobb súllyal)
total_weighted = sum(avg * weight for avg, weight in valid_pairs)
total_days = sum(weight for _, weight in valid_pairs)
daily_avg = total_weighted / total_days if total_days > 0 else 0
# Confidence score: érvényes párok száma / összes lehetséges párok
confidence = min(len(valid_pairs) / max(len(costs) - 1, 1), 1.0)
else:
daily_avg = 0
confidence = 0
# Becsült jelenlegi kilométer
days_since_last = (datetime.now(last_recorded_date.tzinfo) - last_recorded_date).days
estimated_odometer = last_recorded_odometer + (daily_avg * max(days_since_last, 0))
# Állapot frissítése vagy létrehozása
if odometer_state:
odometer_state.last_recorded_odometer = last_recorded_odometer
odometer_state.last_recorded_date = last_recorded_date
odometer_state.daily_avg_distance = daily_avg
odometer_state.estimated_current_odometer = estimated_odometer
odometer_state.confidence_score = confidence
else:
odometer_state = VehicleOdometerState(
vehicle_id=vehicle_id,
last_recorded_odometer=last_recorded_odometer,
last_recorded_date=last_recorded_date,
daily_avg_distance=daily_avg,
estimated_current_odometer=estimated_odometer,
confidence_score=confidence,
manual_override_avg=None
)
db.add(odometer_state)
await db.commit()
await db.refresh(odometer_state)
return odometer_state
@staticmethod
async def get_estimated_odometer(db: AsyncSession, vehicle_id: int) -> Tuple[Optional[float], float]:
"""
Visszaadja a jármű becsült jelenlegi kilométeróra állását és a bizalom pontszámot.
Returns:
Tuple[estimated_odometer, confidence_score]
"""
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
result = await db.execute(stmt)
odometer_state = result.scalars().first()
if not odometer_state:
# Ha nincs állapot, frissítsük
odometer_state = await OdometerService.update_vehicle_stats(db, vehicle_id)
if not odometer_state:
return None, 0.0
return odometer_state.estimated_current_odometer, odometer_state.confidence_score
@staticmethod
async def set_manual_override(db: AsyncSession, vehicle_id: int, daily_avg: Optional[float]) -> Optional[VehicleOdometerState]:
"""
Adminisztrátori manuális átlag beállítása.
Args:
daily_avg: Napi átlagos kilométer (km/nap). Ha None, törli a manuális beállítást.
"""
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
result = await db.execute(stmt)
odometer_state = result.scalars().first()
if not odometer_state:
# Ha nincs állapot, hozzuk létre
odometer_state = VehicleOdometerState(
vehicle_id=vehicle_id,
last_recorded_odometer=0,
last_recorded_date=datetime.now(),
daily_avg_distance=0,
estimated_current_odometer=0,
confidence_score=0,
manual_override_avg=daily_avg
)
db.add(odometer_state)
else:
odometer_state.manual_override_avg = daily_avg
# Frissítsük a becslést a manuális átlaggal
if daily_avg is not None:
days_since_last = (datetime.now(odometer_state.last_recorded_date.tzinfo) - odometer_state.last_recorded_date).days
odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer + (daily_avg * max(days_since_last, 0))
odometer_state.confidence_score = 1.0
await db.commit()
await db.refresh(odometer_state)
return odometer_state
import logging
logger = logging.getLogger(__name__)
logger.warning("OdometerService is deprecated. VehicleOdometerState model has been removed.")

View File

@@ -0,0 +1,162 @@
# /opt/docker/dev/service_finder/backend/app/services/subscription_service.py
"""
Subscription & Entitlement Service — Premium költségkategória láthatóság.
A szolgáltatás a felhasználó előfizetési szintje (tier) alapján szabályozza,
hogy mely költségkategóriák (CostCategory) érhetők el. A tier-ek hierarchikusak:
free < premium < enterprise.
Hierarchia:
- free: alap kategóriák (üzemanyag, szerviz, gumik, biztosítás, adók)
- premium: free + útdíj/parkolás, ápolás, egyéb
- enterprise: premium + minden (nincs korlátozás)
"""
from __future__ import annotations
import logging
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.vehicle.vehicle import CostCategory
logger = logging.getLogger(__name__)
# ── Subscription Feature Matrix ──────────────────────────────────────────────
# Minden funkció/kategória minimális tier szintje.
# A tier-ek: free < premium < enterprise
TIER_HIERARCHY = {
"free": 0,
"premium": 1,
"enterprise": 2,
}
SUBSCRIPTION_FEATURES: dict[str, str] = {
# Feature -> minimum required tier
"cost_category:fuel": "free",
"cost_category:service": "free",
"cost_category:tires": "free",
"cost_category:insurance": "free",
"cost_category:taxes": "free",
"cost_category:toll_parking": "premium",
"cost_category:cleaning": "premium",
"cost_category:other": "premium",
"cost_category:all": "enterprise",
# Feature flags
"export_csv": "free",
"analytics_tco": "premium",
"analytics_detailed": "enterprise",
"api_access": "free",
"multi_vehicle": "free",
"unlimited_vehicles": "premium",
}
class SubscriptionService:
"""
Entitlement Mátrix — előfizetési szintek kezelése.
Metódusok:
get_user_tier(db, user_id) -> str
can_access_feature(tier, feature_key) -> bool
get_visible_categories(db, tier) -> list[CostCategory]
require_tier(tier, required_feature_or_min_tier) -> bool
"""
@staticmethod
def _tier_to_int(tier: str) -> int:
"""Tier string konvertálása numerikus értékre a hierarchiában."""
return TIER_HIERARCHY.get(tier.lower(), 0)
@staticmethod
async def get_user_tier(db: AsyncSession, user_id: int) -> str:
"""
Visszaadja a felhasználó aktuális előfizetési szintjét.
Jelenlegi implementáció: minden user 'free' tier-t kap.
A jövőben ez a subscription/org táblákból lesz lekérdezve.
"""
# TODO: Éles implementáció — subscription tábla lekérdezése
# TODO: Szervezeti tier felülírás (pl. enterprise org)
return "free"
@staticmethod
def can_access_feature(user_tier: str, feature_key: str) -> bool:
"""
Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz.
Args:
user_tier: A felhasználó tier szintje (free/premium/enterprise)
feature_key: A funkció kulcsa (pl. 'cost_category:fuel', 'analytics_tco')
Returns:
bool: True ha hozzáfér, False ha nem
"""
required_tier = SUBSCRIPTION_FEATURES.get(feature_key)
if required_tier is None:
# Ismeretlen feature — alapértelmezetten tiltva
logger.warning(f"Unknown feature key: {feature_key}")
return False
user_level = SubscriptionService._tier_to_int(user_tier)
required_level = SubscriptionService._tier_to_int(required_tier)
return user_level >= required_level
@staticmethod
async def get_visible_categories(
db: AsyncSession,
user_tier: str
) -> List[CostCategory]:
"""
Visszaadja azokat a CostCategory-ket, amelyek a user tier-jének megfelelnek.
Szűrési logika:
- A kategória min_tier mezője alapján szűrünk.
- Ha a kategória min_tier értéke kisebb vagy egyenlő, mint a user tier,
akkor látható.
- Ha a min_tier nincs beállítva (None), akkor 'free'-ként kezeljük.
Args:
db: Adatbázis session
user_tier: A felhasználó tier szintje
Returns:
List[CostCategory]: A látható kategóriák listája
"""
user_level = SubscriptionService._tier_to_int(user_tier)
# Lekérjük az összes kategóriát
stmt = select(CostCategory).order_by(CostCategory.id)
result = await db.execute(stmt)
all_categories = result.scalars().all()
# Szűrés a min_tier alapján
visible = []
for cat in all_categories:
cat_tier = getattr(cat, "min_tier", "free") or "free"
cat_level = SubscriptionService._tier_to_int(cat_tier)
if user_level >= cat_level:
visible.append(cat)
return visible
@staticmethod
def require_tier(user_tier: str, required_feature_or_min_tier: str) -> bool:
"""
Egyszerű tier ellenőrzés — dependency check-hez.
Args:
user_tier: A felhasználó tier szintje
required_feature_or_min_tier: Vagy egy feature kulcs, vagy egy tier név
Returns:
bool: True ha megfelel, False ha nem
"""
# Először feature kulcsként próbáljuk
if required_feature_or_min_tier in SUBSCRIPTION_FEATURES:
return SubscriptionService.can_access_feature(
user_tier, required_feature_or_min_tier
)
# Ha nem feature kulcs, akkor tier névként kezeljük
user_level = SubscriptionService._tier_to_int(user_tier)
required_level = SubscriptionService._tier_to_int(required_feature_or_min_tier)
return user_level >= required_level

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Test script: DELETE /api/v1/users/me
Direct call to FastAPI inside the container (bypasses proxy).
Uses tester_pro2@profibot.hu credentials.
"""
import asyncio
import httpx
import sys
BASE_URL = "http://localhost:8000/api/v1"
async def login_and_get_token():
"""Login as tester_pro2 and get JWT token using OAuth2 form."""
async with httpx.AsyncClient() as client:
# OAuth2PasswordRequestForm expects form data with username and password fields
resp = await client.post(
f"{BASE_URL}/auth/login",
data={
"username": "tester_pro2@profibot.hu",
"password": "TesztElek99!",
"remember_me": "false"
}
)
if resp.status_code != 200:
print(f"❌ LOGIN FAILED: {resp.status_code}")
print(f" Response: {resp.text}")
return None
data = resp.json()
token = data.get("access_token")
print(f"✅ LOGIN OK - Token obtained (first 50 chars): {token[:50]}...")
return token
async def test_delete_me(token):
"""Send DELETE /me request directly to FastAPI."""
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient() as client:
print(f"\n🔍 Sending DELETE {BASE_URL}/users/me ...")
resp = await client.delete(
f"{BASE_URL}/users/me",
headers=headers
)
print(f" Status: {resp.status_code}")
print(f" Response: {resp.text[:500]}")
if resp.status_code == 200:
print("✅ DELETE /me SUCCESS!")
return True
elif resp.status_code == 405:
print("❌ 405 Method Not Allowed - The endpoint is not registered for DELETE")
return False
elif resp.status_code == 404:
print("❌ 404 Not Found - The endpoint path is wrong")
return False
else:
print(f"⚠️ Unexpected status code")
return False
async def main():
print("=" * 60)
print("🧪 TEST: DELETE /api/v1/users/me")
print("=" * 60)
# Step 1: Login
token = await login_and_get_token()
if not token:
sys.exit(1)
# Step 2: Test DELETE
success = await test_delete_me(token)
if success:
print("\n✅ TEST PASSED: DELETE /me works correctly!")
else:
print("\n❌ TEST FAILED: DELETE /me returned an error!")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,31 @@
"""Check and create the CheckConstraint for vehicle.assets table."""
from app.database import engine_sync
from sqlalchemy import text
with engine_sync.connect() as conn:
# Check if constraint exists
result = conn.execute(
text("""
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'vehicle.assets'::regclass
AND contype = 'c'
""")
).fetchall()
print('Constraints on vehicle.assets:')
for row in result:
print(f' {row[0]}: {row[1]}')
if not result:
print('No CheckConstraint found - creating it now...')
conn.execute(
text("""
ALTER TABLE vehicle.assets
ADD CONSTRAINT ck_asset_vin_or_plate_required
CHECK (vin IS NOT NULL OR license_plate IS NOT NULL)
""")
)
conn.commit()
print('CheckConstraint created successfully!')
else:
print('CheckConstraint already exists.')

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""
Repair script for user gy.krisztina76@gmail.com (ID 100).
This user went through light registration + email verification but the KYC
completion failed because of two bugs:
1. verify_email() used Person.user_id (NULL) instead of User.person_id
2. GamificationService.award_points() did internal commit conflicting with outer transaction
This script manually creates all the missing infrastructure that should have
been created by complete_kyc():
- Activate Person
- Create Organization (personal)
- Create Branch
- Create OrganizationMember
- Create Wallet
- Create UserStats
- Set user.scope_id
"""
import asyncio
import logging
import sys
from datetime import datetime, timezone
# Add the backend directory to the path
sys.path.insert(0, '/app/backend')
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
# Setup logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger("repair_kyc")
# Database URL from settings
DATABASE_URL = settings.DATABASE_URL
logger.info(f"Using database: {DATABASE_URL}")
# Create engine and session
engine = create_async_engine(DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def repair_user():
async with AsyncSessionLocal() as db:
try:
# 1. Find the user
from app.models.identity.identity import User, Person, Wallet
from app.models.marketplace.organization import Organization, OrganizationMember, Branch
from app.models.gamification.gamification import UserStats, PointsLedger
stmt = select(User).where(User.email == 'gy.krisztina76@gmail.com')
user = (await db.execute(stmt)).scalar_one_or_none()
if not user:
logger.error("User not found!")
return
logger.info(f"Found user: ID={user.id}, email={user.email}, person_id={user.person_id}")
logger.info(f" is_active={user.is_active}, scope_id={user.scope_id}")
# 2. Find and activate Person
person_stmt = select(Person).where(Person.id == user.person_id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
logger.info(f"Found Person: ID={person.id}, is_active={person.is_active}, user_id={person.user_id}")
person.is_active = True
person.user_id = user.id
logger.info(f" -> Activated Person, set user_id={user.id}")
else:
logger.warning(f"Person with id={user.person_id} not found!")
# 3. Check if Organization already exists
org_stmt = select(Organization).where(Organization.owner_id == user.id)
existing_org = (await db.execute(org_stmt)).scalar_one_or_none()
if existing_org:
logger.info(f"Organization already exists: ID={existing_org.id}, name={existing_org.name}")
new_org = existing_org
else:
# Create personal Organization
from app.core.security import generate_secure_slug
org_name = f"{person.last_name} Flotta" if person else "Personal Flotta"
folder_slug = generate_secure_slug(12)
new_org = Organization(
name=org_name,
full_name=org_name,
display_name=org_name,
folder_slug=folder_slug,
org_type="individual",
status="ACTIVE",
is_deleted=False,
is_active=True,
is_verified=False,
is_anonymized=False,
default_currency="HUF",
country_code="HU",
language="hu",
subscription_plan="FREE",
base_asset_limit=3,
purchased_extra_slots=0,
notification_settings={},
external_integration_config={},
visual_settings={},
is_ownership_transferable=False,
owner_id=user.id,
first_registered_at=datetime.now(timezone.utc),
current_lifecycle_started_at=datetime.now(timezone.utc),
lifecycle_index=1,
created_at=datetime.now(timezone.utc)
)
db.add(new_org)
await db.flush()
logger.info(f"Created Organization: ID={new_org.id}, name={org_name}")
# 4. Check if Branch already exists
branch_stmt = select(Branch).where(Branch.organization_id == new_org.id)
existing_branch = (await db.execute(branch_stmt)).scalar_one_or_none()
if existing_branch:
logger.info(f"Branch already exists: ID={existing_branch.id}, name={existing_branch.name}")
else:
import uuid
branch = Branch(
id=uuid.uuid4(),
organization_id=new_org.id,
name="Home Base",
is_main=True,
opening_hours={},
branch_rating=0.0,
status="ACTIVE",
is_deleted=False,
created_at=datetime.now(timezone.utc)
)
db.add(branch)
await db.flush()
logger.info(f"Created Branch: ID={branch.id}")
# 5. Check if OrganizationMember already exists
member_stmt = select(OrganizationMember).where(
and_(
OrganizationMember.organization_id == new_org.id,
OrganizationMember.user_id == user.id
)
)
existing_member = (await db.execute(member_stmt)).scalar_one_or_none()
if existing_member:
logger.info(f"OrganizationMember already exists: ID={existing_member.id}")
else:
member = OrganizationMember(
organization_id=new_org.id,
user_id=user.id,
role="OWNER",
permissions={},
is_permanent=True,
is_verified=True
)
db.add(member)
await db.flush()
logger.info(f"Created OrganizationMember: user_id={user.id}, org_id={new_org.id}")
# 6. Check if Wallet already exists
wallet_stmt = select(Wallet).where(Wallet.user_id == user.id)
existing_wallet = (await db.execute(wallet_stmt)).scalar_one_or_none()
if existing_wallet:
logger.info(f"Wallet already exists: ID={existing_wallet.id}")
else:
wallet = Wallet(
user_id=user.id,
earned_credits=0,
purchased_credits=0,
service_coins=0,
currency="HUF"
)
db.add(wallet)
await db.flush()
logger.info(f"Created Wallet for user_id={user.id}")
# 7. Check if UserStats already exists
stats_stmt = select(UserStats).where(UserStats.user_id == user.id)
existing_stats = (await db.execute(stats_stmt)).scalar_one_or_none()
if existing_stats:
logger.info(f"UserStats already exists: user_id={existing_stats.user_id}")
else:
stats = UserStats(
user_id=user.id,
total_xp=500, # KYC bonus
social_points=0,
current_level=1,
penalty_points=0,
restriction_level=0,
updated_at=datetime.now(timezone.utc),
penalty_quota_remaining=0,
places_discovered=0,
places_validated=0
)
db.add(stats)
# Add PointsLedger entry for KYC bonus
ledger = PointsLedger(
user_id=user.id,
points=500,
reason="KYC_VERIFICATION_REPAIR"
)
db.add(ledger)
await db.flush()
logger.info(f"Created UserStats and PointsLedger for user_id={user.id}")
# 8. Set user.scope_id if not set
if not user.scope_id:
user.scope_id = str(new_org.id)
logger.info(f"Set user.scope_id = {new_org.id}")
# 9. Ensure user.is_active is True
if not user.is_active:
user.is_active = True
logger.info("Activated user")
await db.commit()
logger.info("=" * 50)
logger.info("REPAIR COMPLETED SUCCESSFULLY!")
logger.info("=" * 50)
# Verify
logger.info("\n--- Verification ---")
logger.info(f"User: ID={user.id}, is_active={user.is_active}, scope_id={user.scope_id}")
if person:
logger.info(f"Person: ID={person.id}, is_active={person.is_active}, user_id={person.user_id}")
logger.info(f"Organization: ID={new_org.id}, name={new_org.name}")
except Exception as e:
await db.rollback()
logger.error(f"Repair failed: {e}")
import traceback
traceback.print_exc()
finally:
await db.close()
await engine.dispose()
if __name__ == "__main__":
asyncio.run(repair_user())

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
Seed script: CostCategory min_tier beállítása.
A szkript beállítja a költségkategóriák min_tier mezőjét a kód alapján.
A tier-ek hierarchikusak: free < premium < enterprise.
Kategória tier hozzárendelés:
- FUEL, SERVICE, TIRES, INSURANCE, TAXES -> free
- TOLL_PARKING, CLEANING, OTHER -> premium
- (minden más) -> enterprise
Használat:
docker compose exec sf_api python -m backend.scripts.seed_cost_category_tiers
"""
import asyncio
import logging
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select, text
from app.core.config import settings
from app.models.vehicle.vehicle import CostCategory
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Tier mapping by category code
# A fő kategóriák (parent_id IS NULL) tier beállításai.
# Az alkategóriák (pl. FUEL_PETROL, MAINT_SERVICE) öröklik a szülő tier-jét.
CATEGORY_TIER_MAP = {
"FUEL": "free", # Üzemanyag / Töltés
"MAINTENANCE": "free", # Szerviz & Karbantartás
"TIRES": "free", # Gumiabroncsok
"INSURANCE": "free", # Biztosítás
"TAX": "free", # Adók
"FEES": "premium", # Útdíj & Parkolás
"ADMIN": "premium", # Hatósági díjak
"FINANCE": "premium", # Finanszírozás
"CLEANING": "premium", # Ápolás & Kozmetika
"OTHER": "premium", # Egyéb
}
# Default tier for categories not in the map
DEFAULT_TIER = "enterprise"
async def seed_cost_category_tiers():
"""Beállítja a min_tier értékeket az összes CostCategory rekordra."""
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
try:
# Lekérjük az összes kategóriát
stmt = select(CostCategory).order_by(CostCategory.id)
result = await db.execute(stmt)
categories = result.scalars().all()
if not categories:
logger.warning("❌ Nincsenek CostCategory rekordok az adatbázisban!")
return
updated_count = 0
for cat in categories:
# Meghatározzuk a tier-t a kód alapján
new_tier = CATEGORY_TIER_MAP.get(cat.code, DEFAULT_TIER)
# Csak akkor frissítjük, ha még nincs beállítva, vagy változott
if cat.min_tier != new_tier:
cat.min_tier = new_tier
updated_count += 1
logger.info(
f" ✅ [{cat.code:15s}] {cat.name:30s} -> tier={new_tier}"
)
if updated_count > 0:
await db.commit()
logger.info(f"\n{updated_count} kategória tier beállítva.")
else:
logger.info("\n✅ Minden kategória tier már be van állítva.")
# Összesítő
logger.info("\n📊 Tier összesítő:")
for cat in categories:
logger.info(f" [{cat.min_tier:10s}] {cat.code:15s} - {cat.name}")
except Exception as e:
await db.rollback()
logger.error(f"❌ Hiba a seedelés során: {e}")
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed_cost_category_tiers())

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
E2E Test: Light Registration → Email Verification → KYC Completion
Verifies that all necessary database relationships are created after KYC.
"""
import asyncio
import httpx
import uuid
import sys
from datetime import datetime, timezone
sys.path.insert(0, '/app')
from sqlalchemy import text
from app.db.session import AsyncSessionLocal
BASE_URL = "http://localhost:8000/api/v1"
TEST_EMAIL = f"test_kyc_{uuid.uuid4().hex[:8]}@example.com"
TEST_PASSWORD = "TestPass123!"
async def get_verification_token(email: str):
async with AsyncSessionLocal() as session:
result = await session.execute(
text("SELECT token FROM identity.verification_tokens vt "
"JOIN identity.users u ON u.id = vt.user_id "
"WHERE u.email = :email ORDER BY vt.created_at DESC LIMIT 1"),
{"email": email}
)
return result.scalar()
async def check_infrastructure(user_id: int):
"""Check all required infrastructure objects exist."""
async with AsyncSessionLocal() as session:
checks = {}
# Person
r = await session.execute(
text("SELECT id, is_active, user_id FROM identity.persons WHERE id = "
"(SELECT person_id FROM identity.users WHERE id = :uid)"),
{"uid": user_id}
)
person = r.fetchone()
checks["person"] = person is not None
if person:
checks["person_active"] = person[1] == True
checks["person_user_id"] = person[2] == user_id
# Organization
r = await session.execute(
text("SELECT id, name, org_type FROM fleet.organizations WHERE owner_id = :uid"),
{"uid": user_id}
)
org = r.fetchone()
checks["organization"] = org is not None
if org:
checks["org_id"] = org[0]
# Branch
if org:
r = await session.execute(
text("SELECT id, name, is_main FROM fleet.branches WHERE organization_id = :oid"),
{"oid": org[0]}
)
branch = r.fetchone()
checks["branch"] = branch is not None
if branch:
checks["branch_main"] = branch[2] == True
# OrganizationMember
r = await session.execute(
text("SELECT id, role FROM fleet.organization_members WHERE user_id = :uid"),
{"uid": user_id}
)
member = r.fetchone()
checks["org_member"] = member is not None
if member:
checks["org_member_role"] = member[1] == "OWNER"
# Wallet
r = await session.execute(
text("SELECT id FROM identity.wallets WHERE user_id = :uid"),
{"uid": user_id}
)
checks["wallet"] = r.fetchone() is not None
# UserStats
r = await session.execute(
text("SELECT user_id, total_xp, current_level FROM gamification.user_stats WHERE user_id = :uid"),
{"uid": user_id}
)
stats = r.fetchone()
checks["user_stats"] = stats is not None
if stats:
checks["user_stats_xp"] = stats[1] >= 500 # KYC bonus
checks["user_stats_level"] = stats[2] >= 1
# User scope_id
r = await session.execute(
text("SELECT scope_id FROM identity.users WHERE id = :uid"),
{"uid": user_id}
)
scope = r.fetchone()
checks["scope_id_set"] = scope is not None and scope[0] is not None
return checks
async def run_test():
passed = 0
failed = 0
print("=" * 60)
print(f"KYC E2E TEST - Email: {TEST_EMAIL}")
print("=" * 60)
async with httpx.AsyncClient() as client:
# --- STEP 1: LITE REGISTRATION ---
print("\n--- 1. LITE REGISTRATION ---")
reg_payload = {
"email": TEST_EMAIL,
"password": TEST_PASSWORD,
"first_name": "KYC",
"last_name": "TestUser",
"region_code": "HU"
}
resp = await client.post(f"{BASE_URL}/auth/register", json=reg_payload)
print(f"Status: {resp.status_code}")
if resp.status_code == 201:
print("✓ Registration successful")
passed += 1
else:
print(f"✗ Registration failed: {resp.text}")
failed += 1
return passed, failed
# --- STEP 2: EMAIL VERIFICATION ---
print("\n--- 2. EMAIL VERIFICATION ---")
token = await get_verification_token(TEST_EMAIL)
if not token:
print("✗ No verification token found!")
failed += 1
return passed, failed
print(f"Token: {token}")
verify_resp = await client.post(f"{BASE_URL}/auth/verify-email", json={"token": str(token)})
print(f"Status: {verify_resp.status_code}")
if verify_resp.status_code == 200:
print("✓ Email verification successful")
passed += 1
else:
print(f"✗ Verification failed: {verify_resp.text}")
failed += 1
return passed, failed
# --- STEP 3: LOGIN TO GET TOKEN ---
print("\n--- 3. LOGIN ---")
login_data = {
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"grant_type": "password"
}
login_resp = await client.post(f"{BASE_URL}/auth/login", data=login_data)
print(f"Status: {login_resp.status_code}")
if login_resp.status_code == 200:
print("✓ Login successful")
passed += 1
access_token = login_resp.json().get("access_token")
else:
print(f"✗ Login failed: {login_resp.text}")
failed += 1
return passed, failed
headers = {"Authorization": f"Bearer {access_token}"}
# --- STEP 4: GET USER INFO ---
print("\n--- 4. GET USER INFO ---")
me_resp = await client.get(f"{BASE_URL}/auth/me", headers=headers)
print(f"Status: {me_resp.status_code}")
if me_resp.status_code == 200:
user_data = me_resp.json()
user_id = user_data.get("id")
print(f"User ID: {user_id}")
print(f"User data: {user_data}")
passed += 1
else:
print(f"✗ Failed to get user info: {me_resp.text}")
failed += 1
return passed, failed
# --- STEP 5: COMPLETE KYC ---
print("\n--- 5. COMPLETE KYC ---")
kyc_payload = {
"first_name": "KYC",
"last_name": "TestUser",
"birth_date": "1990-01-15",
"birth_place": "Budapest",
"mothers_last_name": "Anyuka",
"mothers_first_name": "Mária",
"phone_number": "+36301234567",
"address_zip": "1011",
"address_city": "Budapest",
"address_street_name": "Fő utca",
"address_street_type": "utca",
"address_house_number": "1",
"preferred_currency": "HUF",
"region_code": "HU",
"identity_docs": {
"PASSPORT": {"number": "AB123456", "expiry_date": "2030-01-01"}
}
}
kyc_resp = await client.post(f"{BASE_URL}/auth/complete-kyc", json=kyc_payload, headers=headers)
print(f"Status: {kyc_resp.status_code}")
print(f"Response: {kyc_resp.text}")
if kyc_resp.status_code == 200:
print("✓ KYC completion successful")
passed += 1
else:
print(f"✗ KYC failed: {kyc_resp.text}")
failed += 1
return passed, failed
# --- STEP 6: VERIFY INFRASTRUCTURE ---
print("\n--- 6. VERIFY INFRASTRUCTURE ---")
checks = await check_infrastructure(user_id)
all_ok = True
for check_name, check_result in checks.items():
status = "" if check_result else ""
if not check_result:
all_ok = False
print(f" {status} {check_name}: {check_result}")
if all_ok:
print("✓ All infrastructure checks passed!")
passed += 1
else:
print(f"✗ Some infrastructure checks failed!")
failed += 1
print("\n" + "=" * 60)
print(f"RESULTS: {passed} passed, {failed} failed")
print("=" * 60)
return passed, failed
if __name__ == "__main__":
p, f = asyncio.run(run_test())
sys.exit(1 if f > 0 else 0)

View File

@@ -0,0 +1,269 @@
"""
Test script for:
1. Asset VIN/License Plate OR-OR validation (Pydantic schemas)
2. Person-Preserving Soft Delete logic
"""
import asyncio
import sys
from datetime import datetime, timezone
from typing import Optional
# ─── Test 1: Pydantic Schema Validation ───────────────────────────────────────
print("=" * 70)
print("🧪 TEST 1: Asset Pydantic OR-OR Validation")
print("=" * 70)
from pydantic import ValidationError
sys.path.insert(0, "/app")
from app.schemas.asset import AssetCreate, AssetUpdate
def test_asset_create():
"""Test AssetCreate schema validation."""
passed = 0
failed = 0
# Test 1.1: Both vin and plate provided - should pass
try:
a = AssetCreate(license_plate="ABC-123", vin="WDB1234567890ABCD")
assert a.license_plate == "ABC-123"
assert a.vin == "WDB1234567890ABCD"
print(" ✅ 1.1 Both vin and plate provided: PASS")
passed += 1
except Exception as e:
print(f" ❌ 1.1 Both vin and plate provided: FAIL - {e}")
failed += 1
# Test 1.2: Only license_plate - should pass
try:
a = AssetCreate(license_plate="ABC-123")
assert a.license_plate == "ABC-123"
assert a.vin is None
print(" ✅ 1.2 Only license_plate: PASS")
passed += 1
except Exception as e:
print(f" ❌ 1.2 Only license_plate: FAIL - {e}")
failed += 1
# Test 1.3: Only vin - should pass
try:
a = AssetCreate(vin="WDB1234567890ABCD")
assert a.vin == "WDB1234567890ABCD"
assert a.license_plate is None
print(" ✅ 1.3 Only vin: PASS")
passed += 1
except Exception as e:
print(f" ❌ 1.3 Only vin: FAIL - {e}")
failed += 1
# Test 1.4: Neither vin nor plate - should FAIL
try:
a = AssetCreate()
print(f" ❌ 1.4 Neither vin nor plate: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 1.4 Neither vin nor plate: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 1.4 Neither vin nor plate: FAIL - {e}")
failed += 1
# Test 1.5: Empty string license_plate - should be converted to None, then fail
try:
a = AssetCreate(license_plate="")
print(f" ❌ 1.5 Empty string license_plate: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 1.5 Empty string license_plate: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 1.5 Empty string license_plate: FAIL - {e}")
failed += 1
# Test 1.6: Empty string vin - should be converted to None, then fail
try:
a = AssetCreate(vin=" ")
print(f" ❌ 1.6 Whitespace-only vin: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 1.6 Whitespace-only vin: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 1.6 Whitespace-only vin: FAIL - {e}")
failed += 1
# Test 1.7: Both empty strings - should fail
try:
a = AssetCreate(license_plate="", vin="")
print(f" ❌ 1.7 Both empty strings: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 1.7 Both empty strings: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 1.7 Both empty strings: FAIL - {e}")
failed += 1
return passed, failed
def test_asset_update():
"""Test AssetUpdate schema validation."""
passed = 0
failed = 0
# Test 2.1: Update with only license_plate - should pass
try:
a = AssetUpdate(license_plate="NEW-999")
assert a.license_plate == "NEW-999"
print(" ✅ 2.1 Update with only license_plate: PASS")
passed += 1
except Exception as e:
print(f" ❌ 2.1 Update with only license_plate: FAIL - {e}")
failed += 1
# Test 2.2: Update with only vin - should pass
try:
a = AssetUpdate(vin="NEWVIN123456789")
assert a.vin == "NEWVIN123456789"
print(" ✅ 2.2 Update with only vin: PASS")
passed += 1
except Exception as e:
print(f" ❌ 2.2 Update with only vin: FAIL - {e}")
failed += 1
# Test 2.3: Update with both set to None explicitly - should FAIL
try:
a = AssetUpdate(license_plate=None, vin=None)
print(f" ❌ 2.3 Both set to None: FAIL (should have raised ValueError)")
failed += 1
except ValidationError as e:
print(f" ✅ 2.3 Both set to None: PASS (correctly rejected)")
passed += 1
except Exception as e:
print(f" ❌ 2.3 Both set to None: FAIL - {e}")
failed += 1
# Test 2.4: Empty update (no fields) - should pass (exclude_unset)
try:
a = AssetUpdate()
print(f" ✅ 2.4 Empty update (no fields): PASS")
passed += 1
except Exception as e:
print(f" ❌ 2.4 Empty update (no fields): FAIL - {e}")
failed += 1
return passed, failed
# Run Pydantic tests
p1, f1 = test_asset_create()
p2, f2 = test_asset_update()
print(f"\n📊 Asset Validation Results: {p1+p2} passed, {f1+f2} failed out of {p1+p2+f1+f2} tests")
# ─── Test 2: Soft Delete Logic ────────────────────────────────────────────────
print("\n" + "=" * 70)
print("🧪 TEST 2: Person-Preserving Soft Delete Logic")
print("=" * 70)
async def test_soft_delete():
"""Test the soft_delete_user method via direct DB calls."""
from app.database import AsyncSessionLocal
from app.models.identity.identity import User, Person
from app.services.auth_service import AuthService
from app.services.security_service import security_service
from sqlalchemy import select, text
async with AsyncSessionLocal() as db:
try:
# Find a test user (use first active user)
result = await db.execute(
select(User).where(User.is_deleted == False).limit(1)
)
user = result.scalar_one_or_none()
if not user:
print(" ⚠️ No active test user found - creating one...")
# Create a minimal test scenario
print(" ⚠️ Skipping DB soft delete test (no test user available)")
return 0, 0
user_id = user.id
old_email = user.email
print(f" 📋 Test user: id={user_id}, email={old_email}")
# Check if Person exists
person = None
if user.person_id:
result = await db.execute(
select(Person).where(Person.id == user.person_id)
)
person = result.scalar_one_or_none()
if person:
print(f" 📋 Person record: id={person.id}, is_active={person.is_active}")
# Perform soft delete
success = await AuthService.soft_delete_user(
db=db,
user_id=user_id,
reason="test_script_validation",
actor_id=user_id
)
if not success:
print(f" ❌ Soft delete returned False (user already deleted)")
return 0, 1
# Verify user state
await db.refresh(user)
assert user.is_deleted == True, "is_deleted should be True"
assert user.is_active == False, "is_active should be False"
assert user.deleted_at is not None, "deleted_at should be set"
assert "deleted_" in user.email, f"Email should be anonymized: {user.email}"
assert str(user_id) in user.email, "Email should contain user_id"
print(f" ✅ User.is_deleted = {user.is_deleted}")
print(f" ✅ User.is_active = {user.is_active}")
print(f" ✅ User.deleted_at = {user.deleted_at}")
print(f" ✅ User.email anonymized: {user.email}")
# Verify Person is UNTOUCHED by our soft delete
if person:
await db.refresh(person)
# Person.deleted_at must remain None (our code never sets it)
assert person.deleted_at is None, "Person.deleted_at should remain None (not touched by soft delete)"
# Person data must be unchanged (no email rewrite, no name change)
print(f" ✅ Person.deleted_at preserved = {person.deleted_at} (not touched)")
print(f" ✅ Person data preserved (no modifications by soft delete)")
else:
print(f" ⚠️ No Person record linked to this user")
# Restore user for other tests
user.is_deleted = False
user.is_active = True
user.deleted_at = None
user.email = old_email
await db.commit()
print(f" ✅ User restored to original state")
return 2, 0
except Exception as e:
await db.rollback()
print(f" ❌ Soft delete test failed: {e}")
import traceback
traceback.print_exc()
return 0, 1
p3, f3 = asyncio.run(test_soft_delete())
# ─── Final Summary ────────────────────────────────────────────────────────────
print("\n" + "=" * 70)
total_p = p1 + p2 + p3
total_f = f1 + f2 + f3
print(f"📊 FINAL RESULTS: {total_p} passed, {total_f} failed out of {total_p+total_f} tests")
print("=" * 70)
if total_f > 0:
print("❌ SOME TESTS FAILED!")
sys.exit(1)
else:
print("✅ ALL TESTS PASSED!")
sys.exit(0)

View File

@@ -0,0 +1,285 @@
# Gitea Kártyaállapot Elemzés & Programfejlesztési Áttekintés
**Dátum:** 2026-06-15 (Hétfő)
**Összehasonlítási alap:** [`docs/gitea_open_cards_summary_2026-06-09.md`](docs/gitea_open_cards_summary_2026-06-09.md)
**Lekért adatok:** Nyitott kártyák (62 db), Lezárt kártyák (~110+ db), Git log
---
## 1. Összesített Statisztika
### Alapadatok
| Mutató | 2026-06-09 | 2026-06-15 | Változás |
|--------|-----------|-----------|----------|
| **Összes nyitott kártya** | 63 | 62 | **-1** |
| **Összes lezárt kártya** | ~100+ | ~110+ | **+10+** |
| **Git commit-ek** | - | 3 új commit | `90e3173`, `0a3fd8d`, `ef8df96` |
| **Commit nélküli módosított fájlok** | - | **50+ fájl** | Uncommitted changes |
### Nyitott kártyák megoszlása mérföldkövenként
| Mérföldkő / Csoport | Jún. 9 | Jún. 15 | Változás |
|---------------------|--------|---------|----------|
| **Grafikai Komponensek & Frontend Skin Rendszer** | 4 | 4 | ±0 (belső csere) |
| **Frontend Architektúra 2.0 & Dinamikus Megjelenítés** | 20 | 20 | ±0 (belső csere) |
| **Phase 4: Testing & Deployment** | 7 | 7 | ±0 |
| **Phase 3: Advanced Features & Epic 11** | 6 | 6 | ±0 |
| **Phase 2: Dashboard & Analytics Wiring** | 6 | 6 | ±0 |
| **Phase 1: Core Functionality Fixes** | 4 | 3 | **-1** |
| **Egyéb / Nincs mérföldkő** | 16 | 16 | ±0 |
| **ÖSSZESEN** | **63** | **62** | **-1** |
---
## 2. Részletes Változások Június 9 → Június 15 Között
### 2.1 Lezárt Kártyák (Jelentősebbek)
Az alábbi kártyák kerültek lezárásra a vizsgált időszakban:
#### Újonnan létrehozott ÉS lezárt kártyák
| ID | Cím | Mérföldkő |
|----|-----|-----------|
| #261 | Frontend: Account Restore, Last Admin Warning | - |
| #260 | Identity-Preserving Soft Delete & Asset | - |
| #259 | Clean Odometer & Switcher Logic - Vehicle | - |
| #258 | Audit: Odometer rendszer duplikáció vizsgálat | - |
| #257 | Kettős Védelem (Double-Shield) Organization | - |
| #254 | Standard & Prémium Kártyavázak (Blueprints) | Grafikai Komponensek |
| #251 | Háttérkép: garage_private_default.png | Grafikai Komponensek |
| #237 | 1.1-A: Backend API endpoints visual_settings | Frontend Arch. 2.0 |
| #235 | 4.1: 'App Store' típusú kiterjedő kártya | Frontend Arch. 2.0 |
| #228 | 1.1: Adatbázis sémák bővítése vizuális konfighoz | Frontend Arch. 2.0 |
#### Phase 1-ben lezárt kártya
| ID | Cím | Megjegyzés |
|----|-----|------------|
| #144 | Fix Authentication Endpoint Mismatch | **Az egyetlen Phase 1 kártya, ami lezárásra került** |
#### NAV API & Backend infrastruktúra
| ID | Cím |
|----|-----|
| #223 | NAV API kapcsolat javítása - XML sablon |
| #222 | NAV API hibakezelés szétválasztása |
| #221 | NAV Online Számla API v3 Integráció |
#### Company Onboarding & Frontend
| ID | Cím |
|----|-----|
| #220 | Frontend: Company Onboarding 3-Step Wizard |
| #219 | Frontend: Cég Garázsa gomb a header-ben |
### 2.2 Új Nyitott Kártyák (A Két Időpont Között Jöttek Létre)
Ezek a kártyák a június 9-i listában még nem szerepeltek, most viszont nyitottak:
| ID | Cím | Mérföldkő |
|----|-----|-----------|
| #256 | Skin Rendszer Alapok - Dinamikus Háttérv | Grafikai |
| #255 | Lucide Ikonkészlet Integráció | Grafikai |
| #253 | Háttérkép: garage_theme_dark.png | Grafikai |
| #252 | Háttérkép: garage_company_clean.png | Grafikai |
| #250 | 4.2-A: ContextAlert.vue + useAlert | Frontend Arch 2.0 |
| #249 | 4.1-A: ExpansionCard.vue + animációk | Frontend Arch 2.0 |
| #248 | 3.2-B: AppThemeStore + apply | Frontend Arch 2.0 |
| #247 | 3.2-A: CSS Custom Properties | Frontend Arch 2.0 |
| #246 | 3.1-B: Hardcoded szövegek cseréje | Frontend Arch 2.0 |
| #245 | 3.1-A: Hiányzó i18n kulcsok felvétele | Frontend Arch 2.0 |
| #244 | 2.2-B: View-ok áthelyezése nested struktúrába | Frontend Arch 2.0 |
| #243 | 2.2-A: Route meta kiterjesztés | Frontend Arch 2.0 |
| #242 | 2.1-B: Layout-váltó gombok | Frontend Arch 2.0 |
| #241 | 2.1-A: Layout-ok átnevezése | Frontend Arch 2.0 |
| #240 | 1.2-B: Frontend useFeatureFlag() | Frontend Arch 2.0 |
| #239 | 1.2-A: Backend subscription_service | Frontend Arch 2.0 |
| #238 | 1.1-B: Frontend useVisualSettings() | Frontend Arch 2.0 |
**Fontos:** A Frontend Architektúra 2.0 kártyák NEM bővültek (20 → 20), hanem a nagyobb epikus kártyák (#228, #235, #237) lezárásra kerültek, és helyettük részletesebb alkártyák (#238-#250) nyíltak. Ez **tervszerű finomítás**, nem feladatbővülés.
---
## 3. Git Változások Elemzése
### 3.1 Commit-ek a Június 9 óta
| Commit | Dátum kb. | Üzenet | Értelmezés |
|--------|-----------|--------|------------|
| `90e3173` | 2026-06-10 | `frontend 2026-06-10 bontva a 2 felület` | Frontend szétválasztása B2C és B2B felületekre |
| `0a3fd8d` | 2026-06-11 | `Járműkezelés majdnem kész frontend` | Vehicle management frontend majdnem kész |
| `ef8df96` | 2026-06-12 | `egyedi jármű szerkesztés előtti mentés` | Egyedi jármű szerkesztés mentése |
### 3.2 Módosított Fájlok (Commitálva)
**Backend (modell/séma réteg):**
- `backend/app/models/vehicle/vehicle.py` — Vehicle modell változások
- `backend/app/models/vehicle/asset.py` — Asset modell változások
- `backend/app/models/vehicle/vehicle_definitions.py` — Definíciók
- `backend/app/models/identity/identity.py` — Identity modell
- `backend/app/models/core_logic.py` — Core logika
- `backend/app/models/marketplace/organization.py` — Organization
- `backend/app/schemas/asset.py`, `asset_cost.py`, `organization.py`, `user.py`
**Backend (szolgáltatások):**
- `backend/app/services/auth_service.py` — +264 sor (jelentős bővítés)
- `backend/app/services/asset_service.py` — +110 sor
- `backend/app/services/gamification_service.py` — +45 sor
- `backend/app/services/odometer_service.py` — Jelentős átírás (-220 sor)
**Backend (API végpontok):**
- `backend/app/api/v1/endpoints/admin.py` — +387 sor (jelentős admin API bővítés)
- `backend/app/api/v1/endpoints/organizations.py` — +355 sor
- `backend/app/api/v1/endpoints/auth.py`, `catalog.py`, `users.py`, `assets.py`, `expenses.py`
**Frontend:**
- `frontend/src/layouts/OrganizationLayout.vue`, `PrivateLayout.vue` — Layout átalakítások
- `frontend/src/views/DashboardView.vue` — Dashboard bővítés
- `frontend/src/views/ProfileView.vue` — Profil nézet
- `frontend/src/components/header/HeaderCompanySwitcher.vue`, `HeaderProfile.vue`
- `frontend/src/components/dashboard/VehicleFormModal.vue`, `PrivateVehicleManager.vue`, `ComplexExpenseModal.vue`, `SimpleFuelModal.vue`
- `frontend/src/components/vehicle/VehicleDetailModal.vue`
- `frontend/src/stores/auth.ts`, `cost.ts`
- `frontend/src/router/index.ts`
- `frontend/src/i18n/hu.ts`, `en.ts`
- `frontend/src/components/LoginModal.vue`
- `frontend/src/layouts/AdminLayout.vue`
### 3.3 Commit Nélküli Változások
A `git diff --stat` szerint **50+ fájl** módosult, de nincs commitolva. Ez jelentős mennyiségű munkát jelez, ami még nem került rögzítésre a Git történetben.
---
## 4. Programfejlesztési Állapot Értékelés
### 4.1 Erősségek ✅
1. **Jelentős backend előrelépés:** Az admin API (+387 sor), organization API (+355 sor) és auth service (+264 sor) jelentős bővítése.
2. **Frontend modularizáció:** A layout-ok szétválasztása (PrivateLayout, OrganizationLayout) és a hamburger menü bevezetése a privát nézetbe.
3. **NAV API integráció kész:** A NAV Online Számla v3 (#221, #222, #223) és a Company Onboarding (#220, #219) kártyák lezárása.
4. **Odometer duplikáció megszüntetve:** #258 (Audit) és #259 (Clean) lezárása.
5. **Skin rendszer alapjai:** #251 (garage_private_default.png) és #254 (Standard & Prémium kártyavázak) lezárása.
### 4.2 Gyengeségek ⚠️
1. **Frontend Architektúra 2.0 dominancia:** 20 nyitott kártya = a teljes nyitott készlet **32%-a**.
2. **Phase 2, 3, 4 érintetlen:** 19 kártya a Dashboard, Advanced Features és Testing mérföldkövekben — egyik sem lett lezárva.
3. **Kevés commit (3 db 5 nap alatt):** A sok módosított, de nem commitolt fájl kockázatot jelent.
4. **Egyéb / Nincs mérföldkő (16 kártya, 26%):** Számos fontos feladat nincs mérföldkőhöz rendelve.
### 4.3 Kockázatok 🔴
1. **Commit hiány:** Ha az 50+ módosított fájl elveszne, jelentős munka veszne oda.
2. **Frontend vs. Backend aszinkronitás:** A két réteg eltérő ütemben fejlődhet.
3. **Tesztelési adósság:** 7 Phase 4 kártya egyike sem kezdődött el.
### 4.4 Javaslatok 📋
1. **Git commit-ek rendszeresítése** napi legalább 1 commit a `main` branch-re.
2. **Phase 2 kártyák priorizálása:** Analytics Service (TCO/km) és Financial Dashboard (#147, #149, #152).
3. **Mérföldkő hozzárendelés** a 16 "Nincs mérföldkő" kártyához.
4. **Tesztelés beindítása:** Legalább 1-2 Phase 4 kártya (#159, #160) megkezdése.
---
## 5. Nyitott Kártyák Teljes Listája (62 db)
### Grafikai Komponensek & Frontend Skin Rendszer (4)
| ID | Cím |
|----|-----|
| #256 | Skin Rendszer Alapok - Dinamikus Háttérv |
| #255 | Lucide Ikonkészlet Integráció (lucide-vue) |
| #253 | Háttérkép: garage_theme_dark.png |
| #252 | Háttérkép: garage_company_clean.png (sf_public) |
### Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer (20)
| ID | Cím |
|----|-----|
| #250 | 4.2-A: ContextAlert.vue komponens + useAlert |
| #249 | 4.1-A: ExpansionCard.vue komponens + animációk |
| #248 | 3.2-B: AppThemeStore létrehozása + apply |
| #247 | 3.2-A: CSS Custom Properties + tailwind.config |
| #246 | 3.1-B: Hardcoded szövegek cseréje 5 komponensben |
| #245 | 3.1-A: Hiányzó i18n kulcsok felvétele |
| #244 | 2.2-B: View-ok áthelyezése nested struktúrába |
| #243 | 2.2-A: Route meta kiterjesztés + beforeE |
| #242 | 2.1-B: Layout-váltó gombok (Privát <-> Cég) |
| #241 | 2.1-A: Layout-ok átnevezése és `<slot />` |
| #240 | 1.2-B: Frontend useFeatureFlag() composable |
| #239 | 1.2-A: Backend subscription_service + |
| #238 | 1.1-B: Frontend useVisualSettings() composable |
| #236 | 4.2: Kontextuális Vészjelző Rendszer |
| #234 | 3.2: Tailwind CSS Változók és Témakezelő |
| #233 | 3.1: Jelenlegi állapot felmérése & Szövegek |
| #232 | 2.2: Vue Router átírás (Nested Routing) |
| #231 | 2.1: Független Layout komponensek felépítése |
| #230 | 1.3: Dinamikus és Statikus funkciókapcsolás |
| #229 | 1.2: Engedélyezési szintek és Funkciókapcsolás |
### Phase 4: Testing & Deployment (7)
| ID | Cím |
|----|-----|
| #165 | Production Deployment and Monitoring Setup |
| #164 | CI/CD Pipeline Setup (GitHub Actions) |
| #163 | Security Audit (Penetration Testing) |
| #162 | Accessibility Audit and Fixes (WCAG 2.1) |
| #161 | Performance Optimization (Bundle Size) |
| #160 | Implement Integration Tests (Playwright) |
| #159 | Write Unit Tests for Critical Components |
### Phase 3: Advanced Features & Epic 11 (6)
| ID | Cím |
|----|-----|
| #158 | Implement Advanced Search with Filters |
| #157 | Add Bulk Operations (Vehicle Import, Mass) |
| #156 | Implement Webhook and Notification System |
| #155 | Add Admin Control Panels |
| #154 | Implement Service Booking Flow |
| #153 | Complete Profile Selector (Private vs Co) |
### Phase 2: Dashboard & Analytics Wiring (6)
| ID | Cím |
|----|-----|
| #152 | Implement Historical Data (occurrence_date) |
| #151 | Connect User Management Table to Real Data |
| #150 | Wire Service Map with Real Provider Data |
| #149 | Implement Analytics Service (TCO/km Calc) |
| #148 | Connect Gamification Components to Real Data |
| #147 | Wire Financial Dashboard to Real Finance Data |
### Phase 1: Core Functionality Fixes (3)
| ID | Cím |
|----|-----|
| #146 | Implement Basic Error Handling in Frontend |
| #145 | Standardize API Base URL Usage in Frontend |
| #142 | Implement Catalog API Endpoints |
### Egyéb / Nincs mérföldkő (16)
| ID | Cím |
|----|-----|
| #227 | [FIX] NAV API v3 queryTaxpayer - .env query |
| #226 | Frontend: flag-icons CSS import hiba |
| #225 | Hiba: Frontend - flag-icons CSS import |
| #224 | Hiba: NAV API aláírás (INVALID_REQUEST) |
| #218 | Frontend: Cég létrehozás 3-lépéses Onboarding |
| #217 | Backend: Cég (Organization) CRUD API bővítés |
| #216 | Profil UI hiányosságok (Person & User autocomplete) |
| #175 | Advanced Analytics & Reporting Engine |
| #174 | Real-time Notification System |
| #173 | Service Finder Rendszer Áttekintő Dokumentum |
| #140 | Connect Service Moderation Map to Backend |
| #139 | Integrate Gamification Control Panel |
| #138 | Connect Financial Dashboard Tile to Finance API |
| #137 | Implement Real-time System Health Monitor |
| #136 | Implement AI Researcher Logs Backend & Frontend |
| #135 | Connect User Management Table to Real API |
---
## 6. Következtetés
**Összességében a projekt aktív fejlesztés alatt áll,** de a commit-ek gyakorisága és a Phase 2/3/4 kártyák érintetlensége aggodalomra ad okot. A Frontend Architektúra 2.0 dominálja a nyitott kártyákat (32%), ami stratégiai döntés — a B2C/B2B szétválasztás és a dinamikus megjelenítési rendszer alapvető fontosságú a termék jövője szempontjából.
**Főbb akciópontok:**
1. Git commit-ek rendszeresítése a kód biztonsága érdekében
2. Legalább 1 Phase 2 kártya megkezdése (pl. #149 Analytics Service vagy #147 Financial Dashboard)
3. A 16 "Nincs mérföldkő" kártya besorolása
4. Frontend Arch 2.0 alkártyák (20 db) szisztematikus lezárogatása

View File

@@ -0,0 +1,132 @@
# Odometer Rendszer Duplikáció Audit Jelentés
**Dátum:** 2026-06-12
**Auditor:** Rendszer-Architect
**Gitea Issue:** #258
## Összefoglaló
A vizsgálat feltárta, hogy az odometer (kilométeróra) adatok **több helyen és több formában** vannak tárolva a rendszerben, ami duplikációhoz és architekturális inkonzisztenciához vezet.
---
## 1. Összes Odometer-rel Kapcsolatos Elem
### 1.1 Különálló Táblák
| # | Tábla (Schema) | Modell | Fájl | Elsődleges Entitás |
|---|---|---|---|---|
| 1 | `vehicle.odometer_readings` | `OdometerReading` | `backend/app/models/vehicle/asset.py:347` | Asset (járműpéldány) |
| 2 | `vehicle.vehicle_odometer_states` | `VehicleOdometerState` | `backend/app/models/vehicle/vehicle.py:112` | VehicleModelDefinition (katalógus) |
| 3 | `vehicle.asset_telemetry` | `AssetTelemetry` (`current_mileage`) | `backend/app/models/vehicle/asset.py:338` | Asset (járműpéldány) |
### 1.2 Oszlopok Más Táblákban
| # | Tábla | Oszlop | Fájl |
|---|---|---|---|
| 4 | `vehicle.costs` | `odometer` | `backend/app/models/vehicle/vehicle.py:97` |
| 5 | `vehicle.asset_events` | `odometer_reading` | `backend/app/models/vehicle/asset.py:400` |
| 6 | `marketplace.costs` | `odometer_km` | `backend/app/models/marketplace/service.py:172` |
---
## 2. Részletes Elemzés
### 2.1 `OdometerReading` (asset.py:347)
- **Cél:** Km óra állás időbeli nyomonkövetése. Minden egyes leolvasás külön rekord.
- **Adatbázis séma:** `vehicle.odometer_readings(id, asset_id, reading, recorded_at, source, cost_id)`
- **Forrás:** `manual`, `api`, `telemetry`, `cost_entry`
- **Kötődés:** `vehicle.assets` (Asset) - **konkrét járműpéldány**
- **FK:** `odometer_readings_asset_id_fkey``vehicle.assets.id`
### 2.2 `VehicleOdometerState` (vehicle.py:112)
- **Cél:** Okos odometer - becsült aktuális km állás, napi átlag, konfidencia score.
- **Adatbázis séma:** `vehicle.vehicle_odometer_states(vehicle_id PK, last_recorded_odometer, last_recorded_date, daily_avg_distance, estimated_current_odometer, confidence_score, manual_override_avg)`
- **Kötődés:** `vehicle.vehicle_model_definitions` (VehicleModelDefinition) - **katalógusrekord**
- **FK:** `vehicle_odometer_states_vehicle_id_fkey``vehicle.vehicle_model_definitions.id`
- **Adatforrás:** Kizárólag a `VehicleCost.odometer` mezőből számol (`odometer_service.py:59-62`)
### 2.3 `AssetTelemetry.current_mileage` (asset.py:338)
- **Cél:** Telemetriai adat tárolása.
- **Kötődés:** `vehicle.assets` (Asset) - **konkrét járműpéldány**
- **Nincs integrálva** sem az `OdometerReading`, sem a `VehicleOdometerState` rendszerrel.
### 2.4 `VehicleCost.odometer` (vehicle.py:97)
- **Cél:** Költségbejegyzéshez tartozó km állás (NOT NULL).
- **Kötődés:** `vehicle.vehicle_model_definitions` (VehicleModelDefinition)
- **Funkció:** Ez az adatforrása a `VehicleOdometerState` számításnak.
### 2.5 `AssetEvent.odometer_reading` (asset.py:400)
- **Cél:** Digitális szervizkönyv eseményhez tartozó km állás.
- **Kötődés:** `vehicle.assets` (Asset)
- **Nincs integrálva** semmilyen más odometer rendszerrel.
### 2.6 `marketplace.costs.odometer_km`
- **Cél:** Marketplace szolgáltatás árához tartozó km állás.
- **Teljesen elkülönült** rendszer.
---
## 3. A Duplikáció Leírása
### 3.1 Két Párhuzamos Odometer Rendszer
#### A) Asset-alapú Odometer Rendszer (komplett, de kihasználatlan)
- **Tábla:** `vehicle.odometer_readings`
- **Használat:** Csak `expenses.py:108-127` hoz létre `OdometerReading` rekordot költség rögzítésekor.
- **Kötődés:** `Asset` (konkrét jármű)
- **Státusz:** Létezik, de **nincs kihasználva** a VehicleOdometerState számára.
#### B) Vehicle-definition-alapú Odometer Rendszer (aktív, de hibás)
- **Tábla:** `vehicle.vehicle_odometer_states`
- **Service:** `odometer_service.py` - teljes funkcionalitás
- **Kötődés:** `VehicleModelDefinition` (katalógusrekord)
- **Adatforrás:** `VehicleCost.odometer` (szintén vehicle-definition-hez kötött)
- **Státusz:** Működik, de **architekturálisan hibás**, mert egy katalógusrekordhoz rendel km állást.
### 3.2 Architekturális Következmények
1. **Adatvesztés:** Az Asset-hez tartozó `OdometerReading` adatok nincsenek felhasználva a VehicleOdometerState számításban.
2. **Inkonzisztens adat:** Telemetriából vagy manuális bevitelből származó km adatok nem kerülnek be a VehicleOdometerState-ba.
3. **Entitás zavar:** A `VehicleModelDefinition` egy katalógusrekord, aminek NINCS km óra állása.
4. **Több forrás, nincs szinkron:** AssetTelemetry, OdometerReading, AssetEvent mind Asset-hez kötött, de nincsenek szinkronizálva.
---
## 4. Mermaid Diagram
```mermaid
graph TD
subgraph "ASSET-alapú (konkrét jármű)"
A[Asset] --> OR[OdometerReading<br/>vehicle.odometer_readings]
A --> AT[AssetTelemetry<br/>current_mileage]
A --> AE[AssetEvent<br/>odometer_reading]
end
subgraph "VEHICLE-DEFINITION-alapú (katalógus)"
VMD[VehicleModelDefinition] --> VOS[VehicleOdometerState<br/>vehicle.vehicle_odometer_states]
VMD --> VC[VehicleCost<br/>odometer]
end
subgraph "SZOLGÁLTATÁS"
OS[OdometerService<br/>odometer_service.py] -->|számol| VC
OS -.->|NEM használja| OR
end
subgraph "MARKETPLACE (elkülönült)"
MC[Marketplace Cost<br/>odometer_km]
end
style VOS fill:#f96,stroke:#333,stroke-width:2px
style OR fill:#9cf,stroke:#333,stroke-width:2px
style OS fill:#ff9,stroke:#333,stroke-width:2px
```
---
## 5. Javaslat
1. A `VehicleOdometerState`-ot át kell kötni az `Asset` entitáshoz.
2. Az `OdometerService`-nek az `OdometerReading` táblából kellene számolnia.
3. Az `AssetTelemetry.current_mileage` és `AssetEvent.odometer_reading` integrálni kell az `OdometerReading` rendszerbe.
4. A `marketplace.costs.odometer_km` egységesítése.

View File

@@ -141,8 +141,8 @@
</label>
</div>
<!-- Symmetric Link Row: Resend (left) + Forgot Password (right) -->
<div class="flex justify-between items-center w-full mt-2 mb-6 text-sm">
<!-- Link Row: Resend (left) + Forgot Password (right) -->
<div class="flex justify-between items-center w-full mt-2 mb-2 text-sm">
<a
href="#"
@click.prevent="switchMode('resend')"
@@ -159,6 +159,17 @@
</a>
</div>
<!-- Account Restore Link -->
<div class="flex justify-center mb-6">
<a
href="#"
@click.prevent="switchMode('restore')"
class="text-xs text-[#D4AF37]/60 hover:text-[#D4AF37] transition-colors"
>
{{ t('auth.restoreLink') }}
</a>
</div>
<!-- Error Message (translated via i18n) -->
<div
v-if="authStore.error && authMode === 'login'"
@@ -613,6 +624,209 @@
</a>
</p>
</form>
<!-- ==================== FIFTH FACE: ACCOUNT RESTORE ==================== -->
<form
v-if="activeBackFace === 'restore'"
@submit.prevent="handleRestoreStep"
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
>
<!-- Close Button (X) -->
<button
type="button"
@click="$emit('close')"
class="absolute right-4 top-4 text-white/60 transition-colors hover:text-white"
>
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<!-- Title -->
<h2 class="mb-6 text-center text-2xl font-bold text-white">
{{ t('auth.restoreTitle') }}
</h2>
<!-- Step 1: Email + Send Code -->
<template v-if="restoreStep === 1">
<p class="mb-6 rounded-xl bg-[#1A6B5A]/20 px-4 py-3 text-center text-sm text-[#5EC4B0]">
{{ t('auth.restoreStep1') }}
</p>
<!-- Email Input -->
<div class="mb-6">
<label for="restore-email" class="mb-2 block text-sm font-medium text-white/80">
{{ t('auth.restoreEmailLabel') }}
</label>
<input
id="restore-email"
v-model="restoreEmail"
type="email"
placeholder="pelda@email.com"
autocomplete="email"
required
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- Error Message -->
<div
v-if="restoreError"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
>
{{ translateError(restoreError) }}
</div>
<!-- Send Code Button -->
<button
type="submit"
:disabled="authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.restoreSendCode') }}
</button>
</template>
<!-- Step 2: OTP + New Password -->
<template v-else-if="restoreStep === 2">
<p class="mb-6 rounded-xl bg-[#1A6B5A]/20 px-4 py-3 text-center text-sm text-[#5EC4B0]">
{{ t('auth.restoreStep2') }}
</p>
<!-- Code Sent Info -->
<div class="mb-4 rounded-xl bg-[#1A6B5A]/10 px-4 py-2 text-center text-xs text-[#5EC4B0]">
{{ t('auth.restoreCodeSent') }}
</div>
<!-- OTP Input -->
<div class="mb-5">
<label for="restore-otp" class="mb-2 block text-sm font-medium text-white/80">
{{ t('auth.restoreOtpLabel') }}
</label>
<input
id="restore-otp"
v-model="restoreOtp"
type="text"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
placeholder="123456"
autocomplete="one-time-code"
required
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- New Password Input -->
<div class="mb-6 relative">
<label for="restore-password" class="mb-2 block text-sm font-medium text-white/80">
{{ t('auth.restoreNewPassword') }}
</label>
<input
id="restore-password"
v-model="restoreNewPassword"
:type="showRestorePassword ? 'text' : 'password'"
placeholder="••••••••"
autocomplete="new-password"
required
minlength="6"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 pr-12 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
<button
type="button"
@mousedown="showRestorePassword = true"
@mouseup="showRestorePassword = false"
@mouseleave="showRestorePassword = false"
@touchstart.prevent="showRestorePassword = true"
@touchend="showRestorePassword = false"
class="absolute right-3 top-[42px] text-white/40 hover:text-white/70 transition-colors cursor-pointer"
tabindex="-1"
aria-label="Jelszó megtekintése"
>
<svg
v-if="showRestorePassword"
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<path d="M1 1l22 22" />
</svg>
<svg
v-else
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
<!-- Error Message -->
<div
v-if="restoreError"
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
>
{{ translateError(restoreError) }}
</div>
<!-- Verify Button -->
<button
type="submit"
:disabled="authStore.isLoading"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.restoreVerifyButton') }}
</button>
</template>
<!-- Success Message -->
<template v-else-if="restoreStep === 3">
<div class="mb-6 rounded-xl bg-[#70BC84]/20 px-4 py-6 text-center">
<div class="mb-3 flex justify-center">
<svg class="h-12 w-12 text-[#70BC84]" 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" />
</svg>
</div>
<p class="text-sm font-medium text-[#70BC84]">
{{ t('auth.restoreSuccess') }}
</p>
</div>
</template>
<!-- Back to Login -->
<p class="mt-6 text-center text-sm text-white/50">
<a
href="#"
@click.prevent="switchMode('login')"
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
>
{{ t('auth.backToLogin') }}
</a>
</p>
</form>
</div>
</div>
</div>
@@ -710,8 +924,16 @@ const showRegPassword = ref(false)
const rememberMe = ref(localStorage.getItem('remember-me-state') === 'true')
// ── 3D Flip state machine ──
const authMode = ref<'login' | 'register' | 'forgot' | 'resend'>('login')
const activeBackFace = ref<'register' | 'forgot' | 'resend'>('register')
const authMode = ref<'login' | 'register' | 'forgot' | 'resend' | 'restore'>('login')
const activeBackFace = ref<'register' | 'forgot' | 'resend' | 'restore'>('register')
// ── Account Restore form fields ──
const restoreEmail = ref('')
const restoreOtp = ref('')
const restoreNewPassword = ref('')
const restoreError = ref('')
const restoreStep = ref(1) // 1 = email, 2 = otp+password, 3 = success
const showRestorePassword = ref(false)
// ── Restore saved email, remember-me state, and resend cooldown on mount ──
onMounted(() => {
@@ -763,6 +985,12 @@ watch(
// Reset view to login
authMode.value = 'login'
activeBackFace.value = 'register'
// Reset restore state
restoreStep.value = 1
restoreEmail.value = ''
restoreOtp.value = ''
restoreNewPassword.value = ''
restoreError.value = ''
forgotError.value = ''
forgotSuccess.value = false
isUserInactive.value = false
@@ -779,11 +1007,19 @@ watch(authMode, () => {
authStore.clearError()
})
function switchMode(mode: 'login' | 'register' | 'forgot' | 'resend') {
function switchMode(mode: 'login' | 'register' | 'forgot' | 'resend' | 'restore') {
if (mode !== 'login') {
activeBackFace.value = mode
}
authMode.value = mode
// Reset restore state when switching away from restore
if (mode !== 'restore') {
restoreStep.value = 1
restoreEmail.value = ''
restoreOtp.value = ''
restoreNewPassword.value = ''
restoreError.value = ''
}
}
/**
@@ -903,6 +1139,52 @@ async function handleResend() {
resendError.value = errMsg || 'auth.resendFailed'
}
}
// ── Account Restore Logic ──
async function handleRestoreStep() {
restoreError.value = ''
if (restoreStep.value === 1) {
// Step 1: Send restoration code to email
try {
await authStore.requestRestore(restoreEmail.value)
// Move to step 2
restoreStep.value = 2
} catch (err: any) {
// Capture error from store — display user-friendly message
restoreError.value = authStore.error || err?.response?.data?.detail || 'auth.restoreError'
// Log the actual error for debugging
console.warn('Account restore step 1 failed:', err)
} finally {
// GUARANTEE: loading state is always reset, even if catch throws
authStore.isLoading = false
}
} else if (restoreStep.value === 2) {
// Step 2: Verify OTP + set new password
try {
await authStore.verifyRestore(restoreEmail.value, restoreOtp.value, restoreNewPassword.value)
// Success — show success message
restoreStep.value = 3
// Auto-close modal and redirect after short delay
setTimeout(() => {
emit('close')
if (authStore.isKycComplete) {
router.push('/dashboard')
} else {
router.push('/complete-kyc')
}
}, 2000)
} catch (err: any) {
// Capture error from store — display user-friendly message
restoreError.value = authStore.error || err?.response?.data?.detail || 'auth.restoreError'
// Log the actual error for debugging
console.warn('Account restore step 2 failed:', err)
} finally {
// GUARANTEE: loading state is always reset, even if catch throws
authStore.isLoading = false
}
}
}
</script>
<style scoped>

View File

@@ -0,0 +1,81 @@
<template>
<div class="mode-switcher">
<div class="flex items-center space-x-4">
<!-- B2B/B2C Toggle -->
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('switchMode') }}</span>
<button
@click="toggleAppMode"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="isCorporate ? 'bg-corporate-blue' : 'bg-consumer-orange'"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="isCorporate ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
<span class="text-sm font-medium min-w-[60px] dark:text-gray-300">
{{ isCorporate ? $t('corporate') : $t('consumer') }}
</span>
</div>
<!-- Admin Switch (only for admins) -->
<div v-if="showAdminSwitch" class="flex items-center space-x-2">
<div class="h-6 w-px bg-gray-300 dark:bg-gray-600"></div>
<button
@click="goToAdmin"
class="flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-gradient-to-r from-gray-800 to-gray-900 dark:from-gray-700 dark:to-gray-800 text-white hover:opacity-90 transition-opacity"
>
<span class="text-sm"></span>
<span class="text-sm font-medium">Admin</span>
</button>
<button
v-if="isInAdminMode"
@click="exitAdmin"
class="px-3 py-1.5 rounded-lg bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm font-medium transition-colors"
>
Exit Admin
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppModeStore } from '../stores/appModeStore'
import { useAuthStore } from '../stores/auth'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const appModeStore = useAppModeStore()
const authStore = useAuthStore()
const isCorporate = computed(() => appModeStore.isCorporate)
const isInAdminMode = computed(() => route.path.startsWith('/admin'))
const showAdminSwitch = computed(() => {
// Check if user has admin privileges
return authStore.isAdmin
})
function toggleAppMode() {
appModeStore.toggleMode()
}
function goToAdmin() {
router.push('/admin')
}
function exitAdmin() {
router.push('/')
}
</script>
<style scoped>
.mode-switcher {
font-family: 'Inter', sans-serif;
}
</style>

View File

@@ -0,0 +1,482 @@
<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-gray-800 border border-gray-700 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-5 border-b border-gray-700">
<h2 class="text-xl font-bold text-white">
{{ isEditing ? $t('admin.packages.modal_edit_title') : $t('admin.packages.modal_create_title') }}
</h2>
<button
class="p-2 text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded-lg transition-colors"
@click="$emit('close')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-5 space-y-6">
<!-- Display Name (human-readable) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_display_name') || 'Megjelenített név' }}
</label>
<input
v-model="form.display_name"
type="text"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Pl. Privát Ingyenes"
/>
<p class="text-xs text-gray-500 mt-1">Emberi olvasásra szánt megjelenítési név</p>
</div>
<!-- Name (technical) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_name') }}
</label>
<input
v-model="form.name"
type="text"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
:placeholder="$t('admin.packages.field_name_placeholder')"
/>
<p class="text-xs text-gray-500 mt-1">Technikai azonosító (pl. private_free_v1)</p>
</div>
<!-- Type -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_type') }}
</label>
<select
v-model="form.type"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="private">{{ $t('admin.packages.type_private') }}</option>
<option value="corporate">{{ $t('admin.packages.type_corporate') }}</option>
</select>
</div>
<!-- Pricing Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_monthly_price') }}
</label>
<input
v-model.number="form.monthly_price"
type="number"
step="0.01"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_yearly_price') }}
</label>
<input
v-model.number="form.yearly_price"
type="number"
step="0.01"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_currency') }}
</label>
<select
v-model="form.currency"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="EUR">EUR</option>
<option value="HUF">HUF</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
<!-- Allowances Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_max_vehicles') }}
</label>
<input
v-model.number="form.max_vehicles"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_max_garages') }}
</label>
<input
v-model.number="form.max_garages"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_free_credits') }}
</label>
<input
v-model.number="form.monthly_free_credits"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Credit Price (Kredit Ár) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_credit_price') }}
</label>
<input
v-model.number="form.credit_price"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<p class="text-xs text-gray-500 mt-1">Havi kredit költség a csomaghoz</p>
</div>
<!-- Lifecycle Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_is_public') }}
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="form.is_public"
type="checkbox"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm text-gray-300">{{ form.is_public ? $t('admin.packages.active') : $t('admin.packages.inactive') }}</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_available_until') }}
</label>
<input
v-model="form.available_until"
type="date"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p class="text-xs text-gray-500 mt-1">Ha üres, nincs lejárat</p>
</div>
</div>
<!-- Affiliate Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_commission_rate') }}
</label>
<input
v-model.number="form.commission_rate_percent"
type="number"
min="0"
max="100"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
{{ $t('admin.packages.field_referral_bonus') }}
</label>
<input
v-model.number="form.referral_bonus_credits"
type="number"
min="0"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Entitlements (Chip Selector) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
{{ $t('admin.packages.field_entitlements') }}
</label>
<div class="flex flex-wrap gap-2 mb-3">
<span
v-for="ent in selectedEntitlements"
:key="ent"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-900/50 text-blue-300 border border-blue-700 rounded-full text-sm"
>
{{ ent }}
<button
class="p-0.5 hover:bg-blue-800 rounded-full transition-colors"
@click="removeEntitlement(ent)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
</div>
<div class="relative">
<select
v-model="entitlementToAdd"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="addEntitlement"
>
<option value="">{{ $t('admin.packages.entitlement_placeholder') }}</option>
<option
v-for="ent in availableEntitlements"
:key="ent"
:value="ent"
:disabled="selectedEntitlements.includes(ent)"
>
{{ ent }}
</option>
</select>
</div>
</div>
<!-- Error Display -->
<div
v-if="errorMessage"
class="px-4 py-3 bg-red-900/40 border border-red-700 rounded-lg text-red-300 text-sm"
>
{{ errorMessage }}
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-700">
<button
class="px-4 py-2.5 text-sm font-medium text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
@click="$emit('close')"
>
{{ $t('admin.packages.cancel') }}
</button>
<button
class="px-4 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isFormValid"
@click="handleSave"
>
<span v-if="isSaving" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" 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>
{{ $t('admin.packages.saving') }}
</span>
<span v-else>
{{ isEditing ? $t('admin.packages.save_changes') : $t('admin.packages.create') }}
</span>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAdminPackagesStore, AVAILABLE_ENTITLEMENTS } from '../../stores/adminPackages'
import type { SubscriptionTierItem, SubscriptionRulesModel } from '../../stores/adminPackages'
const props = defineProps<{
package?: SubscriptionTierItem | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const store = useAdminPackagesStore()
const isEditing = computed(() => !!props.package)
// ── Form State ──────────────────────────────────────────────────────────────
const form = ref({
name: '',
display_name: '',
type: 'private' as 'private' | 'corporate',
monthly_price: 0,
yearly_price: 0,
currency: 'EUR',
credit_price: null as number | null,
max_vehicles: 0,
max_garages: 0,
monthly_free_credits: 0,
is_public: true,
available_until: '' as string,
commission_rate_percent: 0,
referral_bonus_credits: 0,
})
const selectedEntitlements = ref<string[]>([])
const entitlementToAdd = ref('')
const isSaving = ref(false)
const errorMessage = ref<string | null>(null)
const availableEntitlements = computed(() => {
return AVAILABLE_ENTITLEMENTS.filter((e) => !selectedEntitlements.value.includes(e))
})
const isFormValid = computed(() => {
return form.value.name.trim().length >= 2
})
// ── Initialize form when editing ────────────────────────────────────────────
watch(
() => props.package,
(pkg) => {
if (pkg) {
const rules = pkg.rules
form.value.name = pkg.name
form.value.display_name = rules.display_name || ''
form.value.type = rules.type
form.value.monthly_price = rules.pricing.monthly_price
form.value.yearly_price = rules.pricing.yearly_price
form.value.currency = rules.pricing.currency
form.value.credit_price = rules.pricing.credit_price ?? null
form.value.max_vehicles = rules.allowances.max_vehicles
form.value.max_garages = rules.allowances.max_garages
form.value.monthly_free_credits = rules.allowances.monthly_free_credits
form.value.is_public = rules.lifecycle?.is_public ?? true
form.value.available_until = rules.lifecycle?.available_until
? rules.lifecycle.available_until.slice(0, 10)
: ''
form.value.commission_rate_percent = rules.affiliate.commission_rate_percent
form.value.referral_bonus_credits = rules.affiliate.referral_bonus_credits
selectedEntitlements.value = [...rules.entitlements]
} else {
resetForm()
}
},
{ immediate: true },
)
function resetForm() {
form.value = {
name: '',
display_name: '',
type: 'private',
monthly_price: 0,
yearly_price: 0,
currency: 'EUR',
credit_price: null,
max_vehicles: 0,
max_garages: 0,
monthly_free_credits: 0,
is_public: true,
available_until: '',
commission_rate_percent: 0,
referral_bonus_credits: 0,
}
selectedEntitlements.value = []
errorMessage.value = null
}
// ── Entitlement Chip Management ─────────────────────────────────────────────
function addEntitlement() {
const val = entitlementToAdd.value
if (val && !selectedEntitlements.value.includes(val)) {
selectedEntitlements.value.push(val)
}
entitlementToAdd.value = ''
}
function removeEntitlement(ent: string) {
selectedEntitlements.value = selectedEntitlements.value.filter((e) => e !== ent)
}
// ── Build Rules Object ──────────────────────────────────────────────────────
function buildRules(): SubscriptionRulesModel {
const rules: SubscriptionRulesModel = {
type: form.value.type,
pricing: {
monthly_price: form.value.monthly_price,
yearly_price: form.value.yearly_price,
currency: form.value.currency,
},
allowances: {
max_vehicles: form.value.max_vehicles,
max_garages: form.value.max_garages,
monthly_free_credits: form.value.monthly_free_credits,
},
entitlements: [...selectedEntitlements.value],
affiliate: {
commission_rate_percent: form.value.commission_rate_percent,
referral_bonus_credits: form.value.referral_bonus_credits,
},
}
if (form.value.display_name.trim()) {
rules.display_name = form.value.display_name.trim()
}
// Credit price
if (form.value.credit_price !== null && form.value.credit_price >= 0) {
rules.pricing.credit_price = form.value.credit_price
}
// Lifecycle
const lifecycle: Record<string, any> = {
is_public: form.value.is_public,
}
if (form.value.available_until) {
lifecycle.available_until = form.value.available_until
}
rules.lifecycle = lifecycle as any
return rules
}
// ── Save Handler ────────────────────────────────────────────────────────────
async function handleSave() {
if (!isFormValid.value) return
isSaving.value = true
errorMessage.value = null
try {
const rules = buildRules()
if (isEditing.value && props.package) {
await store.updatePackage(props.package.id, {
name: form.value.name,
rules,
})
} else {
// Create: do NOT send id — PostgreSQL auto-increments it
await store.createPackage({
name: form.value.name,
rules,
})
}
emit('saved')
} catch (err: any) {
errorMessage.value = err.response?.data?.detail || store.error || 'An error occurred'
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -180,6 +180,7 @@ const errorMessage = ref<string | null>(null)
interface CategoryOption {
id: number
name: string
code?: string
parent_id: number | null
}
@@ -208,14 +209,16 @@ async function fetchCategories() {
window.__allCostCategories = allCategories
} catch (err) {
console.error('[ComplexExpenseModal] Failed to load categories:', err)
// Fallback: provide basic categories
// Fallback: provide basic categories — IDs must match vehicle.cost_categories in DB
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: 1, name: 'Üzemanyag / Töltés', parent_id: null },
{ id: 2, name: 'Szerviz & Karbantartás', parent_id: null },
{ id: 3, name: 'Gumiabroncsok', 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 },
{ id: 5, name: 'Adók', parent_id: null },
{ id: 6, name: 'Útdíj & Parkolás', parent_id: null },
{ id: 9, name: 'Ápolás & Kozmetika', parent_id: null },
{ id: 10, name: 'Egyéb', parent_id: null },
]
}
}
@@ -246,22 +249,23 @@ async function handleSubmit() {
let categoryId: number
if (props.isFeeMode) {
// Fee mode: pre-filled as FEES
// Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID
costType = 'fee'
categoryId = 3 // Parkolás / Útdíj
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
const feesCat = allCats.find((c: CategoryOption) => c.code === 'FEES' && c.parent_id === null)
categoryId = feesCat?.id || 6 // Fallback to ID 6 if lookup fails
} else {
// Complex mode: use selected sub-category or main category
costType = 'other'
categoryId = Number(form.subCategory || form.mainCategory) || 6
categoryId = Number(form.subCategory || form.mainCategory) || 10
}
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,
amount_net: form.amount,
currency: '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'),

View File

@@ -13,23 +13,13 @@
</div>
<div class="flex items-center gap-3">
<button
@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"
@click="$emit('add-new')"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
>
<svg
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">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
Új jármű hozzáadása
</button>
</div>
</div>
@@ -70,7 +60,7 @@
:key="vehicle.id"
:vehicle="vehicle"
@click="openDetailView(vehicle)"
@edit="openEditDirect(vehicle)"
@edit="$emit('edit-vehicle', vehicle)"
@set-primary="setPrimaryVehicle"
/>
</div>
@@ -86,41 +76,17 @@
<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="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"
@click="$emit('add-new')"
class="mt-6 inline-flex items-center gap-2 rounded-xl bg-sf-accent px-6 py-3 text-sm font-semibold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
>
<svg
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">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
Új jármű hozzáadása
</button>
</div>
</template>
<!-- Vehicle Form Modal (Add) -->
<VehicleFormModal
:is-open="isAddFormOpen"
@close="isAddFormOpen = false"
@saved="onVehicleSaved"
/>
<!-- Vehicle Form Modal (Edit) -->
<VehicleFormModal
:is-open="isEditFormOpen"
:vehicle="editingVehicle"
@close="isEditFormOpen = false"
@saved="onVehicleEdited"
/>
<!-- Vehicle Detail Modal (read-only) -->
<VehicleDetailModal
:is-open="isDetailOpen"
@@ -129,8 +95,6 @@
@edit="openEditFromDetail"
@set-primary="setPrimaryVehicle"
/>
<!-- Task 3: Hidden trigger for direct edit from DashboardView -->
<div style="display: none">{{ triggerEditFromParent }}</div>
</div>
</template>
@@ -138,18 +102,16 @@
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import { useVehicleStore } from '../../stores/vehicle'
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': []
'add-new': []
'edit-vehicle': [vehicle: VehicleData]
}>()
const vehicleStore = useVehicleStore()
@@ -188,71 +150,12 @@ function openDetailView(vehicle: VehicleData) {
isDetailOpen.value = true
}
// ── Direct Edit (from card edit button) ──
function openEditDirect(vehicle: VehicleData) {
editingVehicle.value = vehicle ? { ...vehicle } : null
isEditFormOpen.value = true
}
// ── Bridge: Detail → Edit ──
// ── Bridge: Detail → Edit (emits to parent DashboardView) ──
async function openEditFromDetail(vehicle: VehicleData) {
isDetailOpen.value = false
// Use nextTick so detail modal closes before edit opens
await nextTick()
editingVehicle.value = vehicle ? { ...vehicle } : null
isEditFormOpen.value = true
}
// ── Modal state (Add) ──
const isAddFormOpen = ref(false)
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
// In real implementation, refresh the vehicle list from the store
}
// ── Modal state (Edit) ──
const isEditFormOpen = ref(false)
const editingVehicle = ref<VehicleData | null>(null)
function onVehicleEdited() {
isEditFormOpen.value = false
editingVehicle.value = null
// In real implementation, refresh the vehicle list from the store
emit('edit-vehicle', vehicle)
}
// ── Targeted scroll to vehicle card ──
@@ -276,24 +179,9 @@ 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)
// TODO: Real API call — PUT /api/v1/assets/vehicles/{id} with { is_primary: true }
// This will also need to unset any other primary vehicle for this user.
// For now, just log and update local state optimistically.
vehicleStore.setPrimaryVehicle(vehicle.id)
}
</script>

View File

@@ -110,8 +110,9 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useCostStore } from '../../stores/cost'
import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle'
const props = defineProps<{
@@ -135,6 +136,26 @@ const form = reactive({
const errorMessage = ref<string | null>(null)
// ── Category Resolution ──
const fuelCategoryId = ref<number>(1) // Default fallback
async function resolveFuelCategory() {
try {
const res = await api.get('/dictionaries/cost-categories')
const allCats = res.data as Array<{ id: number; code?: string; name: string; parent_id: number | null }>
const fuelCat = allCats.find((c) => c.code === 'FUEL' && c.parent_id === null)
if (fuelCat) {
fuelCategoryId.value = fuelCat.id
}
} catch (err) {
console.warn('[SimpleFuelModal] Could not fetch categories, using fallback ID 1:', err)
}
}
onMounted(() => {
resolveFuelCategory()
})
const vehicleLabel = computed(() => {
if (!props.vehicle) return 'Nincs kiválasztva jármű'
const plate = props.vehicle.license_plate || ''
@@ -151,14 +172,13 @@ async function handleSubmit() {
errorMessage.value = null
// Build payload: cost_type = 'fuel', category auto-set to FUEL
// Build payload: category auto-set to FUEL (resolved dynamically by code)
// 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,
category_id: fuelCategoryId.value, // FUEL category (resolved by code)
amount_net: form.amount,
currency: '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`,

File diff suppressed because it is too large Load Diff

View File

@@ -4,29 +4,15 @@
v-if="authStore.isAuthenticated"
@click.stop="isOpen = !isOpen"
:disabled="authStore.isLoading"
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
:class="orgButtonClass"
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer text-white/70 hover:bg-white/5 hover:text-white"
>
<!-- Loading spinner -->
<svg
v-if="authStore.isLoading"
class="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
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" />
<!-- Garage icon -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<!-- Icon -->
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="orgButtonState === 'create'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span>{{ orgButtonLabel }}</span>
<!-- Chevron down when there are organizations -->
<span>{{ t('header.garageSelector') }}</span>
<!-- Chevron down -->
<svg
v-if="authStore.myOrganizations.length > 0 && orgButtonState === 'switch'"
class="w-3 h-3 transition-transform"
:class="{ 'rotate-180': isOpen }"
fill="none"
@@ -37,7 +23,7 @@
</svg>
</button>
<!-- Organization Dropdown -->
<!-- Garage Selector Dropdown -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="scale-95 opacity-0 -translate-y-2"
@@ -50,67 +36,79 @@
v-if="isOpen"
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- When on Company Garage: show "👤 Privát Garázs" back button -->
<div v-if="isOnCompanyGarage" class="py-1 border-b border-white/10">
<button
@click="goToPersonalDashboard"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-gray-200 transition-all duration-150 hover:bg-white/10 hover:text-white"
<!-- Private Garage (always first) -->
<button
@click="goToPersonalDashboard"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
:class="!isOnCompanyGarage ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span>👤 {{ t('header.privateGarage') }}</span>
<svg
v-if="!isOnCompanyGarage"
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg class="w-4 h-4 text-gray-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>👤 {{ t('header.privateGarage') }}</span>
</button>
</div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Organization list -->
<div v-if="filteredOrganizations.length > 0" class="py-1 max-h-60 overflow-y-auto">
<router-link
v-for="org in filteredOrganizations"
:key="org.organization_id"
:to="'/organization/' + org.organization_id"
@click="isOpen = false"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
:class="isActiveOrg(org) ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
>
<!-- Org icon -->
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span class="truncate">{{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}</span>
<!-- Active checkmark -->
<svg
v-if="isActiveOrg(org)"
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<!-- Company Garages (if any) -->
<template v-if="companyOrganizations.length > 0">
<div class="border-t border-white/10 my-1"></div>
<div class="px-4 py-1.5 text-xs text-white/40 uppercase tracking-wider font-semibold">
{{ t('header.myCompanies') }}
</div>
<div class="max-h-60 overflow-y-auto">
<router-link
v-for="org in companyOrganizations"
:key="org.organization_id"
:to="'/organization/' + org.organization_id"
@click="isOpen = false"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
:class="isActiveOrg(org) ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</router-link>
</div>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span class="truncate">{{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}</span>
<svg
v-if="isActiveOrg(org)"
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</router-link>
</div>
</template>
<!-- Empty state when no orgs exist -->
<div v-if="authStore.myOrganizations.length === 0" class="py-4 px-4 text-center text-white/50 text-sm">
{{ t('header.noCompanies') }}
</div>
<!-- Divider before "Create new" -->
<hr class="border-gray-700 my-1" />
<!-- Create new organization -->
<div class="py-1">
<button
@click="goToOnboard"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>{{ t('header.newCompany') }}</span>
</button>
</div>
<!-- Separator + PLG actions -->
<div class="border-t border-white/10 my-1"></div>
<button
@click="handleCreateCompany"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>{{ t('header.createCompany') }}</span>
</button>
<button
@click="handleJoinCompany"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7l3-3m0 0l3-3m-3 3l-3-3m3 3l3 3M8 21l-3 3m0 0l-3 3m3-3l-3-3m3 3l3-3M4 4l16 16" />
</svg>
<span>{{ t('header.joinCompany') }}</span>
</button>
</div>
</Transition>
</div>
@@ -136,7 +134,15 @@ onMounted(async () => {
if (authStore.myOrganizations.length === 0 && authStore.isAuthenticated) {
await authStore.fetchMyOrganizations()
}
console.log('🔍 [HeaderCompanySwitcher] Available companies:', authStore.myOrganizations)
console.log('🔍 [HeaderCompanySwitcher] All orgs:', authStore.myOrganizations)
console.log('🔍 [HeaderCompanySwitcher] Company orgs (filtered):', companyOrganizations.value)
})
// ── Filter: only real companies (exclude individual/private orgs) ──
const companyOrganizations = computed(() => {
return authStore.myOrganizations.filter(
(org) => org.org_type && org.org_type !== 'individual'
)
})
// ── Route-based detection ─────────────────────────────────────────
@@ -144,55 +150,6 @@ const isOnCompanyGarage = computed(() => {
return route.path.startsWith('/organization/')
})
// ── Current org ID from route (when on company garage) ────────────
const currentOrgId = computed(() => {
if (!isOnCompanyGarage.value) return null
return Number(route.params.id) || null
})
// ── Filtered organizations: exclude the current one when on garage ─
const filteredOrganizations = computed(() => {
const orgs = authStore.myOrganizations
if (isOnCompanyGarage.value && currentOrgId.value) {
return orgs.filter((o) => o.organization_id !== currentOrgId.value)
}
return orgs
})
// ── Organization Button State ──────────────────────────────────────
const orgButtonState = computed(() => {
if (!authStore.isAuthenticated) return 'create'
if (isOnCompanyGarage.value) return 'on-garage'
if (authStore.isCorporateMode) return 'corporate'
if (authStore.hasBusinessOrganization) return 'switch'
return 'create'
})
const orgButtonLabel = computed(() => {
switch (orgButtonState.value) {
case 'create':
return t('header.newCompany')
case 'switch': {
const count = authStore.myOrganizations.length
if (count === 1) return t('header.myCompany')
return t('header.myCompanies')
}
case 'corporate':
return t('header.personalGarage')
case 'on-garage':
return t('header.privateGarage')
default:
return t('header.company')
}
})
const orgButtonClass = computed(() => {
if (orgButtonState.value === 'corporate') {
return 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/20 hover:bg-yellow-500/25 hover:text-yellow-300'
}
return 'text-white/70 hover:bg-white/5 hover:text-white'
})
// ── Check if org is the active one ─────────────────────────────────
function isActiveOrg(org: OrganizationItem): boolean {
return authStore.user?.active_organization_id === org.organization_id
@@ -205,12 +162,20 @@ async function goToPersonalDashboard() {
router.push('/dashboard')
}
// ── Navigate to onboard ───────────────────────────────────────────
function goToOnboard() {
// ── PLG: Create Company ───────────────────────────────────────────
function handleCreateCompany() {
isOpen.value = false
console.log('🔧 [PLG] Create Company clicked — modal/onboarding coming soon')
router.push('/company/onboard')
}
// ── PLG: Join Company ─────────────────────────────────────────────
function handleJoinCompany() {
isOpen.value = false
console.log('🔧 [PLG] Join Company clicked — join flow coming soon')
alert('🔗 Join Company — This feature is coming soon!')
}
// ── Close dropdown on outside click ───────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement

View File

@@ -50,6 +50,28 @@
</svg>
{{ t('header.profile') }}
</button>
<!-- Admin Center only for admin/superadmin roles -->
<button
v-if="isAdmin"
@click="goToAdmin"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-yellow-400/80 transition-all duration-150 hover:bg-white/5 hover:text-yellow-300"
>
<svg
class="h-4 w-4 text-yellow-400/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
{{ t('header.adminCenter') }}
</button>
</div>
<!-- Divider -->
@@ -120,12 +142,26 @@ const initials = computed(() => {
return '?'
})
// ── Admin check: only admin/superadmin/region_admin/country_admin/moderator ──
const isAdmin = computed(() => {
const role = authStore.user?.role
if (!role) return false
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
return adminRoles.includes(role)
})
// ── Navigate to Profile ────────────────────────────────────────────
function goToProfile() {
isOpen.value = false
router.push('/profile')
}
// ── Navigate to Admin Center ───────────────────────────────────────
function goToAdmin() {
isOpen.value = false
router.push('/admin')
}
// ── Logout handler ─────────────────────────────────────────────────
function handleLogout() {
isOpen.value = false

View File

@@ -0,0 +1,318 @@
<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="handleBackdropClick"
>
<div
class="relative w-full max-w-lg mx-4 rounded-2xl bg-[#04151F] border border-white/10 shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h3 class="text-lg font-semibold text-white">
{{ isEditing ? t('company.settingsModalTitle') : t('company.companyDataTitle') }}
</h3>
<button
@click="$emit('close')"
class="p-1 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-8 w-8 text-[#00E5A0]" xmlns="http://www.w3.org/2000/svg" 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>
</div>
<template v-else>
<!-- Company Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsName') }}</label>
<input
v-if="isEditing"
v-model="form.name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.name || '—' }}</p>
</div>
<!-- Full Company Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsFullName') }}</label>
<input
v-if="isEditing"
v-model="form.full_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.full_name || '—' }}</p>
</div>
<!-- Display Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsDisplayName') }}</label>
<input
v-if="isEditing"
v-model="form.display_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.display_name || '—' }}</p>
</div>
<!-- Tax Number (DISABLED in edit mode too) -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsTaxNumber') }}</label>
<input
v-if="isEditing"
v-model="form.tax_number"
type="text"
disabled
class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white/50 text-sm cursor-not-allowed"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.tax_number || '—' }}</p>
</div>
<!-- Separator -->
<div class="border-t border-white/10 my-2"></div>
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.addressTitle') || 'Address' }}</p>
<!-- ZIP Code -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressZip') }}</label>
<input
v-if="isEditing"
v-model="form.address_zip"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_zip || '—' }}</p>
</div>
<!-- City -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressCity') }}</label>
<input
v-if="isEditing"
v-model="form.address_city"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_city || '—' }}</p>
</div>
<!-- Street -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressStreet') }}</label>
<input
v-if="isEditing"
v-model="form.address_street_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_street_name || '—' }}</p>
</div>
<!-- House Number -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressHouseNumber') }}</label>
<input
v-if="isEditing"
v-model="form.address_house_number"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
<p v-else class="text-sm text-white/90 px-1">{{ org?.address_house_number || '—' }}</p>
</div>
</template>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10">
<button
@click="isEditing ? cancelEdit() : $emit('close')"
class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors"
>
{{ isEditing ? t('profile.cancel') : t('company.close') || t('header.close') }}
</button>
<!-- Edit button (read-only mode) -->
<button
v-if="!isEditing"
@click="startEdit"
class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm transition-colors"
>
{{ t('profile.edit') }}
</button>
<!-- Save button (edit mode) -->
<button
v-else
@click="handleSave"
:disabled="isSaving"
class="px-4 py-2 rounded-lg bg-[#00E5A0] hover:bg-[#00E5A0]/80 text-[#04151F] font-semibold text-sm transition-all duration-200 disabled:opacity-50"
>
<span v-if="isSaving" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" 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>
{{ t('company.submitting') }}
</span>
<span v-else>{{ t('profile.save') }}</span>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/axios'
import type { OrganizationItem } from '@/types/organization'
const { t } = useI18n()
const route = useRoute()
const authStore = useAuthStore()
const emit = defineEmits<{
close: []
saved: []
}>()
// ── State ───────────────────────────────────────────────────────────────────
const isLoading = ref(false)
const isSaving = ref(false)
const isEditing = ref(false)
const org = ref<OrganizationItem | null>(null)
const form = reactive({
name: '',
full_name: '',
display_name: '',
tax_number: '',
address_zip: '',
address_city: '',
address_street_name: '',
address_house_number: '',
})
// ── Lifecycle ───────────────────────────────────────────────────────────────
onMounted(async () => {
const orgId = Number(route.params.id)
if (!orgId) return
// Find org from store
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (found) {
org.value = found
prefillForm(found)
} else {
// Try to fetch fresh data
isLoading.value = true
try {
await authStore.fetchMyOrganizations()
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (refound) {
org.value = refound
prefillForm(refound)
}
} catch (err) {
console.error('Failed to load organization:', err)
} finally {
isLoading.value = false
}
}
})
// ── Helpers ─────────────────────────────────────────────────────────────────
function prefillForm(data: OrganizationItem) {
form.name = data.name || ''
form.full_name = data.full_name || ''
form.display_name = data.display_name || ''
form.tax_number = data.tax_number || ''
// Address fields may come from a nested address object or flat fields
// We use type-safe access with optional chaining
form.address_zip = (data as any).address_zip || ''
form.address_city = (data as any).address_city || ''
form.address_street_name = (data as any).address_street_name || ''
form.address_house_number = (data as any).address_house_number || ''
}
function startEdit() {
// Re-populate form from current org data
if (org.value) {
prefillForm(org.value)
}
isEditing.value = true
}
function cancelEdit() {
isEditing.value = false
// Reset form to original values
if (org.value) {
prefillForm(org.value)
}
}
function handleBackdropClick() {
if (isEditing.value) {
cancelEdit()
} else {
emit('close')
}
}
// ── Save Handler ────────────────────────────────────────────────────────────
async function handleSave() {
const orgId = Number(route.params.id)
if (!orgId) return
isSaving.value = true
try {
// Build payload with only non-empty fields
const payload: Record<string, string> = {}
for (const [key, value] of Object.entries(form)) {
if (value.trim()) {
payload[key] = value.trim()
}
}
await api.patch(`/organizations/${orgId}`, payload)
// Refresh org data in store
await authStore.fetchMyOrganizations()
// Update local org reference
const updated = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (updated) {
org.value = updated
}
isEditing.value = false
emit('saved')
} catch (err: any) {
console.error('Failed to save organization data:', err)
alert(t('company.settingsSaveError'))
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,251 @@
<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div
class="relative w-full max-w-lg mx-4 rounded-2xl bg-[#04151F] border border-white/10 shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h3 class="text-lg font-semibold text-white">{{ t('company.settingsModalTitle') }}</h3>
<button
@click="$emit('close')"
class="p-1 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-8 w-8 text-[#00E5A0]" xmlns="http://www.w3.org/2000/svg" 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>
</div>
<template v-else>
<!-- Company Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsName') }}</label>
<input
v-model="form.name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
:placeholder="org?.name || ''"
/>
</div>
<!-- Full Company Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsFullName') }}</label>
<input
v-model="form.full_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
:placeholder="org?.full_name || ''"
/>
</div>
<!-- Display Name -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsDisplayName') }}</label>
<input
v-model="form.display_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
:placeholder="org?.display_name || ''"
/>
</div>
<!-- Tax Number -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsTaxNumber') }}</label>
<input
v-model="form.tax_number"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
:placeholder="org?.tax_number || ''"
/>
</div>
<!-- Separator -->
<div class="border-t border-white/10 my-2"></div>
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.addressTitle') }}</p>
<!-- ZIP Code -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressZip') }}</label>
<input
v-model="form.address_zip"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
</div>
<!-- City -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressCity') }}</label>
<input
v-model="form.address_city"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
</div>
<!-- Street -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressStreet') }}</label>
<input
v-model="form.address_street_name"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
</div>
<!-- House Number -->
<div>
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.settingsAddressHouseNumber') }}</label>
<input
v-model="form.address_house_number"
type="text"
class="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0] placeholder-white/30"
/>
</div>
</template>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10">
<button
@click="$emit('close')"
class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors"
>
{{ t('company.cancel') }}
</button>
<button
@click="handleSave"
:disabled="isSaving"
class="px-4 py-2 rounded-lg bg-[#00E5A0] hover:bg-[#00E5A0]/80 text-[#04151F] font-semibold text-sm transition-all duration-200 disabled:opacity-50"
>
<span v-if="isSaving" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" 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>
{{ t('company.submitting') }}
</span>
<span v-else>{{ t('profile.save') }}</span>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/axios'
import type { OrganizationItem } from '@/types/organization'
const { t } = useI18n()
const route = useRoute()
const authStore = useAuthStore()
const emit = defineEmits<{
close: []
saved: []
}>()
// ── State ───────────────────────────────────────────────────────────────────
const isLoading = ref(false)
const isSaving = ref(false)
const org = ref<OrganizationItem | null>(null)
const form = reactive({
name: '',
full_name: '',
display_name: '',
tax_number: '',
address_zip: '',
address_city: '',
address_street_name: '',
address_house_number: '',
})
// ── Lifecycle ───────────────────────────────────────────────────────────────
onMounted(async () => {
const orgId = Number(route.params.id)
if (!orgId) return
// Find org from store
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (found) {
org.value = found
// Pre-fill form with existing values
form.name = found.name || ''
form.full_name = found.full_name || ''
form.display_name = found.display_name || ''
form.tax_number = found.tax_number || ''
} else {
// Try to fetch fresh data
isLoading.value = true
try {
await authStore.fetchMyOrganizations()
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
if (refound) {
org.value = refound
form.name = refound.name || ''
form.full_name = refound.full_name || ''
form.display_name = refound.display_name || ''
form.tax_number = refound.tax_number || ''
}
} catch (err) {
console.error('Failed to load organization:', err)
} finally {
isLoading.value = false
}
}
})
// ── Save Handler ────────────────────────────────────────────────────────────
async function handleSave() {
const orgId = Number(route.params.id)
if (!orgId) return
isSaving.value = true
try {
// Build payload with only non-empty fields
const payload: Record<string, string> = {}
for (const [key, value] of Object.entries(form)) {
if (value.trim()) {
payload[key] = value.trim()
}
}
await api.patch(`/organizations/${orgId}`, payload)
// Refresh org data in store
await authStore.fetchMyOrganizations()
emit('saved')
} catch (err: any) {
console.error('Failed to save organization settings:', err)
alert(t('company.settingsSaveError'))
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -170,168 +170,144 @@
<!-- 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>
<!-- Loading State -->
<div
v-if="costsLoading"
class="flex items-center justify-center py-12"
>
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</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>
<template v-if="!costsLoading">
<!-- 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">{{ totalCostDisplay }}</p>
<p class="text-xs text-slate-400 mt-1">~ {{ dailyCostDisplay }} / nap</p>
</div>
<!-- Real Costs Table (Free users) -->
<template v-if="!isPremium">
<div v-if="vehicleCosts.length === 0" class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center">
<p class="text-sm text-slate-500">Nincs rögzített költség</p>
</div>
<div v-else 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">Dátum</th>
<th class="text-left px-4 py-3 font-semibold text-slate-600">Kategória</th>
<th class="text-right px-4 py-3 font-semibold text-slate-600">Összeg</th>
</tr>
</thead>
<tbody>
<tr
v-for="cost in vehicleCosts"
:key="cost.id"
class="border-b border-slate-200 last:border-b-0"
>
<td class="px-4 py-2.5 text-slate-600">{{ formatDate(cost.occurrence_date) }}</td>
<td class="px-4 py-2.5 text-slate-600">{{ cost.category_name || cost.category }}</td>
<td class="px-4 py-2.5 text-right font-semibold text-slate-800">{{ formatAmount(cost.amount) }}</td>
</tr>
</tbody>
<tfoot>
<tr class="bg-slate-100 border-t border-slate-200">
<td colspan="2" 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">{{ totalCostDisplay }}</td>
</tr>
</tfoot>
</table>
</div>
</template>
<!-- Premium users: detailed breakdown with charts -->
<template v-if="isPremium">
<div v-if="vehicleCosts.length === 0" class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center">
<p class="text-sm text-slate-500">Nincs rögzített költség</p>
</div>
<div v-else 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
v-for="cost in fixedCosts"
:key="cost.id"
class="flex items-center justify-between"
>
<span class="text-xs text-slate-500">{{ cost.category_name || cost.category }}</span>
<span class="text-sm font-bold text-slate-700">{{ formatAmount(cost.amount) }}</span>
</div>
<div v-if="fixedCosts.length === 0" class="text-xs text-slate-400 text-center py-2">Nincs állandó költség</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">{{ formatAmount(fixedTotal) }}</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
v-for="cost in runningCosts"
:key="cost.id"
class="flex items-center justify-between"
>
<span class="text-xs text-slate-500">{{ cost.category_name || cost.category }}</span>
<span class="text-sm font-bold text-slate-700">{{ formatAmount(cost.amount) }}</span>
</div>
<div v-if="runningCosts.length === 0" class="text-xs text-slate-400 text-center py-2">Nincs folyó költség</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">{{ formatAmount(runningTotal) }}</span>
</div>
</div>
</div>
</div>
<!-- Premium: Cost distribution chart -->
<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
v-for="cat in costDistribution"
:key="cat.label"
>
<div class="flex justify-between text-xs text-slate-500 mb-1">
<span>{{ cat.label }}</span>
<span>{{ cat.percent }}%</span>
</div>
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="cat.color"
:style="{ width: cat.percent + '%' }"
/>
</div>
</div>
<div v-if="costDistribution.length === 0" class="text-xs text-slate-400 text-center py-2">Nincs adat</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> ~ {{ tcoPerKmDisplay }}
<span class="text-slate-300 mx-2">|</span>
<span class="font-semibold text-slate-700">Havi átlag:</span> ~ {{ monthlyAverageDisplay }}
</p>
</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 -->
@@ -448,6 +424,7 @@
import { ref, computed, watch } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import { useAuthStore } from '../../stores/auth'
import api from '../../api/axios'
import VehiclePlateBadge from './VehiclePlateBadge.vue'
const props = defineProps<{
@@ -476,9 +453,114 @@ const tabs = [
watch(() => props.isOpen, (newVal) => {
if (newVal) {
activeTab.value = 'basics'
vehicleCosts.value = []
costsLoading.value = false
}
})
// ── Financials: Vehicle Costs from API ──
interface CostItem {
id: string | number
/** Backend returns `date` (ISO datetime), mapped to `occurrence_date` for display */
occurrence_date: string
/** Backend returns `category_name` from CostCategory relationship */
category: string
category_name?: string
/** Backend returns `category_code` from CostCategory relationship */
category_code?: string
/** Backend returns `amount_net` (Decimal) — the main monetary value */
amount: number
/** Backend returns `currency` (e.g. 'HUF') */
currency?: string
/** Backend returns `description` extracted from data JSONB */
description?: string
/** Backend returns `mileage_at_cost` extracted from data JSONB */
mileage_at_cost?: number
/** Raw backend fields kept for reference */
amount_net?: number
currency_raw?: string
date?: string
data?: Record<string, any>
}
const vehicleCosts = ref<CostItem[]>([])
const costsLoading = ref(false)
/**
* Map a raw backend AssetCostResponse item to the frontend CostItem shape.
* Backend schema (AssetCostResponse) now uses:
* amount_net, currency, date, category_name, category_code, description, mileage_at_cost
*/
function mapBackendCost(raw: any): CostItem {
return {
id: raw.id,
occurrence_date: raw.date || raw.occurrence_date || '',
category: raw.category_name || raw.category_code || raw.category || '',
category_name: raw.category_name,
category_code: raw.category_code,
amount: Number(raw.amount_net ?? raw.amount ?? 0),
currency: raw.currency || 'HUF',
description: raw.description || raw.data?.description || '',
mileage_at_cost: raw.mileage_at_cost ?? raw.data?.mileage_at_cost,
// Keep raw fields for reference
amount_net: raw.amount_net,
currency_raw: raw.currency,
date: raw.date,
data: raw.data,
}
}
/**
* Fetch costs for the current vehicle when the 'finance' tab is activated.
* GET /api/v1/assets/{vehicle.id}/costs
*/
watch(() => activeTab.value, async (tab) => {
if (tab === 'finance' && props.vehicle?.id) {
costsLoading.value = true
try {
const res = await api.get(`/assets/${props.vehicle.id}/costs`)
const rawData = (res.data as any[]) || []
vehicleCosts.value = rawData.map(mapBackendCost)
} catch (err: any) {
console.error('[VehicleDetailModal] Failed to fetch costs:', err)
vehicleCosts.value = []
} finally {
costsLoading.value = false
}
}
})
/**
* Also fetch costs when the vehicle prop changes while on the finance tab.
*/
watch(() => props.vehicle, (newVehicle) => {
if (activeTab.value === 'finance' && newVehicle?.id) {
costsLoading.value = true
api.get(`/assets/${newVehicle.id}/costs`)
.then(res => {
const rawData = (res.data as any[]) || []
vehicleCosts.value = rawData.map(mapBackendCost)
})
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
.finally(() => { costsLoading.value = false })
}
})
// ── Format date helper ──
function formatDate(dateStr: string): string {
if (!dateStr) return '—'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return '—'
return d.toLocaleDateString('hu-HU', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
// ── Format currency helper ──
function formatAmount(amount: number): string {
if (amount === undefined || amount === null) return '—'
if (isNaN(amount)) return '—'
return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: 'HUF', maximumFractionDigits: 0 }).format(amount)
}
// ── Task 4: Trust Score computed helpers ──
const trustScorePercent = computed(() => {
const v = props.vehicle
@@ -543,6 +625,100 @@ const hpDisplay = computed(() => {
if (!kw) return '—'
return `${Math.round(kw * 1.341)} LE`
})
// ── Financial computed helpers ──
const totalCost = computed(() => {
return vehicleCosts.value.reduce((sum, c) => {
const amt = Number(c.amount)
return sum + (isNaN(amt) ? 0 : amt)
}, 0)
})
const totalCostDisplay = computed(() => {
return formatAmount(totalCost.value)
})
const dailyCostDisplay = computed(() => {
if (!totalCost.value || totalCost.value === 0) return '0 Ft'
const daily = totalCost.value / 365
return formatAmount(daily)
})
const monthlyAverageDisplay = computed(() => {
if (!totalCost.value || totalCost.value === 0) return '0 Ft'
const monthly = totalCost.value / 12
return formatAmount(monthly)
})
const tcoPerKmDisplay = computed(() => {
const mileage = props.vehicle?.current_mileage || props.vehicle?.mileage
if (!mileage || mileage === 0 || !totalCost.value || totalCost.value === 0) return '—'
const perKm = totalCost.value / mileage
return formatAmount(perKm) + '/km'
})
// ── Premium breakdown: fixed vs running costs ──
const fixedCategories = ['insurance', 'tax', 'inspection', 'Biztosítás', 'Gépjárműadó', 'Műszaki vizsga', 'Adó', 'Kötelező']
const fixedCosts = computed(() => {
return vehicleCosts.value.filter(c => {
const cat = (c.category || '').toLowerCase()
const name = (c.category_name || '').toLowerCase()
return fixedCategories.some(fc => cat.includes(fc.toLowerCase()) || name.includes(fc.toLowerCase()))
})
})
const runningCosts = computed(() => {
return vehicleCosts.value.filter(c => {
const cat = (c.category || '').toLowerCase()
const name = (c.category_name || '').toLowerCase()
return !fixedCategories.some(fc => cat.includes(fc.toLowerCase()) || name.includes(fc.toLowerCase()))
})
})
const fixedTotal = computed(() => fixedCosts.value.reduce((sum, c) => {
const amt = Number(c.amount)
return sum + (isNaN(amt) ? 0 : amt)
}, 0))
const runningTotal = computed(() => runningCosts.value.reduce((sum, c) => {
const amt = Number(c.amount)
return sum + (isNaN(amt) ? 0 : amt)
}, 0))
// ── Cost distribution for premium chart ──
const chartColors = [
'bg-blue-500',
'bg-emerald-500',
'bg-amber-500',
'bg-purple-500',
'bg-rose-500',
'bg-cyan-500',
'bg-orange-500',
'bg-teal-500',
]
interface DistributionItem {
label: string
percent: number
color: string
}
const costDistribution = computed<DistributionItem[]>(() => {
if (!totalCost.value || totalCost.value === 0) return []
// Group by category_name or category
const grouped: Record<string, number> = {}
for (const c of vehicleCosts.value) {
const key = c.category_name || c.category || 'Egyéb'
const amt = Number(c.amount)
grouped[key] = (grouped[key] || 0) + (isNaN(amt) ? 0 : amt)
}
return Object.entries(grouped)
.map(([label, amount], idx) => ({
label,
percent: Math.round((amount / totalCost.value) * 100),
color: chartColors[idx % chartColors.length],
}))
.sort((a, b) => b.percent - a.percent)
})
</script>
<style scoped>

View File

@@ -33,6 +33,10 @@ export default {
close: 'Close',
noCompanies: 'No companies yet',
newCompany: ' New Company',
joinCompany: '🔗 Join Company',
createCompany: ' Create Company',
actions: 'Actions',
garageSelector: 'Garage Selector',
myCompany: '🏢 My Company',
myCompanies: '🏢 My Companies',
personalGarage: '👤 Personal Garage',
@@ -50,6 +54,8 @@ export default {
regionSettings: 'Region Settings',
regionDescription: 'Date, time & currency format',
dashboard: 'Dashboard',
menu: 'Menu',
adminCenter: '🔐 Admin Center',
},
dashboard: {
loading: 'Loading...',
@@ -126,6 +132,20 @@ export default {
registerFailed: 'Registration failed.',
resendFailed: 'Resend failed. Please try again later.',
forgotFailed: 'An error occurred while sending the request. Please try again.',
// Account Restore (30-day)
restoreLink: 'Restore deleted account',
restoreTitle: 'Restore Deleted Account',
restoreStep1: 'Enter your email address to request a restoration code.',
restoreStep2: 'Enter the received code and your new password.',
restoreEmailLabel: 'Email Address',
restoreSendCode: 'Send Restoration Code',
restoreOtpLabel: 'Restoration Code (6 digits)',
restoreNewPassword: 'New Password',
restoreVerifyButton: 'Restore Account',
restoreSuccess: 'Your account has been successfully restored! Auto-logging in...',
restoreError: 'Restoration failed. Please try again.',
restoreNotFound: 'No deleted account found with this email address.',
restoreCodeSent: 'The restoration code has been sent to your email!',
},
profile: {
title: 'Profile Settings',
@@ -205,6 +225,18 @@ export default {
currencyHUF: 'HUF (Ft)',
currencyEUR: 'EUR (€)',
currencyUSD: 'USD ($)',
// Delete Account
deleteAccount: 'Delete Account',
deleteAccountConfirm: 'Are you sure you want to delete your account?',
deleteAccountWarning: 'Are you sure you want to delete your account? This action is permanent and cannot be undone.',
deleteAccountReason: 'Why do you want to delete your account? (optional)',
deleteAccountButton: 'Yes, delete my account',
deleteAccountCancel: 'Cancel',
deleteSuccess: 'Your account has been successfully deleted. Redirecting...',
deleteError: 'An error occurred while deleting your account.',
// Last Admin Warning
lastAdminWarningTitle: '⚠️ Last Admin Warning',
lastAdminWarning: 'Warning! You are the last active administrator of your company. Deleting your account will put company data and fleet into passive (orphan) state!',
// Back
backToGarage: 'Back to Garage',
// Diagnostic
@@ -283,6 +315,23 @@ export default {
quickActionsTitle: 'Quick Actions',
quickNewWaybill: 'New Waybill',
quickReportIssue: 'Report Issue',
// Organization Settings
companyDataTitle: 'Company Data',
companyDataMenu: 'Company Data',
close: 'Close',
settingsTitle: 'Company Settings',
settingsDescription: 'Edit company details',
settingsModalTitle: 'Edit Company Details',
settingsName: 'Company Name (short)',
settingsFullName: 'Full Company Name',
settingsTaxNumber: 'Tax Number',
settingsDisplayName: 'Display Name',
settingsAddressZip: 'ZIP Code',
settingsAddressCity: 'City',
settingsAddressStreet: 'Street',
settingsAddressHouseNumber: 'House Number',
settingsSaveSuccess: 'Company details updated successfully!',
settingsSaveError: 'Error saving company details.',
// CompanyOnboardingView
onboardingTitle: 'Company Registration — 3 simple steps',
stepLegal: 'Company Data',
@@ -341,6 +390,25 @@ export default {
submitError: 'Company creation failed. Please try again.',
// Success
successMessage: 'Company created successfully! Redirecting to garage...',
// Smart Company Claiming / Joining
activeExistsPanel: 'This company is already registered in the system.',
joinRequestButton: 'Send Join Request',
joinRequestSent: 'Your join request has been sent to the company administrator!',
orphanedPanel: 'This company was previously registered but is currently inactive. You can claim it.',
claimRequestButton: 'Request Company Claim',
claimCodeSent: 'The claim code has been sent to the company\'s official email!',
claimVerifyTitle: 'Verify Claim',
claimOtpLabel: 'Claim Code (6 digits)',
claimVerifyButton: 'Confirm Claim',
claimSuccess: 'Company successfully assigned to your account!',
claimError: 'Claim failed. Please try again.',
manualFallbackTitle: '📄 Manual Claim (Upload Documents)',
manualFallbackDescription: 'If none of the above options work, upload documents (e.g., court ruling, signature specimen) for manual admin approval.',
uploadDocumentLabel: 'Upload Document',
uploadDocumentButton: 'Choose File',
manualSubmitButton: 'Submit Claim',
manualSubmitSuccess: 'Your request has been submitted for admin approval!',
domainMismatch: 'Your email domain does not match the company\'s domain. Please use the manual claim option.',
},
organization: {
backToPrivate: '⬅️ Back to Private Garage',
@@ -353,4 +421,139 @@ export default {
hrsz: 'Parcel No.: {value}',
placeholder: '—',
},
admin: {
services: {
title: 'Services',
subtitle: 'Manage service catalog entries, credit costs, and availability',
create_button: 'Create New Service',
refresh: 'Refresh',
loading: 'Loading services...',
empty: 'No services found',
empty_hint: 'Create your first service to get started.',
badge_inactive: 'Inactive',
credit_cost_label: 'Credit Cost',
credit_unit: 'credits',
edit_button: 'Edit',
deactivate_button: 'Deactivate',
activate_button: 'Activate',
// Modal
modal_edit_title: 'Edit Service',
modal_create_title: 'Create New Service',
field_code: 'Service Code',
field_code_placeholder: 'e.g. SRV_DATA_EXPORT',
field_name: 'Service Name',
field_name_placeholder: 'e.g. Data Export',
field_description: 'Description',
field_description_placeholder: 'Describe what this service does...',
field_credit_cost: 'Credit Cost',
cancel: 'Cancel',
saving: 'Saving...',
save_changes: 'Save Changes',
create: 'Create Service',
},
packages: {
title: 'Subscription Packages',
subtitle: 'Manage subscription tiers, pricing, and entitlements',
create_button: 'Create New Package',
show_hidden: 'Show inactive packages',
refresh: 'Refresh',
loading: 'Loading packages...',
empty: 'No packages found',
empty_hint: 'Create your first subscription package to get started.',
badge_inactive: 'Inactive',
badge_custom: 'Custom',
type_private: 'Private',
type_corporate: 'Corporate',
monthly: 'Monthly',
yearly: 'Yearly',
max_vehicles_label: 'Max Vehicles',
max_garages_label: 'Max Garages',
free_credits_label: 'Free Credits / mo',
edit_button: 'Edit',
delete_button: 'Deactivate',
// Modal
modal_edit_title: 'Edit Package',
modal_create_title: 'Create New Package',
field_name: 'Package Name',
field_name_placeholder: 'e.g. private_pro_v2',
field_type: 'Package Type',
field_monthly_price: 'Monthly Price',
field_yearly_price: 'Yearly Price',
field_currency: 'Currency',
field_max_vehicles: 'Max Vehicles',
field_max_garages: 'Max Garages',
field_free_credits: 'Free Credits / Month',
field_commission_rate: 'Commission Rate (%)',
field_referral_bonus: 'Referral Bonus (credits)',
field_credit_price: 'Price (Credits)',
field_is_public: 'Active / Inactive',
field_available_until: 'Expiry Date',
active: 'Active',
inactive: 'Inactive',
date_from_label: 'Date From',
date_until_label: 'Date Until',
field_entitlements: 'Services (Entitlements)',
entitlement_placeholder: 'Select a service to add...',
cancel: 'Cancel',
saving: 'Saving...',
save_changes: 'Save Changes',
create: 'Create Package',
},
users: {
// Search & Filters
search_category_all: 'All',
search_category_email: 'E-mail',
search_category_name: 'Name',
search_category_phone: 'Phone',
search_category_id: 'ID',
search_category_address: 'Address',
search_placeholder: 'Search...',
search_button: 'Search',
clear_search: 'Clear search',
filter_all_roles: 'All Roles',
filter_all_status: 'All Status',
status_active: 'Active',
status_inactive: 'Inactive',
status_deleted: 'Deleted',
refresh: 'Refresh',
// Bulk actions
n_selected: '{count} user(s) selected',
bulk_ban: 'Ban',
bulk_unban: 'Unban',
bulk_soft_delete: 'Soft Delete',
bulk_restore: 'Restore',
bulk_hard_delete: 'Hard Delete',
clear_selection: 'Clear Selection',
loading: 'Loading...',
// Table columns
col_email: 'E-mail',
col_name: 'Name',
col_phone: 'Phone',
col_address: 'Address',
col_role: 'Role',
col_status: 'Status',
col_subscription: 'Subscription',
col_region: 'Region',
col_created: 'Created',
col_actions: 'Actions',
// Individual actions (kebab menu)
actions: 'Actions',
action_edit: 'Edit',
action_password_reset: 'Password Reset E-mail',
action_ban: 'Ban',
action_unban: 'Unban',
action_soft_delete: 'Soft Delete',
action_restore: 'Restore',
action_hard_delete: 'Hard Delete',
// Empty states
empty_search: 'No users match your search',
empty_search_hint: 'Try a different search category or term.',
empty_no_users: 'No users in the database yet.',
// Pagination
showing_of: 'Showing {showing} of {total} users',
pagination_prev: 'Previous',
pagination_next: 'Next',
pagination_page: 'Page {current} of {total}',
},
},
}

View File

@@ -33,6 +33,10 @@ export default {
close: 'Bezárás',
noCompanies: 'Még nincs céged',
newCompany: ' Új cég',
joinCompany: '🔗 Csatlakozás céghez',
createCompany: ' Cég létrehozása',
actions: 'Műveletek',
garageSelector: 'Garázs választó',
myCompany: '🏢 Cégem',
myCompanies: '🏢 Cégeim',
personalGarage: '👤 Személyes Garázs',
@@ -50,6 +54,8 @@ export default {
regionSettings: 'Területi beállítások',
regionDescription: 'Dátum, idő és pénznem formátum',
dashboard: 'Irányítópult',
menu: 'Menü',
adminCenter: '🔐 Adminisztrációs Központ',
},
dashboard: {
loading: 'Betöltés...',
@@ -126,6 +132,20 @@ export default {
registerFailed: 'Regisztráció sikertelen.',
resendFailed: 'Az újraküldés sikertelen. Kérlek próbáld újra később.',
forgotFailed: 'Hiba történt a kérelem elküldésekor. Kérlek próbáld újra.',
// Account Restore (30-day)
restoreLink: 'Törölt fiók visszaállítása',
restoreTitle: 'Törölt fiók visszaállítása',
restoreStep1: 'Add meg az e-mail címed, és kérj egy visszaállítási kódot.',
restoreStep2: 'Add meg a kapott kódot és az új jelszavadat.',
restoreEmailLabel: 'E-mail cím',
restoreSendCode: 'Visszaállítási kód küldése',
restoreOtpLabel: 'Visszaállítási kód (6 számjegy)',
restoreNewPassword: 'Új jelszó',
restoreVerifyButton: 'Fiók visszaállítása',
restoreSuccess: 'A fiókod sikeresen visszaállításra került! Automatikus bejelentkezés...',
restoreError: 'A visszaállítás sikertelen. Kérlek próbáld újra.',
restoreNotFound: 'Nem található törölt fiók ezzel az e-mail címmel.',
restoreCodeSent: 'A visszaállítási kódot elküldtük az e-mail címedre!',
},
profile: {
title: 'Profil beállítások',
@@ -205,6 +225,18 @@ export default {
currencyHUF: 'HUF (Ft)',
currencyEUR: 'EUR (€)',
currencyUSD: 'USD ($)',
// Delete Account
deleteAccount: 'Fiók törlése',
deleteAccountConfirm: 'Biztosan törölni szeretnéd a fiókodat?',
deleteAccountWarning: 'Biztosan törölni szeretnéd a fiókodat? Ez a művelet végleges és nem vonható vissza.',
deleteAccountReason: 'Miért szeretnéd törölni a fiókodat? (opcionális)',
deleteAccountButton: 'Igen, töröld a fiókomat',
deleteAccountCancel: 'Mégsem',
deleteSuccess: 'A fiókod sikeresen törlésre került. Átirányítás...',
deleteError: 'Hiba történt a fiók törlése során.',
// Last Admin Warning
lastAdminWarningTitle: '⚠️ Utolsó adminisztrátor figyelmeztetés',
lastAdminWarning: 'Figyelem! Te vagy a céged utolsó aktív adminisztrátora. A fiókod törlésével a cég adatai és flottája passzív (árva) állapotba kerül!',
// Back
backToGarage: 'Vissza a Garázsba',
// Diagnostic
@@ -283,6 +315,23 @@ export default {
quickActionsTitle: 'Gyorsműveletek',
quickNewWaybill: 'Új menetlevél',
quickReportIssue: 'Hiba bejelentése',
// Organization Settings
companyDataTitle: 'Cég adatai',
companyDataMenu: 'Cég adatai',
close: 'Bezárás',
settingsTitle: 'Cég beállítások',
settingsDescription: 'Cégadatok szerkesztése',
settingsModalTitle: 'Cégadatok szerkesztése',
settingsName: 'Cégnév (rövid)',
settingsFullName: 'Teljes cégnév',
settingsTaxNumber: 'Adószám',
settingsDisplayName: 'Megjelenítendő név',
settingsAddressZip: 'Irányítószám',
settingsAddressCity: 'Város',
settingsAddressStreet: 'Utca',
settingsAddressHouseNumber: 'Házszám',
settingsSaveSuccess: 'Cégadatok sikeresen frissítve!',
settingsSaveError: 'Hiba a cégadatok mentése során.',
// CompanyOnboardingView
onboardingTitle: 'Cég Regisztráció — 3 egyszerű lépésben',
stepLegal: 'Cégadatok',
@@ -341,6 +390,25 @@ export default {
submitError: 'A cég létrehozása sikertelen. Kérlek próbáld újra.',
// Success
successMessage: 'A cég sikeresen létrejött! Átirányítás a garázsba...',
// Smart Company Claiming / Joining
activeExistsPanel: 'Ez a cég már regisztrálva van a rendszerben.',
joinRequestButton: 'Csatlakozási kérelem küldése',
joinRequestSent: 'A csatlakozási kérelmet elküldtük a cég adminisztrátorának!',
orphanedPanel: 'Ez a cég korábban regisztrálva volt, de jelenleg inaktív. Igényelheted a céget.',
claimRequestButton: 'Cég igénylésének kérése',
claimCodeSent: 'Az igénylési kódot elküldtük a cég hivatalos e-mail címére!',
claimVerifyTitle: 'Igénylés ellenőrzése',
claimOtpLabel: 'Igénylési kód (6 számjegy)',
claimVerifyButton: 'Igénylés megerősítése',
claimSuccess: 'A cég sikeresen hozzárendelve a fiókodhoz!',
claimError: 'Az igénylés sikertelen. Kérlek próbáld újra.',
manualFallbackTitle: '📄 Kézi igénylés (dokumentumok feltöltése)',
manualFallbackDescription: 'Ha a fenti lehetőségek egyike sem működik, tölts fel dokumentumokat (pl. bírósági végzés, aláírási címpéldány) a manuális adminisztrátori jóváhagyáshoz.',
uploadDocumentLabel: 'Dokumentum feltöltése',
uploadDocumentButton: 'Fájl kiválasztása',
manualSubmitButton: 'Igénylés elküldése',
manualSubmitSuccess: 'A kérelmet elküldtük adminisztrátori jóváhagyásra!',
domainMismatch: 'Az e-mail címed domainje nem egyezik a cég domainjével. Kérlek használd a kézi igénylést.',
},
organization: {
backToPrivate: '⬅️ Vissza a Privát Garázsba',
@@ -353,4 +421,139 @@ export default {
hrsz: 'Hrsz.: {value}',
placeholder: '—',
},
admin: {
services: {
title: 'Szolgáltatások',
subtitle: 'Szolgáltatás katalógus bejegyzések, kredit árak és elérhetőség kezelése',
create_button: 'Új Szolgáltatás',
refresh: 'Frissítés',
loading: 'Szolgáltatások betöltése...',
empty: 'Nincsenek szolgáltatások',
empty_hint: 'Hozd létre az első szolgáltatást a kezdéshez.',
badge_inactive: 'Inaktív',
credit_cost_label: 'Kredit Ár',
credit_unit: 'kredit',
edit_button: 'Szerkesztés',
deactivate_button: 'Inaktívvá tétel',
activate_button: 'Aktiválás',
// Modal
modal_edit_title: 'Szolgáltatás Szerkesztése',
modal_create_title: 'Új Szolgáltatás Létrehozása',
field_code: 'Szolgáltatás Kód',
field_code_placeholder: 'pl. SRV_DATA_EXPORT',
field_name: 'Szolgáltatás Neve',
field_name_placeholder: 'pl. Adat Export',
field_description: 'Leírás',
field_description_placeholder: 'Írd le, mit nyújt ez a szolgáltatás...',
field_credit_cost: 'Kredit Ár',
cancel: 'Mégsem',
saving: 'Mentés...',
save_changes: 'Változások Mentése',
create: 'Szolgáltatás Létrehozása',
},
packages: {
title: 'Előfizetési Csomagok',
subtitle: 'Előfizetési csomagok, árak és jogosultságok kezelése',
create_button: 'Új Csomag Létrehozása',
show_hidden: 'Inaktív csomagok mutatása',
refresh: 'Frissítés',
loading: 'Csomagok betöltése...',
empty: 'Nincsenek csomagok',
empty_hint: 'Hozd létre az első előfizetési csomagot a kezdéshez.',
badge_inactive: 'Inaktív',
badge_custom: 'Egyedi',
type_private: 'Privát',
type_corporate: 'Vállalati',
monthly: 'Havi',
yearly: 'Éves',
max_vehicles_label: 'Max. Jármű',
max_garages_label: 'Max. Garázs',
free_credits_label: 'Ingyenes kredit / hó',
edit_button: 'Szerkesztés',
delete_button: 'Kivezetés',
// Modal
modal_edit_title: 'Csomag Szerkesztése',
modal_create_title: 'Új Csomag Létrehozása',
field_name: 'Csomag Neve',
field_name_placeholder: 'pl. private_pro_v2',
field_type: 'Csomag Típusa',
field_monthly_price: 'Havi Díj',
field_yearly_price: 'Éves Díj',
field_currency: 'Pénznem',
field_max_vehicles: 'Max. Járművek',
field_max_garages: 'Max. Garázsok',
field_free_credits: 'Ingyenes Kredit / Hó',
field_commission_rate: 'Jutalék (%)',
field_referral_bonus: 'Ajánlási Bónusz (kredit)',
field_credit_price: 'Ár (Kredit)',
field_is_public: 'Aktív / Inaktív',
field_available_until: 'Lejárat Dátuma',
active: 'Aktív',
inactive: 'Inaktív',
date_from_label: 'Dátum tól',
date_until_label: 'Dátum ig',
field_entitlements: 'Szolgáltatások (Jogosultságok)',
entitlement_placeholder: 'Válassz egy szolgáltatást...',
cancel: 'Mégsem',
saving: 'Mentés...',
save_changes: 'Változások Mentése',
create: 'Csomag Létrehozása',
},
users: {
// Search & Filters
search_category_all: 'Mind',
search_category_email: 'E-mail',
search_category_name: 'Név',
search_category_phone: 'Telefon',
search_category_id: 'Azonosító',
search_category_address: 'Cím',
search_placeholder: 'Keresés...',
search_button: 'Keresés',
clear_search: 'Keresés törlése',
filter_all_roles: 'Összes szerepkör',
filter_all_status: 'Összes állapot',
status_active: 'Aktív',
status_inactive: 'Inaktív',
status_deleted: 'Törölt',
refresh: 'Frissítés',
// Bulk actions
n_selected: '{count} felhasználó kiválasztva',
bulk_ban: 'Tiltás',
bulk_unban: 'Tiltás feloldása',
bulk_soft_delete: 'Lágy törlés',
bulk_restore: 'Visszaállítás',
bulk_hard_delete: 'Végleges törlés',
clear_selection: 'Kijelölés törlése',
loading: 'Betöltés...',
// Table columns
col_email: 'E-mail',
col_name: 'Név',
col_phone: 'Telefon',
col_address: 'Cím',
col_role: 'Szerepkör',
col_status: 'Állapot',
col_subscription: 'Előfizetés',
col_region: 'Régió',
col_created: 'Létrehozva',
col_actions: 'Műveletek',
// Individual actions (kebab menu)
actions: 'Műveletek',
action_edit: 'Szerkesztés',
action_password_reset: 'Jelszó visszaállító e-mail',
action_ban: 'Tiltás',
action_unban: 'Tiltás feloldása',
action_soft_delete: 'Lágy törlés',
action_restore: 'Visszaállítás',
action_hard_delete: 'Végleges törlés',
// Empty states
empty_search: 'Nincs a keresésnek megfelelő felhasználó',
empty_search_hint: 'Próbáld meg más keresési kategóriával vagy kifejezéssel.',
empty_no_users: 'Még nincsenek felhasználók az adatbázisban.',
// Pagination
showing_of: '{showing} / {total} felhasználó',
pagination_prev: 'Előző',
pagination_next: 'Következő',
pagination_page: '{current}. oldal / {total}',
},
},
}

View File

@@ -27,15 +27,34 @@
<span class="font-medium">Dashboard</span>
</router-link>
<router-link
to="/admin/users"
<router-link
to="/admin/users"
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path.startsWith('/admin/users') }"
>
<span class="text-xl">👥</span>
<span class="font-medium">User Management</span>
</router-link>
<router-link
<router-link
to="/admin/packages"
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path.startsWith('/admin/packages') }"
>
<span class="text-xl">📦</span>
<span class="font-medium">Subscription Packages</span>
</router-link>
<router-link
to="/admin/services"
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path.startsWith('/admin/services') }"
>
<span class="text-xl">🛠</span>
<span class="font-medium">Services</span>
</router-link>
<router-link
to="/admin/vehicles"
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
>
@@ -59,24 +78,23 @@
<span class="font-medium">System Settings</span>
</router-link>
<router-link
to="/admin/audit"
<router-link
to="/admin/audit"
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
>
<span class="text-xl">📋</span>
<span class="font-medium">Audit Logs</span>
</router-link>
<div class="pt-6 border-t border-gray-700">
<div class="px-4 py-2 text-xs text-gray-500 uppercase tracking-wider">Tools</div>
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors">
<span class="text-xl">🔍</span>
<span class="font-medium">Database Explorer</span>
</a>
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors">
<span class="text-xl">📈</span>
<span class="font-medium">Analytics</span>
</a>
<div class="px-4 py-2 text-xs text-gray-500 uppercase tracking-wider">Navigation</div>
<router-link
to="/dashboard"
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
>
<span class="text-xl">🏠</span>
<span class="font-medium">Back to Dashboard</span>
</router-link>
</div>
</nav>
@@ -84,15 +102,12 @@
<div class="p-4 border-t border-gray-700">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-600 to-gray-800 flex items-center justify-center">
<span class="text-lg">AD</span>
<span class="text-lg">{{ userInitials }}</span>
</div>
<div class="flex-1">
<div class="font-medium">Admin User</div>
<div class="text-xs text-gray-400">Super Administrator</div>
<div class="font-medium truncate">{{ userName }}</div>
<div class="text-xs text-gray-400 capitalize">{{ userRoleLabel }}</div>
</div>
<button class="p-2 hover:bg-gray-700 rounded-lg transition-colors">
<span class="text-xl"></span>
</button>
</div>
</div>
</div>
@@ -116,9 +131,8 @@
<ModeSwitcher />
</slot>
<button class="px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg hover:opacity-90 transition-opacity">
Quick Actions
</button>
<!-- HeaderProfile komponens: profil, admin center, kilépés -->
<HeaderProfile />
</div>
</div>
</header>
@@ -126,7 +140,7 @@
<!-- Content Area -->
<main class="p-8">
<div class="bg-gray-800/50 rounded-xl border border-gray-700 p-6">
<slot />
<router-view />
</div>
<!-- Footer Stats -->
@@ -156,10 +170,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
import ModeSwitcher from '../components/ModeSwitcher.vue'
import HeaderProfile from '../components/header/HeaderProfile.vue'
const route = useRoute()
const authStore = useAuthStore()
const currentTime = ref('')
const pageTitle = computed(() => {
@@ -167,9 +184,29 @@ const pageTitle = computed(() => {
return name.charAt(0).toUpperCase() + name.slice(1)
})
const userName = computed(() => {
if (!authStore.user) return 'Admin User'
const { first_name, last_name } = authStore.user
if (first_name && last_name) return `${first_name} ${last_name}`
return authStore.user.email || 'Admin User'
})
const userInitials = computed(() => {
if (!authStore.user) return 'AD'
const { first_name, last_name } = authStore.user
if (first_name && last_name) {
return (first_name[0] + last_name[0]).toUpperCase()
}
return authStore.user.email?.[0]?.toUpperCase() || 'A'
})
const userRoleLabel = computed(() => {
return authStore.user?.role?.replace(/_/g, ' ') || 'Administrator'
})
function updateTime() {
const now = new Date()
currentTime.value = now.toLocaleTimeString('en-US', {
currentTime.value = now.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit'

View File

@@ -11,6 +11,47 @@
</template>
<template #right>
<!-- Hamburger Menu (Company Settings) -->
<div class="relative hamburger-container">
<button
@click.stop="isHamburgerOpen = !isHamburgerOpen"
class="flex h-9 w-9 items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-all duration-200 cursor-pointer"
:aria-label="t('company.companyDataMenu')"
:title="t('company.companyDataMenu')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Hamburger Dropdown -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="scale-95 opacity-0 -translate-y-2"
enter-to-class="scale-100 opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100 translate-y-0"
leave-to-class="scale-95 opacity-0 -translate-y-2"
>
<div
v-if="isHamburgerOpen"
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<div class="py-1">
<button
@click="openCompanyData"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
{{ t('company.companyDataMenu') }}
</button>
</div>
</div>
</Transition>
</div>
<HeaderCompanySwitcher />
<HeaderProfile />
</template>
@@ -20,11 +61,18 @@
<main class="relative z-10">
<router-view />
</main>
<!-- Company Data Modal -->
<CompanyDataModal
v-if="showCompanyDataModal"
@close="showCompanyDataModal = false"
@saved="showCompanyDataModal = false"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
@@ -33,12 +81,17 @@ import BaseHeader from '../components/layout/BaseHeader.vue'
import HeaderLogo from '../components/header/HeaderLogo.vue'
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
import HeaderProfile from '../components/header/HeaderProfile.vue'
import CompanyDataModal from '../components/organization/CompanyDataModal.vue'
const route = useRoute()
const { t } = useI18n()
const authStore = useAuthStore()
const themeStore = useThemeStore()
// ── Hamburger Menu State ──────────────────────────────────────────
const isHamburgerOpen = ref(false)
const showCompanyDataModal = ref(false)
// ── Resolve organization display name from route params ────────────
const orgDisplayName = computed(() => {
const id = Number(route.params.id)
@@ -54,6 +107,20 @@ const currentOrganization = computed(() => {
return authStore.myOrganizations.find((o) => o.organization_id === id) || null
})
// ── Hamburger Menu Handlers ───────────────────────────────────────
function openCompanyData() {
isHamburgerOpen.value = false
showCompanyDataModal.value = true
}
// ── Close hamburger on outside click ──────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (isHamburgerOpen.value && !target.closest('.hamburger-container')) {
isHamburgerOpen.value = false
}
}
// ── Apply theme when organization loads or changes ────────────────
function applyOrgTheme() {
const org = currentOrganization.value
@@ -73,10 +140,12 @@ watch(
// Apply theme on mount
onMounted(() => {
applyOrgTheme()
document.addEventListener('mousedown', handleOutsideClick)
})
// Reset theme when leaving the organization view
onUnmounted(() => {
themeStore.resetTheme()
document.removeEventListener('mousedown', handleOutsideClick)
})
</script>

View File

@@ -14,6 +14,71 @@
</template>
<template #right>
<!-- Hamburger Menu (Private Garage Navigation) -->
<div class="relative hamburger-container">
<button
@click.stop="isHamburgerOpen = !isHamburgerOpen"
class="flex h-9 w-9 items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-all duration-200 cursor-pointer"
:aria-label="t('header.menu')"
:title="t('header.menu')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Hamburger Dropdown -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="scale-95 opacity-0 -translate-y-2"
enter-to-class="scale-100 opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100 translate-y-0"
leave-to-class="scale-95 opacity-0 -translate-y-2"
>
<div
v-if="isHamburgerOpen"
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<div class="py-1">
<!-- Járművek -->
<button
@click="navigateToCard('vehicles')"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{{ t('menu.garage') }}
</button>
<!-- Költségek -->
<button
@click="navigateToCard('costs')"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('menu.finance') }}
</button>
<!-- Szerviz kereső -->
<button
@click="navigateToCard('service')"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ t('menu.features') }}
</button>
</div>
</div>
</Transition>
</div>
<HeaderCompanySwitcher />
<HeaderProfile />
</template>
@@ -27,7 +92,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import BaseHeader from '../components/layout/BaseHeader.vue'
@@ -35,9 +101,14 @@ import HeaderLogo from '../components/header/HeaderLogo.vue'
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
import HeaderProfile from '../components/header/HeaderProfile.vue'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const authStore = useAuthStore()
// ── Hamburger Menu State ──────────────────────────────────────────
const isHamburgerOpen = ref(false)
// ── Center label: shows "{firstName}_garage" ───────────────────────
const centerLabel = computed(() => {
const firstName = authStore.user?.first_name ||
@@ -45,4 +116,27 @@ const centerLabel = computed(() => {
t('header.user')
return t('header.personalGarageLabel', { name: firstName })
})
// ── Navigate to dashboard with card query param ────────────────────
function navigateToCard(cardId: string) {
isHamburgerOpen.value = false
// 'vehicles' opens the flip modal with PrivateVehicleManager
// 'costs' and 'service' navigate to dashboard where their cards are already visible inline
if (cardId === 'costs' || cardId === 'service') {
router.push('/dashboard')
} else {
router.push({ path: '/dashboard', query: { card: cardId } })
}
}
// ── Close hamburger on outside click ──────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (isHamburgerOpen.value && !target.closest('.hamburger-container')) {
isHamburgerOpen.value = false
}
}
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
</script>

View File

@@ -67,6 +67,33 @@ const router = createRouter({
// The auth store will be checked at runtime
return { name: 'dashboard' }
}
},
{
path: '/admin',
component: () => import('../layouts/AdminLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: '',
name: 'admin-dashboard',
component: () => import('../views/admin/AdminDashboardView.vue')
},
{
path: 'users',
name: 'admin-users',
component: () => import('../views/admin/AdminUsersView.vue')
},
{
path: 'packages',
name: 'admin-packages',
component: () => import('../views/admin/AdminPackagesView.vue')
},
{
path: 'services',
name: 'admin-services',
component: () => import('../views/admin/AdminServicesView.vue')
}
]
}
]
})
@@ -96,6 +123,17 @@ router.beforeEach(async (to, _from, next) => {
return next('/')
}
// ── Admin RBAC Guard ──────────────────────────────────────────────
// If the route requires admin privileges, check the user's role.
if (to.meta.requiresAdmin) {
const role = authStore.user?.role
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
if (!role || !adminRoles.includes(role)) {
// Non-admin user trying to access admin area → redirect to dashboard
return next('/dashboard')
}
}
next()
})

View File

@@ -0,0 +1,244 @@
/**
* 📦 Admin Packages Store
*
* Pinia store for managing SubscriptionTier packages.
* Mirrors the backend admin_packages.py API endpoints.
*
* Endpoints:
* GET /admin/packages/ — List all packages
* POST /admin/packages/ — Create a new package
* PATCH /admin/packages/{id} — Update a package
* DELETE /admin/packages/{id} — Soft-delete (deactivate) a package
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api/axios'
// ── TypeScript Interfaces (mirroring backend Pydantic schemas) ───────────────
export interface PricingModel {
monthly_price: number
yearly_price: number
currency: string
credit_price?: number | null
}
export interface AllowancesModel {
max_vehicles: number
max_garages: number
monthly_free_credits: number
}
export interface LifecycleModel {
is_public: boolean
available_until?: string | null
}
export interface AffiliateModel {
commission_rate_percent: number
referral_bonus_credits: number
}
export interface SubscriptionRulesModel {
type: 'private' | 'corporate'
display_name?: string | null
pricing: PricingModel
allowances: AllowancesModel
entitlements: string[]
affiliate: AffiliateModel
lifecycle?: LifecycleModel | null
}
export interface SubscriptionTierItem {
id: number
name: string
rules: SubscriptionRulesModel
is_custom: boolean
}
export interface SubscriptionTierListResponse {
total: number
tiers: SubscriptionTierItem[]
}
export interface SubscriptionTierCreatePayload {
name: string
rules: SubscriptionRulesModel
is_custom?: boolean
}
export interface SubscriptionTierUpdatePayload {
name?: string
rules?: SubscriptionRulesModel
is_custom?: boolean
}
export interface DeletePackageResponse {
status: string
message: string
tier_id: number
is_public: boolean
}
// ── Available Entitlements (service codes) ───────────────────────────────────
export const AVAILABLE_ENTITLEMENTS: string[] = [
'SRV_DATA_EXPORT',
'SRV_AI_UPLOAD',
'SRV_ACCOUNTING_SYNC',
'SRV_API_ACCESS',
'SRV_MULTI_USER',
'SRV_PRIORITY_SUPPORT',
'SRV_ADVANCED_ANALYTICS',
'SRV_CUSTOM_REPORTS',
'SRV_GARAGE_SHARING',
'SRV_SERVICE_HISTORY',
'SRV_FUEL_MANAGEMENT',
'SRV_MAINTENANCE_ALERTS',
'SRV_ODOMETER_TRACKING',
'SRV_DOCUMENT_STORAGE',
'SRV_INSURANCE_INTEGRATION',
]
// ── Store ────────────────────────────────────────────────────────────────────
export const useAdminPackagesStore = defineStore('adminPackages', () => {
// ── State ────────────────────────────────────────────────────────────────
const tiers = ref<SubscriptionTierItem[]>([])
const total = ref(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
// ── Actions ──────────────────────────────────────────────────────────────
/**
* Fetch all subscription packages from GET /admin/packages/.
*/
async function fetchPackages(params: {
skip?: number
limit?: number
include_hidden?: boolean
type_filter?: string
date_from?: string
date_until?: string
} = {}) {
isLoading.value = true
error.value = null
try {
const queryParams: Record<string, any> = {
skip: params.skip ?? 0,
limit: params.limit ?? 100,
}
if (params.include_hidden) {
queryParams.include_hidden = true
}
if (params.type_filter) {
queryParams.type_filter = params.type_filter
}
if (params.date_from) {
queryParams.date_from = params.date_from
}
if (params.date_until) {
queryParams.date_until = params.date_until
}
const res = await api.get('/admin/packages', { params: queryParams })
const data = res.data as SubscriptionTierListResponse
console.log('[AdminPackages] API response:', data)
console.log('[AdminPackages] tiers array:', data.tiers)
console.log('[AdminPackages] tiers length:', data.tiers?.length)
tiers.value = data.tiers
total.value = data.total
return data
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch packages'
throw err
} finally {
isLoading.value = false
}
}
/**
* Create a new subscription package via POST /admin/packages/.
* NOTE: Do NOT send the `id` field — PostgreSQL auto-increments it.
*/
async function createPackage(payload: SubscriptionTierCreatePayload): Promise<SubscriptionTierItem> {
isLoading.value = true
error.value = null
try {
const res = await api.post('/admin/packages', payload)
const created = res.data as SubscriptionTierItem
// Refresh the list
await fetchPackages({ include_hidden: true })
return created
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to create package'
throw err
} finally {
isLoading.value = false
}
}
/**
* Update an existing package via PATCH /admin/packages/{id}.
*/
async function updatePackage(
tierId: number,
payload: SubscriptionTierUpdatePayload,
): Promise<SubscriptionTierItem> {
isLoading.value = true
error.value = null
try {
const res = await api.patch(`/admin/packages/${tierId}`, payload)
const updated = res.data as SubscriptionTierItem
// Refresh the list
await fetchPackages({ include_hidden: true })
return updated
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to update package'
throw err
} finally {
isLoading.value = false
}
}
/**
* Soft-delete (deactivate) a package via DELETE /admin/packages/{id}.
* Sets lifecycle.is_public = false.
*/
async function deletePackage(tierId: number): Promise<DeletePackageResponse> {
isLoading.value = true
error.value = null
try {
const res = await api.delete(`/admin/packages/${tierId}`)
const result = res.data as DeletePackageResponse
// Refresh the list
await fetchPackages({ include_hidden: true })
return result
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to delete package'
throw err
} finally {
isLoading.value = false
}
}
return {
// State
tiers,
total,
isLoading,
error,
// Actions
fetchPackages,
createPackage,
updatePackage,
deletePackage,
}
})

View File

@@ -0,0 +1,142 @@
/**
* 🛠️ Admin Services Store
*
* Pinia store for managing ServiceCatalog entries.
* Mirrors the backend admin_services.py API endpoints.
*
* Endpoints:
* GET /admin/services/ — List all services
* POST /admin/services/ — Create a new service
* PATCH /admin/services/{id} — Update a service
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api/axios'
// ── TypeScript Interfaces (mirroring backend Pydantic schemas) ───────────────
export interface ServiceCatalogItem {
id: number
service_code: string
name: string
description: string | null
credit_cost: number
is_active: boolean
}
export interface ServiceCatalogCreatePayload {
service_code: string
name: string
description?: string | null
credit_cost?: number
is_active?: boolean
}
export interface ServiceCatalogUpdatePayload {
name?: string
description?: string | null
credit_cost?: number
is_active?: boolean
}
// ── Store ────────────────────────────────────────────────────────────────────
export const useAdminServicesStore = defineStore('adminServices', () => {
// ── State ────────────────────────────────────────────────────────────────
const services = ref<ServiceCatalogItem[]>([])
const total = ref(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
// ── Actions ──────────────────────────────────────────────────────────────
/**
* Fetch all services from GET /admin/services/.
*/
async function fetchServices(params: {
skip?: number
limit?: number
is_active?: boolean | null
} = {}) {
isLoading.value = true
error.value = null
try {
const queryParams: Record<string, any> = {
skip: params.skip ?? 0,
limit: params.limit ?? 200,
}
if (params.is_active !== undefined && params.is_active !== null) {
queryParams.is_active = params.is_active
}
const res = await api.get('/admin/services', { params: queryParams })
const data = res.data as ServiceCatalogItem[]
services.value = data
total.value = data.length
return data
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch services'
throw err
} finally {
isLoading.value = false
}
}
/**
* Create a new service via POST /admin/services/.
*/
async function createService(payload: ServiceCatalogCreatePayload): Promise<ServiceCatalogItem> {
isLoading.value = true
error.value = null
try {
const res = await api.post('/admin/services', payload)
const created = res.data as ServiceCatalogItem
await fetchServices()
return created
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to create service'
throw err
} finally {
isLoading.value = false
}
}
/**
* Update an existing service via PATCH /admin/services/{id}.
*/
async function updateService(
serviceId: number,
payload: ServiceCatalogUpdatePayload,
): Promise<ServiceCatalogItem> {
isLoading.value = true
error.value = null
try {
const res = await api.patch(`/admin/services/${serviceId}`, payload)
const updated = res.data as ServiceCatalogItem
await fetchServices()
return updated
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to update service'
throw err
} finally {
isLoading.value = false
}
}
return {
// State
services,
total,
isLoading,
error,
// Actions
fetchServices,
createService,
updateService,
}
})

View File

@@ -0,0 +1,129 @@
// /opt/docker/dev/service_finder/frontend/src/stores/adminUsers.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../api/axios'
export interface AdminUserItem {
id: number
email: string
role: string
is_active: boolean
is_deleted: boolean
subscription_plan: string
region_code: string
preferred_language: string
created_at: string | null
deleted_at: string | null
// Person adatok
first_name: string | null
last_name: string | null
phone: string | null
// Címadatok
postal_code: string | null
city: string | null
street: string | null
house_number: string | null
address: string | null
}
export interface UserListResponse {
total: number
skip: number
limit: number
users: AdminUserItem[]
}
export interface BulkActionResponse {
status: string
action: string
affected_count: number
total_requested: number
missing_ids: number[] | null
message: string
}
export const useAdminUsersStore = defineStore('adminUsers', () => {
// ────────────────────────────── State ──────────────────────────────
const users = ref<AdminUserItem[]>([])
const total = ref(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
// ────────────────────────────── Actions ────────────────────────────
/**
* Fetch paginated and filtered user list from GET /admin/users.
* Supports targeted search with search_term + search_category.
*/
async function fetchUsers(params: {
skip?: number
limit?: number
search_term?: string
search_category?: string
role?: string
is_active?: boolean
is_deleted?: boolean
} = {}) {
isLoading.value = true
error.value = null
try {
const queryParams: Record<string, any> = {
skip: params.skip ?? 0,
limit: params.limit ?? 50,
}
if (params.search_term) queryParams.search_term = params.search_term
if (params.search_category) queryParams.search_category = params.search_category
if (params.role) queryParams.role = params.role
if (params.is_active !== undefined) queryParams.is_active = params.is_active
if (params.is_deleted !== undefined) queryParams.is_deleted = params.is_deleted
const res = await api.get('/admin/users', { params: queryParams })
const data = res.data as UserListResponse
users.value = data.users
total.value = data.total
return data
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch users'
throw err
} finally {
isLoading.value = false
}
}
/**
* Execute a bulk action on selected users via POST /admin/users/bulk-action.
*/
async function bulkAction(userIds: number[], action: string): Promise<BulkActionResponse> {
isLoading.value = true
error.value = null
try {
const res = await api.post('/admin/users/bulk-action', {
user_ids: userIds,
action,
})
const data = res.data as BulkActionResponse
// Refresh the user list after a successful bulk action
await fetchUsers()
return data
} catch (err: any) {
error.value = err.response?.data?.detail || `Failed to execute bulk action: ${action}`
throw err
} finally {
isLoading.value = false
}
}
return {
// State
users,
total,
isLoading,
error,
// Actions
fetchUsers,
bulkAction,
}
})

View File

@@ -474,6 +474,105 @@ export const useAuthStore = defineStore('auth', () => {
}
}
/**
* Delete the current user's account (soft delete).
* DELETE /users/me with optional reason as query parameter.
* On success, clears all local state and redirects to landing page.
* If the backend returns is_last_admin=true, the response includes
* a warning detail that the caller can display.
*/
async function deleteAccount(reason?: string): Promise<{ is_last_admin?: boolean }> {
isLoading.value = true
error.value = null
try {
const params: Record<string, string> = {}
if (reason) {
params.reason = reason
}
const res = await api.delete('/users/me', { params })
// Check if backend returned is_last_admin warning
const isLastAdmin = res.data?.is_last_admin === true
// Clear all local state — same as logout but without API call
token.value = null
user.value = null
myOrganizations.value = []
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
router.push('/')
return { is_last_admin: isLastAdmin }
} catch (err: any) {
// If the error response contains is_last_admin, pass it through
if (err.response?.data?.is_last_admin) {
error.value = err.response?.data?.detail || 'profile.deleteError'
throw { ...err, is_last_admin: true }
}
error.value = err?.response?.data?.detail || 'profile.deleteError'
throw err
} finally {
isLoading.value = false
}
}
/**
* Request account restoration code (Step 1 of Account Restore).
* POST /auth/restore/request with { email }.
* Sends a 6-digit OTP to the user's email.
*/
async function requestRestore(email: string): Promise<{ status: string; message: string }> {
isLoading.value = true
error.value = null
try {
const res = await api.post('/auth/restore/request', { email })
return res.data as { status: string; message: string }
} catch (err: any) {
error.value = getErrorMessage(err, 'auth.restoreError')
throw err
} finally {
isLoading.value = false
}
}
/**
* Verify account restoration code and set new password (Step 2 of Account Restore).
* POST /auth/restore/verify with { email, otp, new_password }.
* On success, returns JWT tokens and auto-logs in the user.
*/
async function verifyRestore(email: string, otp: string, newPassword: string): Promise<void> {
isLoading.value = true
error.value = null
try {
const res = await api.post('/auth/restore/verify', {
email,
otp,
new_password: newPassword,
})
const data = res.data as {
access_token: string
refresh_token?: string
token_type: string
}
// Persist token
localStorage.setItem('access_token', data.access_token)
if (data.refresh_token) {
localStorage.setItem('refresh_token', data.refresh_token)
}
token.value = data.access_token
// Fetch user profile and organizations
await fetchUser()
await fetchMyOrganizations()
} catch (err: any) {
error.value = getErrorMessage(err, 'auth.restoreError')
throw err
} finally {
isLoading.value = false
}
}
/**
* Clear any stored error message.
*/
@@ -591,6 +690,9 @@ export const useAuthStore = defineStore('auth', () => {
completeKyc,
updatePerson,
changePassword,
deleteAccount,
requestRestore,
verifyRestore,
logout,
init,
clearError,

View File

@@ -9,9 +9,8 @@ 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
amount_net: number
currency?: string
date: string
mileage_at_cost?: number | null
description?: string | null
@@ -52,9 +51,8 @@ export const useCostStore = defineStore('cost', () => {
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',
amount_net: payload.amount_net,
currency: payload.currency || 'HUF',
date: payload.date,
mileage_at_cost: payload.mileage_at_cost ?? null,
description: payload.description || null,

View File

@@ -119,8 +119,17 @@
<!-- Action Buttons -->
<div class="flex-1 flex flex-col gap-2.5 justify-center">
<!-- No vehicle warning -->
<div
v-if="!selectedActionVehicle"
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
>
<span> Nincs kiválasztva jármű. Adj hozzá egy járművet először!</span>
</div>
<!-- Tankolás (Big, prominent) -->
<button
v-if="selectedActionVehicle"
@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]"
>
@@ -131,6 +140,7 @@
<!-- 🅿 Parkolás / Útdíj (Medium) -->
<button
v-if="selectedActionVehicle"
@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]"
>
@@ -141,6 +151,7 @@
<!-- További költségek (Medium) -->
<button
v-if="selectedActionVehicle"
@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]"
>
@@ -318,8 +329,8 @@
<PrivateVehicleManager
v-if="activeCard === 'vehicles'"
:target-vehicle-id="selectedTargetId"
:edit-target-vehicle="editTargetVehicle"
@clear-edit-target="editTargetVehicle = null"
@add-new="handleAddNew"
@edit-vehicle="handleEditVehicle"
/>
<!-- Fallback for other cards -->
@@ -370,23 +381,49 @@
@edit="handleEditFromDetail"
@set-primary="handleSetPrimaryFromDetail"
/>
<!--
Vehicle Form Modal (Add) standalone, decoupled from Manager
-->
<VehicleFormModal
:is-open="isAddFormOpen"
@close="isAddFormOpen = false"
@saved="onAddVehicleSaved"
/>
<!--
Vehicle Form Modal (Edit) standalone, decoupled from Manager
-->
<VehicleFormModal
:is-open="isEditFormOpen"
:vehicle="editTargetVehicle"
@close="closeEditForm"
@saved="onEditVehicleSaved"
@deleted="onVehicleDeleted"
/>
</div><!-- end min-h-screen -->
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle'
import type { VehicleData } from '../types/vehicle'
import api from '../api/axios'
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
import VehicleCardCompact from '../components/vehicle/VehicleCardCompact.vue'
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue'
import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue'
import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
@@ -405,9 +442,6 @@ const openCard = (cardId: string, targetId: string | null = null) => {
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
@@ -418,11 +452,115 @@ function closeVehicleDetail() {
detailVehicle.value = null
}
// ── Vehicle Form Modal State (standalone, decoupled from Manager) ──
const isAddFormOpen = ref(false)
const isEditFormOpen = ref(false)
const editTargetVehicle = ref<VehicleData | null>(null)
const isCheckingQuota = ref(false)
/**
* Return-State flag: when true, closing the edit form should re-open
* the VehicleDetailModal instead of returning to the dashboard.
*/
const returnToVehicleDetail = 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 handleAddNew() {
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) {
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
}
isAddFormOpen.value = true
} catch (err: any) {
console.error('[DashboardView] Quota check failed:', err)
isAddFormOpen.value = true
} finally {
isCheckingQuota.value = false
}
}
function onAddVehicleSaved() {
isAddFormOpen.value = false
}
function handleEditVehicle(vehicle: VehicleData) {
editTargetVehicle.value = vehicle ? { ...vehicle } : null
isEditFormOpen.value = true
}
function closeEditForm() {
isEditFormOpen.value = false
// If we came from the detail modal, return to it
if (returnToVehicleDetail.value) {
returnToVehicleDetail.value = false
const vehicle = editTargetVehicle.value
editTargetVehicle.value = null
if (vehicle) {
nextTick(() => {
detailVehicle.value = vehicle
isVehicleDetailOpen.value = true
})
}
} else {
editTargetVehicle.value = null
}
}
function onEditVehicleSaved(savedVehicle?: VehicleData) {
isEditFormOpen.value = false
// If we came from the detail modal, return to it
if (returnToVehicleDetail.value) {
returnToVehicleDetail.value = false
// Use the saved vehicle (with updated data) if available, otherwise fallback
const vehicle = savedVehicle || editTargetVehicle.value
editTargetVehicle.value = null
if (vehicle) {
nextTick(() => {
detailVehicle.value = vehicle
isVehicleDetailOpen.value = true
})
}
} else {
editTargetVehicle.value = null
}
}
/**
* Called when the edit VehicleFormModal emits 'deleted'.
* Completely resets the return-state and closes the edit form,
* then refreshes the vehicle list so the deleted vehicle disappears.
*/
function onVehicleDeleted() {
returnToVehicleDetail.value = false
isEditFormOpen.value = false
editTargetVehicle.value = null
vehicleStore.fetchVehicles()
}
// ── Bridge: Detail → Edit (opens edit modal directly, no Manager card) ──
function handleEditFromDetail(vehicle: VehicleData) {
returnToVehicleDetail.value = true
closeVehicleDetail()
// Task 3: Open the vehicles card AND pass the vehicle to edit directly
editTargetVehicle.value = vehicle
activeCard.value = 'vehicles'
// Use nextTick so detail modal closes before edit opens
nextTick(() => {
editTargetVehicle.value = vehicle ? { ...vehicle } : null
isEditFormOpen.value = true
})
}
function handleSetPrimaryFromDetail(vehicle: VehicleData) {
@@ -497,12 +635,28 @@ const badges = ref([
onMounted(() => {
vehicleStore.fetchVehicles()
authStore.fetchMyOrganizations()
// ── Handle query param card navigation ──────────────────────────
const cardParam = route.query.card as string | undefined
if (cardParam) {
openCard(cardParam)
// Clean up the query param so it doesn't re-trigger
router.replace({ query: {} })
}
})
// Watch for vehicles to load, then auto-select
watch(() => vehicleStore.vehicles.length, () => {
autoSelectVehicle()
}, { immediate: true })
// ── Watch for query param changes (hamburger menu navigation) ────
watch(() => route.query.card, (newCard) => {
if (newCard && typeof newCard === 'string') {
openCard(newCard)
router.replace({ query: {} })
}
})
</script>
<style scoped>

View File

@@ -172,6 +172,17 @@
</svg>
{{ t('profile.changePassword') }}
</button>
<!-- Delete Account Button -->
<button
@click="showDeleteModal = true"
class="inline-flex items-center gap-2 rounded-xl bg-red-600/20 border border-red-500/40 px-6 py-2.5 text-sm font-semibold text-red-400 transition-all duration-200 hover:bg-red-600/30 cursor-pointer"
>
<svg class="w-4 h-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>
{{ t('profile.deleteAccount') }}
</button>
</div>
</div>
@@ -792,6 +803,109 @@
</div>
</div>
</Teleport>
<!-- Delete Account Confirmation Modal -->
<Teleport to="body">
<div
v-if="showDeleteModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="closeDeleteModal"
>
<div
@click.stop
class="relative w-full max-w-md mx-4 rounded-2xl border border-red-500/20 bg-black/80 p-6 backdrop-blur-2xl shadow-2xl"
>
<!-- Modal Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 text-red-400" 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>
<h3 class="text-lg font-bold text-white">{{ t('profile.deleteAccountConfirm') }}</h3>
</div>
<button
@click="closeDeleteModal"
class="text-white/40 transition-colors duration-200 hover:text-red-400 cursor-pointer"
:aria-label="t('header.close')"
>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="space-y-4">
<!-- Last Admin Warning -->
<div
v-if="isLastAdmin"
class="rounded-xl px-4 py-3 text-sm bg-orange-500/20 text-orange-300 border border-orange-500/30"
>
<p class="font-semibold mb-1">{{ t('profile.lastAdminWarningTitle') }}</p>
<p>{{ t('profile.lastAdminWarning') }}</p>
</div>
<!-- Warning message -->
<div class="rounded-xl px-4 py-3 text-sm bg-red-500/10 text-red-300 border border-red-500/20">
<p class="font-semibold mb-1">{{ t('profile.deleteAccountWarning') }}</p>
</div>
<!-- Reason input (optional) -->
<div>
<label class="mb-2 block text-sm text-white/50">{{ t('profile.deleteAccountReason') }}</label>
<textarea
v-model="deleteReason"
rows="2"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-red-500/50 focus:outline-none resize-none"
:placeholder="t('profile.deleteAccountReason')"
></textarea>
</div>
<!-- Error message -->
<div
v-if="deleteError"
class="rounded-xl px-4 py-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20"
>
{{ deleteError }}
</div>
<!-- Success message -->
<div
v-if="deleteSuccess"
class="rounded-xl px-4 py-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20"
>
{{ deleteSuccess }}
</div>
</div>
<!-- Modal Footer -->
<div class="flex justify-end gap-3 mt-6">
<button
@click="closeDeleteModal"
:disabled="isDeleting"
class="rounded-xl border border-white/20 bg-white/5 px-4 py-2 text-sm text-white/70 transition-all duration-200 hover:bg-white/10 cursor-pointer disabled:opacity-50"
>
{{ t('profile.deleteAccountCancel') }}
</button>
<button
@click="submitDeleteAccount"
:disabled="isDeleting"
class="inline-flex items-center gap-2 rounded-xl bg-red-600 px-6 py-2 text-sm text-white font-semibold transition-all duration-200 hover:bg-red-700 disabled:opacity-50 cursor-pointer"
>
<svg v-if="isDeleting" class="w-4 h-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="w-4 h-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>
{{ t('profile.deleteAccountButton') }}
</button>
</div>
</div>
</div>
</Teleport>
</div><!-- /backdrop overlay -->
</div><!-- /root -->
</template>
@@ -946,6 +1060,8 @@ function handleEscapeKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showPasswordModal.value) {
closePasswordModal()
} else if (showDeleteModal.value) {
closeDeleteModal()
} else {
closeProfile()
}
@@ -1232,4 +1348,44 @@ async function submitPasswordChange() {
isPasswordSaving.value = false
}
}
// ── Delete Account Modal State ─────────────────────────────────────────
const showDeleteModal = ref(false)
const isDeleting = ref(false)
const deleteReason = ref('')
const deleteError = ref('')
const deleteSuccess = ref('')
const isLastAdmin = ref(false)
function closeDeleteModal() {
showDeleteModal.value = false
deleteReason.value = ''
deleteError.value = ''
deleteSuccess.value = ''
isLastAdmin.value = false
}
async function submitDeleteAccount() {
deleteError.value = ''
deleteSuccess.value = ''
isDeleting.value = true
try {
const result = await authStore.deleteAccount(deleteReason.value || undefined)
if (result?.is_last_admin) {
isLastAdmin.value = true
}
deleteSuccess.value = t('profile.deleteSuccess')
setTimeout(() => {
closeDeleteModal()
}, 2000)
} catch (err: any) {
if (err.is_last_admin) {
isLastAdmin.value = true
}
deleteError.value = err?.response?.data?.detail || t('profile.deleteError')
} finally {
isDeleting.value = false
}
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div class="admin-dashboard">
<!-- Page Header -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-white">Admin Dashboard</h2>
<p class="text-gray-400 mt-1">System overview and key metrics</p>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-blue-500/30 transition-colors">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 rounded-lg bg-blue-500/20 flex items-center justify-center">
<span class="text-2xl">👥</span>
</div>
<span class="text-sm text-gray-400">Total</span>
</div>
<div class="text-3xl font-bold text-white">--</div>
<div class="text-sm text-gray-400 mt-1">Registered Users</div>
</div>
<div class="bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-green-500/30 transition-colors">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 rounded-lg bg-green-500/20 flex items-center justify-center">
<span class="text-2xl">🚗</span>
</div>
<span class="text-sm text-gray-400">Active</span>
</div>
<div class="text-3xl font-bold text-white">--</div>
<div class="text-sm text-gray-400 mt-1">Vehicle Assets</div>
</div>
<div class="bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-purple-500/30 transition-colors">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 rounded-lg bg-purple-500/20 flex items-center justify-center">
<span class="text-2xl">🏢</span>
</div>
<span class="text-sm text-gray-400">Total</span>
</div>
<div class="text-3xl font-bold text-white">--</div>
<div class="text-sm text-gray-400 mt-1">Organizations</div>
</div>
<div class="bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-yellow-500/30 transition-colors">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<span class="text-2xl">🔔</span>
</div>
<span class="text-sm text-gray-400">24h</span>
</div>
<div class="text-3xl font-bold text-white">--</div>
<div class="text-sm text-gray-400 mt-1">Critical Alerts</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-gray-800/40 rounded-xl border border-gray-700 p-6 mb-8">
<h3 class="text-lg font-semibold text-white mb-4">Quick Actions</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<button class="flex flex-col items-center gap-2 p-4 rounded-lg bg-gray-700/50 hover:bg-gray-700 transition-colors">
<span class="text-2xl">🔍</span>
<span class="text-sm text-gray-300">Browse Users</span>
</button>
<button class="flex flex-col items-center gap-2 p-4 rounded-lg bg-gray-700/50 hover:bg-gray-700 transition-colors">
<span class="text-2xl"></span>
<span class="text-sm text-gray-300">System Config</span>
</button>
<button class="flex flex-col items-center gap-2 p-4 rounded-lg bg-gray-700/50 hover:bg-gray-700 transition-colors">
<span class="text-2xl">📋</span>
<span class="text-sm text-gray-300">Audit Logs</span>
</button>
<button class="flex flex-col items-center gap-2 p-4 rounded-lg bg-gray-700/50 hover:bg-gray-700 transition-colors">
<span class="text-2xl">🌐</span>
<span class="text-sm text-gray-300">Translations</span>
</button>
</div>
</div>
<!-- Recent Activity Placeholder -->
<div class="bg-gray-800/40 rounded-xl border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4">Recent Activity</h3>
<div class="text-gray-500 text-center py-8">
<span class="text-4xl block mb-3">📡</span>
<p>Activity feed will appear here once connected to the backend.</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* AdminDashboardView.vue — Admin központi áttekintő nézet.
* Ideiglenes statisztikai kártyákkal és gyorsműveletekkel.
*/
</script>

View File

@@ -0,0 +1,327 @@
<template>
<div class="admin-packages-view">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-white">{{ $t('admin.packages.title') }}</h2>
<p class="text-gray-400 mt-1">{{ $t('admin.packages.subtitle') }}</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
@click="openCreateModal"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ $t('admin.packages.create_button') }}
</button>
</div>
<!-- Toggle Hidden + Filters -->
<div class="flex flex-wrap items-center gap-3 mb-6">
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="showHidden"
type="checkbox"
class="rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500"
@change="loadPackages"
/>
<span class="text-sm text-gray-300">{{ $t('admin.packages.show_hidden') }}</span>
</label>
<button
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
@click="loadPackages"
>
{{ $t('admin.packages.refresh') }}
</button>
<!-- Type Filter -->
<div class="flex items-center gap-2 ml-4">
<label class="text-sm text-gray-400">{{ $t('admin.packages.field_type') }}:</label>
<select
v-model="typeFilter"
@change="loadPackages"
class="px-3 py-1.5 rounded-lg bg-gray-700 border border-gray-600 text-gray-200 text-sm focus:outline-none focus:border-blue-500"
>
<option value="">{{ $t('admin.users.filter_all_roles') }}</option>
<option value="private">{{ $t('admin.packages.type_private') }}</option>
<option value="corporate">{{ $t('admin.packages.type_corporate') }}</option>
</select>
</div>
<!-- Date From Filter -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">{{ $t('admin.packages.date_from_label') || 'Dátum tól' }}:</label>
<input
type="date"
v-model="dateFrom"
@change="loadPackages"
class="px-3 py-1.5 rounded-lg bg-gray-700 border border-gray-600 text-gray-200 text-sm focus:outline-none focus:border-blue-500 [color-scheme:dark]"
/>
</div>
<!-- Date Until Filter -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">{{ $t('admin.packages.date_until_label') || 'Dátum ig' }}:</label>
<input
type="date"
v-model="dateUntil"
@change="loadPackages"
class="px-3 py-1.5 rounded-lg bg-gray-700 border border-gray-600 text-gray-200 text-sm focus:outline-none focus:border-blue-500 [color-scheme:dark]"
/>
</div>
<!-- Clear Filters -->
<button
v-if="typeFilter || dateFrom || dateUntil"
@click="clearFilters"
class="px-3 py-1.5 rounded-lg bg-red-800/30 hover:bg-red-800/50 text-red-400 text-sm transition-colors"
>
{{ $t('admin.users.clear_search') }}
</button>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="text-center py-12 text-gray-400">
{{ $t('admin.packages.loading') }}
</div>
<!-- Error State -->
<div v-else-if="store.error" class="text-center py-12 text-red-400">
{{ store.error }}
</div>
<!-- Empty State -->
<div
v-else-if="store.tiers.length === 0"
class="text-center py-12 text-gray-500"
>
<span class="text-5xl block mb-4">📦</span>
<p class="text-lg">{{ $t('admin.packages.empty') }}</p>
<p class="text-sm mt-1">{{ $t('admin.packages.empty_hint') }}</p>
</div>
<!-- Package Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="pkg in store.tiers"
:key="pkg.id"
class="relative bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-blue-500/30 transition-all group"
:class="{ 'opacity-70': !isPublic(pkg) }"
>
<!-- Inactive Badge -->
<div
v-if="!isPublic(pkg)"
class="absolute top-3 right-3 px-2 py-0.5 bg-yellow-900/60 text-yellow-300 border border-yellow-700 rounded-full text-xs font-medium"
>
{{ $t('admin.packages.badge_inactive') }}
</div>
<!-- Custom Badge -->
<div
v-if="pkg.is_custom"
class="absolute top-3 left-3 px-2 py-0.5 bg-purple-900/60 text-purple-300 border border-purple-700 rounded-full text-xs font-medium"
>
{{ $t('admin.packages.badge_custom') }}
</div>
<!-- Package Name & Type -->
<div class="mb-4">
<h3 class="text-lg font-bold text-white">{{ pkg.rules.display_name || pkg.name }}</h3>
<div class="flex items-center gap-2 mt-1">
<span
class="inline-block px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="typeBadgeClass(pkg.rules.type)"
>
{{ pkg.rules.type === 'private' ? $t('admin.packages.type_private') : $t('admin.packages.type_corporate') }}
</span>
<span class="text-xs text-gray-500 font-mono">{{ pkg.name }}</span>
</div>
</div>
<!-- Pricing Info -->
<div class="space-y-2 mb-4">
<div class="flex justify-between text-sm">
<span class="text-gray-400">{{ $t('admin.packages.monthly') }}</span>
<span class="text-gray-200 font-medium">{{ formatPrice(pkg.rules.pricing.monthly_price, pkg.rules.pricing.currency) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-400">{{ $t('admin.packages.yearly') }}</span>
<span class="text-gray-200 font-medium">{{ formatPrice(pkg.rules.pricing.yearly_price, pkg.rules.pricing.currency) }}</span>
</div>
</div>
<!-- Allowances -->
<div class="border-t border-gray-700/50 pt-3 mb-4 space-y-1.5">
<div class="flex justify-between text-sm">
<span class="text-gray-400">{{ $t('admin.packages.max_vehicles_label') }}</span>
<span class="text-gray-300">{{ pkg.rules.allowances.max_vehicles }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-400">{{ $t('admin.packages.max_garages_label') }}</span>
<span class="text-gray-300">{{ pkg.rules.allowances.max_garages }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-400">{{ $t('admin.packages.free_credits_label') }}</span>
<span class="text-gray-300">{{ pkg.rules.allowances.monthly_free_credits }}</span>
</div>
</div>
<!-- Entitlements Chips -->
<div class="border-t border-gray-700/50 pt-3 mb-4">
<div class="flex flex-wrap gap-1.5">
<span
v-for="ent in pkg.rules.entitlements.slice(0, 4)"
:key="ent"
class="px-2 py-0.5 bg-gray-700/60 text-gray-300 rounded text-xs"
>
{{ ent }}
</span>
<span
v-if="pkg.rules.entitlements.length > 4"
class="px-2 py-0.5 bg-gray-700/60 text-gray-400 rounded text-xs"
>
+{{ pkg.rules.entitlements.length - 4 }}
</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-3 border-t border-gray-700/50">
<button
class="flex-1 px-3 py-2 text-sm font-medium text-blue-300 bg-blue-900/30 hover:bg-blue-900/50 border border-blue-700/50 rounded-lg transition-colors"
@click="openEditModal(pkg)"
>
{{ $t('admin.packages.edit_button') }}
</button>
<button
class="flex-1 px-3 py-2 text-sm font-medium text-red-300 bg-red-900/30 hover:bg-red-900/50 border border-red-700/50 rounded-lg transition-colors"
@click="confirmDelete(pkg)"
>
{{ $t('admin.packages.delete_button') }}
</button>
</div>
</div>
</div>
<!-- Edit / Create Modal -->
<PackageEditModal
v-if="showModal"
:package="editingPackage"
@close="closeModal"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAdminPackagesStore } from '../../stores/adminPackages'
import type { SubscriptionTierItem } from '../../stores/adminPackages'
import PackageEditModal from '../../components/admin/PackageEditModal.vue'
const store = useAdminPackagesStore()
// ── State ───────────────────────────────────────────────────────────────────
const showHidden = ref(false)
const showModal = ref(false)
const editingPackage = ref<SubscriptionTierItem | null>(null)
// ── Filter State ────────────────────────────────────────────────────────────
const typeFilter = ref('')
const dateFrom = ref('')
const dateUntil = ref('')
// ── Lifecycle ───────────────────────────────────────────────────────────────
onMounted(() => {
loadPackages()
})
// ── Methods ─────────────────────────────────────────────────────────────────
async function loadPackages() {
await store.fetchPackages({
include_hidden: showHidden.value,
type_filter: typeFilter.value || undefined,
date_from: dateFrom.value || undefined,
date_until: dateUntil.value || undefined,
})
}
function clearFilters() {
typeFilter.value = ''
dateFrom.value = ''
dateUntil.value = ''
loadPackages()
}
function isPublic(pkg: SubscriptionTierItem): boolean {
return pkg.rules.lifecycle?.is_public !== false
}
function typeBadgeClass(type: string): string {
if (type === 'corporate') {
return 'bg-indigo-900/60 text-indigo-300 border border-indigo-700'
}
return 'bg-green-900/60 text-green-300 border border-green-700'
}
function formatPrice(price: number, currency: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(price)
}
// ── Modal Handlers ──────────────────────────────────────────────────────────
function openCreateModal() {
editingPackage.value = null
showModal.value = true
}
function openEditModal(pkg: SubscriptionTierItem) {
editingPackage.value = pkg
showModal.value = true
}
function closeModal() {
showModal.value = false
editingPackage.value = null
}
function onSaved() {
closeModal()
loadPackages()
}
// ── Delete Handler ──────────────────────────────────────────────────────────
async function confirmDelete(pkg: SubscriptionTierItem) {
const confirmed = window.confirm(
`Are you sure you want to deactivate the package "${pkg.name}"? This will set it to inactive (is_public = false).`,
)
if (!confirmed) return
try {
await store.deletePackage(pkg.id)
await loadPackages()
} catch (err: any) {
alert(store.error || 'Failed to deactivate package')
}
}
</script>
<style scoped>
.admin-packages-view {
font-family: 'Inter', sans-serif;
}
input[type='checkbox'] {
accent-color: #3b82f6;
}
</style>

View File

@@ -0,0 +1,349 @@
<template>
<div class="admin-services-view">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-white">{{ $t('admin.services.title') }}</h2>
<p class="text-gray-400 mt-1">{{ $t('admin.services.subtitle') }}</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
@click="openCreateModal"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ $t('admin.services.create_button') }}
</button>
</div>
<!-- Toolbar -->
<div class="flex items-center gap-3 mb-6">
<button
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
@click="loadServices"
>
{{ $t('admin.services.refresh') }}
</button>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="text-center py-12 text-gray-400">
{{ $t('admin.services.loading') }}
</div>
<!-- Error State -->
<div v-else-if="store.error" class="text-center py-12 text-red-400">
{{ store.error }}
</div>
<!-- Empty State -->
<div
v-else-if="store.services.length === 0"
class="text-center py-12 text-gray-500"
>
<span class="text-5xl block mb-4">🛠</span>
<p class="text-lg">{{ $t('admin.services.empty') }}</p>
<p class="text-sm mt-1">{{ $t('admin.services.empty_hint') }}</p>
</div>
<!-- Service Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="svc in store.services"
:key="svc.id"
class="relative bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-blue-500/30 transition-all group"
:class="{ 'opacity-70': !svc.is_active }"
>
<!-- Inactive Badge -->
<div
v-if="!svc.is_active"
class="absolute top-3 right-3 px-2 py-0.5 bg-yellow-900/60 text-yellow-300 border border-yellow-700 rounded-full text-xs font-medium"
>
{{ $t('admin.services.badge_inactive') }}
</div>
<!-- Service Name & Description -->
<div class="mb-4">
<h3 class="text-lg font-bold text-white">{{ svc.name }}</h3>
<!-- Service Code as small secondary badge -->
<span class="inline-block mt-1 px-2 py-0.5 bg-gray-700/50 text-gray-500 border border-gray-600/50 rounded text-xs font-mono">
{{ svc.service_code }}
</span>
<p v-if="svc.description" class="text-sm text-gray-400 mt-2 line-clamp-2">
{{ svc.description }}
</p>
</div>
<!-- Credit Cost -->
<div class="border-t border-gray-700/50 pt-3 mb-4">
<div class="flex justify-between text-sm">
<span class="text-gray-400">{{ $t('admin.services.credit_cost_label') }}</span>
<span class="text-gray-200 font-medium">{{ svc.credit_cost }} {{ $t('admin.services.credit_unit') }}</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-3 border-t border-gray-700/50">
<button
class="flex-1 px-3 py-2 text-sm font-medium text-blue-300 bg-blue-900/30 hover:bg-blue-900/50 border border-blue-700/50 rounded-lg transition-colors"
@click="openEditModal(svc)"
>
{{ $t('admin.services.edit_button') }}
</button>
<button
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors"
:class="svc.is_active
? 'text-red-300 bg-red-900/30 hover:bg-red-900/50 border border-red-700/50'
: 'text-green-300 bg-green-900/30 hover:bg-green-900/50 border border-green-700/50'"
@click="toggleActive(svc)"
>
{{ svc.is_active ? $t('admin.services.deactivate_button') : $t('admin.services.activate_button') }}
</button>
</div>
</div>
</div>
<!-- Edit / Create Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="closeModal"
>
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-lg mx-4 shadow-2xl">
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-bold text-white">
{{ editingService ? $t('admin.services.modal_edit_title') : $t('admin.services.modal_create_title') }}
</h3>
<button
class="text-gray-400 hover:text-white transition-colors"
@click="closeModal"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="px-6 py-4 space-y-4">
<!-- Service Code (only for create) -->
<div v-if="!editingService">
<label class="block text-sm font-medium text-gray-300 mb-1">
{{ $t('admin.services.field_code') }}
</label>
<input
v-model="form.service_code"
type="text"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
:placeholder="$t('admin.services.field_code_placeholder')"
/>
<p v-if="formErrors.service_code" class="text-red-400 text-xs mt-1">{{ formErrors.service_code }}</p>
</div>
<!-- Name -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
{{ $t('admin.services.field_name') }}
</label>
<input
v-model="form.name"
type="text"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
:placeholder="$t('admin.services.field_name_placeholder')"
/>
<p v-if="formErrors.name" class="text-red-400 text-xs mt-1">{{ formErrors.name }}</p>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
{{ $t('admin.services.field_description') }}
</label>
<textarea
v-model="form.description"
rows="3"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none"
:placeholder="$t('admin.services.field_description_placeholder')"
></textarea>
</div>
<!-- Credit Cost (no spinners) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
{{ $t('admin.services.field_credit_cost') }}
</label>
<input
v-model.number="form.credit_cost"
type="number"
min="0"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-700">
<button
class="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
@click="closeModal"
>
{{ $t('admin.services.cancel') }}
</button>
<button
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="store.isLoading"
@click="saveService"
>
<span v-if="store.isLoading">{{ $t('admin.services.saving') }}</span>
<span v-else>{{ editingService ? $t('admin.services.save_changes') : $t('admin.services.create') }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useAdminServicesStore } from '../../stores/adminServices'
import type { ServiceCatalogItem } from '../../stores/adminServices'
const store = useAdminServicesStore()
// ── State ───────────────────────────────────────────────────────────────────
const showModal = ref(false)
const editingService = ref<ServiceCatalogItem | null>(null)
const form = reactive({
service_code: '',
name: '',
description: '',
credit_cost: 0,
})
const formErrors = reactive({
service_code: '',
name: '',
})
// ── Lifecycle ───────────────────────────────────────────────────────────────
onMounted(() => {
loadServices()
})
// ── Methods ─────────────────────────────────────────────────────────────────
async function loadServices() {
await store.fetchServices()
}
function resetForm() {
form.service_code = ''
form.name = ''
form.description = ''
form.credit_cost = 0
formErrors.service_code = ''
formErrors.name = ''
}
function validateForm(): boolean {
let valid = true
formErrors.service_code = ''
formErrors.name = ''
if (!editingService) {
if (!form.service_code || form.service_code.trim().length < 3) {
formErrors.service_code = 'Service code must be at least 3 characters'
valid = false
}
}
if (!form.name || form.name.trim().length < 2) {
formErrors.name = 'Name must be at least 2 characters'
valid = false
}
return valid
}
// ── Modal Handlers ──────────────────────────────────────────────────────────
function openCreateModal() {
editingService.value = null
resetForm()
showModal.value = true
}
function openEditModal(svc: ServiceCatalogItem) {
editingService.value = svc
form.service_code = svc.service_code
form.name = svc.name
form.description = svc.description || ''
form.credit_cost = svc.credit_cost
formErrors.service_code = ''
formErrors.name = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingService.value = null
resetForm()
}
async function saveService() {
if (!validateForm()) return
try {
if (editingService.value) {
await store.updateService(editingService.value.id, {
name: form.name.trim(),
description: form.description.trim() || null,
credit_cost: form.credit_cost,
})
} else {
await store.createService({
service_code: form.service_code.trim().toUpperCase(),
name: form.name.trim(),
description: form.description.trim() || null,
credit_cost: form.credit_cost,
is_active: true,
})
}
closeModal()
await loadServices()
} catch (err: any) {
// Error is already set in the store
}
}
async function toggleActive(svc: ServiceCatalogItem) {
try {
await store.updateService(svc.id, { is_active: !svc.is_active })
await loadServices()
} catch (err: any) {
// Error is already set in the store
}
}
</script>
<style scoped>
.admin-services-view {
font-family: 'Inter', sans-serif;
}
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>

View File

@@ -0,0 +1,594 @@
<template>
<div class="admin-users-view">
<!-- Header: Search & Filters -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<!-- Search Category Dropdown -->
<select
v-model="searchCategory"
class="px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">{{ $t('admin.users.search_category_all') }}</option>
<option value="email">{{ $t('admin.users.search_category_email') }}</option>
<option value="name">{{ $t('admin.users.search_category_name') }}</option>
<option value="phone">{{ $t('admin.users.search_category_phone') }}</option>
<option value="id">{{ $t('admin.users.search_category_id') }}</option>
<option value="address">{{ $t('admin.users.search_category_address') }}</option>
</select>
<!-- Search Input + Clear Button + Search Button -->
<div class="relative flex-1 min-w-[200px] flex items-center gap-2">
<div class="relative flex-1">
<input
v-model="searchQuery"
type="text"
:placeholder="$t('admin.users.search_placeholder')"
class="w-full px-4 py-2 pr-10 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
@keyup.enter="triggerSearch"
/>
<!-- Clear (X) button -->
<button
v-if="searchQuery"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-200 transition-colors rounded-full hover:bg-gray-600"
@click="clearSearch"
:title="$t('admin.users.clear_search')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
@click="triggerSearch"
:title="$t('admin.users.search_button')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
<!-- Role Filter -->
<select
v-model="roleFilter"
class="px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="applyFilters"
>
<option value="">{{ $t('admin.users.filter_all_roles') }}</option>
<option value="superadmin">Superadmin</option>
<option value="admin">Admin</option>
<option value="region_admin">Region Admin</option>
<option value="country_admin">Country Admin</option>
<option value="moderator">Moderator</option>
<option value="user">User</option>
<option value="service_owner">Service Owner</option>
<option value="fleet_manager">Fleet Manager</option>
<option value="driver">Driver</option>
</select>
<!-- Status Filter -->
<select
v-model="statusFilter"
class="px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="applyFilters"
>
<option value="">{{ $t('admin.users.filter_all_status') }}</option>
<option value="active">{{ $t('admin.users.status_active') }}</option>
<option value="inactive">{{ $t('admin.users.status_inactive') }}</option>
<option value="deleted">{{ $t('admin.users.status_deleted') }}</option>
</select>
<!-- Refresh Button -->
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
@click="loadUsers"
>
{{ $t('admin.users.refresh') }}
</button>
</div>
<!-- Bulk Action Bar (visible when at least 1 user is selected) -->
<div
v-if="selectedUserIds.length > 0"
class="sticky top-[64px] z-30 flex flex-wrap items-center gap-3 px-4 py-3 mb-4 bg-gray-800 border border-gray-600 rounded-lg shadow-lg"
>
<span class="text-sm text-gray-300">
{{ $t('admin.users.n_selected', { count: selectedUserIds.length }) }}
</span>
<button
class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="bulkLoading"
@click="executeBulkAction('ban')"
>
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_ban') }}
</button>
<button
class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="bulkLoading"
@click="executeBulkAction('unban')"
>
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_unban') }}
</button>
<button
class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="bulkLoading"
@click="executeBulkAction('soft_delete')"
>
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_soft_delete') }}
</button>
<button
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="bulkLoading"
@click="executeBulkAction('restore')"
>
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_restore') }}
</button>
<!-- Hard Delete: Only visible for Superadmin -->
<button
v-if="authStore.user?.role === 'superadmin'"
class="px-3 py-1.5 bg-red-800 hover:bg-red-900 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="bulkLoading"
@click="executeBulkAction('hard_delete')"
>
{{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_hard_delete') }}
</button>
<button
class="px-3 py-1.5 bg-gray-600 hover:bg-gray-500 text-white text-sm rounded-lg transition-colors ml-auto"
@click="clearSelection"
>
{{ $t('admin.users.clear_selection') }}
</button>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="text-center py-8 text-gray-400">
{{ $t('admin.users.loading') }}
</div>
<!-- Error State -->
<div v-else-if="store.error" class="text-center py-8 text-red-400">
{{ store.error }}
</div>
<!-- Users Table -->
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-gray-700 text-left text-sm text-gray-400 uppercase">
<th class="py-3 px-4 w-12">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
class="rounded bg-gray-700 border-gray-500"
/>
</th>
<th class="py-3 px-4">ID</th>
<th class="py-3 px-4">{{ $t('admin.users.col_email') }}</th>
<th class="py-3 px-4">{{ $t('admin.users.col_name') }}</th>
<th class="py-3 px-4">{{ $t('admin.users.col_phone') }}</th>
<th class="py-3 px-4">{{ $t('admin.users.col_address') }}</th>
<th class="py-3 px-4">{{ $t('admin.users.col_role') }}</th>
<th class="py-3 px-4">{{ $t('admin.users.col_status') }}</th>
<th class="py-3 px-4">{{ $t('admin.users.col_subscription') }}</th>
<th class="py-3 px-4">{{ $t('admin.users.col_region') }}</th>
<th class="py-3 px-4">{{ $t('admin.users.col_created') }}</th>
<th class="py-3 px-4 w-16">{{ $t('admin.users.col_actions') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in store.users"
:key="user.id"
class="border-b border-gray-700/50 hover:bg-gray-700/30 transition-colors"
>
<td class="py-3 px-4">
<input
type="checkbox"
:checked="selectedUserIds.includes(user.id)"
@change="toggleUser(user.id)"
class="rounded bg-gray-700 border-gray-500"
/>
</td>
<td class="py-3 px-4 text-gray-400 text-sm">{{ user.id }}</td>
<td class="py-3 px-4">{{ user.email }}</td>
<td class="py-3 px-4">{{ user.first_name || '' }} {{ user.last_name || '' }}</td>
<td class="py-3 px-4 text-sm text-gray-300">{{ user.phone || '-' }}</td>
<td class="py-3 px-4 text-sm text-gray-300">{{ user.address || '-' }}</td>
<td class="py-3 px-4">
<span
class="inline-block px-2 py-0.5 text-xs font-medium rounded-full"
:class="roleBadgeClass(user.role)"
>
{{ user.role }}
</span>
</td>
<td class="py-3 px-4">
<span
class="inline-block px-2 py-0.5 text-xs font-medium rounded-full"
:class="statusBadgeClass(user)"
>
{{ statusLabel(user) }}
</span>
</td>
<td class="py-3 px-4 text-sm text-gray-300">{{ user.subscription_plan }}</td>
<td class="py-3 px-4 text-sm text-gray-400">{{ user.region_code }}</td>
<td class="py-3 px-4 text-sm text-gray-400">{{ formatDate(user.created_at) }}</td>
<td class="py-3 px-4 relative">
<!-- Kebab Menu -->
<div class="relative inline-block text-left">
<button
class="p-1.5 rounded-lg hover:bg-gray-600 transition-colors text-gray-400 hover:text-gray-200"
@click.stop="toggleKebab(user.id)"
:title="$t('admin.users.actions')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01" />
</svg>
</button>
<!-- Dropdown -->
<div
v-if="openKebabId === user.id"
class="absolute right-0 mt-1 w-56 bg-gray-800 border border-gray-600 rounded-lg shadow-xl z-40 py-1"
@click.stop
>
<button
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
@click="editUser(user)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{{ $t('admin.users.action_edit') }}
</button>
<button
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
@click="sendPasswordReset(user)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{{ $t('admin.users.action_password_reset') }}
</button>
<button
v-if="!user.is_deleted && user.is_active"
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
@click="individualAction(user.id, 'ban')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
{{ $t('admin.users.action_ban') }}
</button>
<button
v-if="!user.is_deleted && !user.is_active"
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
@click="individualAction(user.id, 'unban')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
{{ $t('admin.users.action_unban') }}
</button>
<button
v-if="!user.is_deleted"
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
@click="individualAction(user.id, 'soft_delete')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
{{ $t('admin.users.action_soft_delete') }}
</button>
<button
v-if="user.is_deleted"
class="w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 transition-colors flex items-center gap-2"
@click="individualAction(user.id, 'restore')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
{{ $t('admin.users.action_restore') }}
</button>
<div class="border-t border-gray-600 my-1"></div>
<button
v-if="authStore.user?.role === 'superadmin'"
class="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 transition-colors flex items-center gap-2"
@click="individualAction(user.id, 'hard_delete')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
{{ $t('admin.users.action_hard_delete') }}
</button>
</div>
</div>
</td>
</tr>
<!-- Empty State -->
<tr v-if="store.users.length === 0 && !store.isLoading">
<td :colspan="12" class="py-12 text-center">
<div class="flex flex-col items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-gray-400 text-lg">
{{ searchQuery ? $t('admin.users.empty_search') : $t('admin.users.empty_no_users') }}
</p>
<p v-if="searchQuery" class="text-gray-500 text-sm">
{{ $t('admin.users.empty_search_hint') }}
</p>
<button
v-if="searchQuery"
class="mt-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg transition-colors text-sm"
@click="clearSearch"
>
{{ $t('admin.users.clear_search') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination & Stats Footer -->
<div class="flex items-center justify-between mt-6 pt-4 border-t border-gray-700">
<div class="text-sm text-gray-400">
{{ $t('admin.users.showing_of', { showing: store.users.length, total: store.total }) }}
</div>
<div class="flex items-center gap-2">
<button
:disabled="currentSkip === 0"
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors"
@click="prevPage"
>
{{ $t('admin.users.pagination_prev') }}
</button>
<span class="text-sm text-gray-400">
{{ $t('admin.users.pagination_page', { current: currentPage, total: totalPages }) }}
</span>
<button
:disabled="currentSkip + currentLimit >= store.total"
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors"
@click="nextPage"
>
{{ $t('admin.users.pagination_next') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAdminUsersStore } from '../../stores/adminUsers'
import { useAuthStore } from '../../stores/auth'
import api from '../../api/axios'
const store = useAdminUsersStore()
const authStore = useAuthStore()
// ── Filter State ──────────────────────────────────────────────────
const searchQuery = ref('')
const searchCategory = ref('all')
const roleFilter = ref('')
const statusFilter = ref('')
// ── Pagination State ──────────────────────────────────────────────
const currentSkip = ref(0)
const currentLimit = ref(50)
// ── Selection State ───────────────────────────────────────────────
const selectedUserIds = ref<number[]>([])
const bulkLoading = ref(false)
// ── Kebab Menu State ──────────────────────────────────────────────
const openKebabId = ref<number | null>(null)
// ── Computed ──────────────────────────────────────────────────────
const currentPage = computed(() => Math.floor(currentSkip.value / currentLimit.value) + 1)
const totalPages = computed(() => Math.max(1, Math.ceil(store.total / currentLimit.value)))
const isAllSelected = computed(() => {
return store.users.length > 0 && store.users.every((u) => selectedUserIds.value.includes(u.id))
})
const isIndeterminate = computed(() => {
const selected = store.users.filter((u) => selectedUserIds.value.includes(u.id)).length
return selected > 0 && selected < store.users.length
})
// ── Methods ───────────────────────────────────────────────────────
function roleBadgeClass(role: string): string {
const map: Record<string, string> = {
superadmin: 'bg-purple-900/60 text-purple-300 border border-purple-700',
admin: 'bg-blue-900/60 text-blue-300 border border-blue-700',
region_admin: 'bg-indigo-900/60 text-indigo-300 border border-indigo-700',
country_admin: 'bg-cyan-900/60 text-cyan-300 border border-cyan-700',
moderator: 'bg-green-900/60 text-green-300 border border-green-700',
user: 'bg-gray-700/60 text-gray-300 border border-gray-600',
service_owner: 'bg-orange-900/60 text-orange-300 border border-orange-700',
fleet_manager: 'bg-teal-900/60 text-teal-300 border border-teal-700',
driver: 'bg-slate-700/60 text-slate-300 border border-slate-600',
}
return map[role] || 'bg-gray-700/60 text-gray-300 border border-gray-600'
}
function statusBadgeClass(user: { is_active: boolean; is_deleted: boolean }): string {
if (user.is_deleted) return 'bg-red-900/60 text-red-300 border border-red-700'
if (user.is_active) return 'bg-green-900/60 text-green-300 border border-green-700'
return 'bg-yellow-900/60 text-yellow-300 border border-yellow-700'
}
function statusLabel(user: { is_active: boolean; is_deleted: boolean }): string {
if (user.is_deleted) return 'Deleted'
if (user.is_active) return 'Active'
return 'Inactive'
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function triggerSearch() {
currentSkip.value = 0
loadUsers()
}
function clearSearch() {
searchQuery.value = ''
searchCategory.value = 'all'
currentSkip.value = 0
loadUsers()
}
function applyFilters() {
currentSkip.value = 0
loadUsers()
}
async function loadUsers() {
const params: Record<string, any> = {
skip: currentSkip.value,
limit: currentLimit.value,
}
if (searchQuery.value) {
params.search_term = searchQuery.value
params.search_category = searchCategory.value
}
if (roleFilter.value) params.role = roleFilter.value
if (statusFilter.value === 'active') params.is_active = true
else if (statusFilter.value === 'inactive') params.is_active = false
else if (statusFilter.value === 'deleted') params.is_deleted = true
await store.fetchUsers(params)
selectedUserIds.value = []
}
function prevPage() {
if (currentSkip.value > 0) {
currentSkip.value = Math.max(0, currentSkip.value - currentLimit.value)
loadUsers()
}
}
function nextPage() {
if (currentSkip.value + currentLimit.value < store.total) {
currentSkip.value += currentLimit.value
loadUsers()
}
}
// ── Selection Methods ─────────────────────────────────────────────
function toggleUser(userId: number) {
const idx = selectedUserIds.value.indexOf(userId)
if (idx === -1) {
selectedUserIds.value.push(userId)
} else {
selectedUserIds.value.splice(idx, 1)
}
}
function toggleSelectAll() {
if (isAllSelected.value) {
selectedUserIds.value = []
} else {
selectedUserIds.value = store.users.map((u) => u.id)
}
}
function clearSelection() {
selectedUserIds.value = []
}
// ── Kebab Menu ────────────────────────────────────────────────────
function toggleKebab(userId: number) {
openKebabId.value = openKebabId.value === userId ? null : userId
}
// Close kebab on outside click
function handleClickOutside() {
openKebabId.value = null
}
// ── Individual Actions ────────────────────────────────────────────
function editUser(user: any) {
console.log('Edit user:', user.id, user.email)
openKebabId.value = null
// Future: open edit modal
}
async function sendPasswordReset(user: any) {
openKebabId.value = null
if (!window.confirm(`Send password reset email to ${user.email}?`)) return
try {
await api.post('/auth/password-reset/initiate', { email: user.email })
alert(`Password reset email sent to ${user.email}`)
} catch (err: any) {
alert(err.response?.data?.detail || 'Failed to send password reset email')
}
}
async function individualAction(userId: number, action: string) {
openKebabId.value = null
const actionLabel = action.replace('_', ' ')
if (!window.confirm(`Are you sure you want to ${actionLabel} user #${userId}?`)) return
try {
await store.bulkAction([userId], action)
alert(`User #${userId} ${actionLabel} successful`)
} catch (err: any) {
alert(store.error || `Failed to ${actionLabel} user #${userId}`)
}
}
// ── Bulk Actions ──────────────────────────────────────────────────
async function executeBulkAction(action: string) {
if (selectedUserIds.value.length === 0) return
const actionLabel = action.replace('_', ' ')
if (!window.confirm(`Are you sure you want to ${actionLabel} ${selectedUserIds.value.length} user(s)?`)) return
bulkLoading.value = true
try {
const result = await store.bulkAction(selectedUserIds.value, action)
alert(result.message)
selectedUserIds.value = []
} catch (err: any) {
alert(store.error || `Failed to execute ${action}`)
} finally {
bulkLoading.value = false
}
}
// ── Lifecycle ─────────────────────────────────────────────────────
onMounted(() => {
loadUsers()
document.addEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.admin-users-view {
font-family: 'Inter', sans-serif;
}
input[type='checkbox'] {
accent-color: #3b82f6;
}
</style>

View File

@@ -205,6 +205,17 @@
<button class="w-full px-4 py-3 rounded-xl bg-amber-50/80 hover:bg-amber-100/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-amber-200">
{{ t('company.quickSchedule') || 'Schedule Service' }}
</button>
<!-- Organization Settings Button -->
<button
@click="showSettingsModal = true"
class="w-full px-4 py-3 rounded-xl bg-emerald-50/80 hover:bg-emerald-100/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-emerald-200 flex items-center gap-2"
>
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ t('company.settingsTitle') }}
</button>
</div>
<!-- Recent activity mini-list -->
<div class="mt-4 pt-3 border-t border-slate-200">
@@ -224,15 +235,26 @@
</div>
</div>
</div>
<!-- Organization Settings Modal -->
<OrganizationSettingsModal
v-if="showSettingsModal"
@close="showSettingsModal = false"
@saved="showSettingsModal = false"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import GlassCard from '../../components/ui/GlassCard.vue'
import OrganizationSettingsModal from '../../components/organization/OrganizationSettingsModal.vue'
const { t } = useI18n()
// ── Organization Settings Modal ──
const showSettingsModal = ref(false)
// ── Placeholder notifications ──
const notifications = ref([
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },

View File

@@ -161,6 +161,123 @@
/>
</div>
</template>
<!-- Smart Company Claiming / Joining Panels -->
<!-- Active Exists: Company is already registered -->
<template v-if="companyStatus === 'active_exists'">
<div class="rounded-xl border border-[#D4AF37]/30 bg-[#D4AF37]/10 p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-[#D4AF37] mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-sm text-[#D4AF37] font-medium mb-2">{{ t('company.activeExistsPanel') }}</p>
<button
@click="sendJoinRequest"
:disabled="isJoinRequestLoading"
class="inline-flex items-center gap-2 rounded-xl bg-[#D4AF37]/20 px-4 py-2 text-sm text-[#D4AF37] font-medium border border-[#D4AF37]/30 hover:bg-[#D4AF37]/30 transition-all disabled:opacity-50"
>
<svg v-if="isJoinRequestLoading" class="animate-spin h-4 w-4" 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>
{{ t('company.joinRequestButton') }}
</button>
<p v-if="joinRequestSent" class="text-xs text-[#D4AF37]/70 mt-2">{{ t('company.joinRequestSent') }}</p>
</div>
</div>
</div>
</template>
<!-- Orphaned: Company is inactive, can be claimed -->
<template v-if="companyStatus === 'orphaned'">
<!-- Claim Step 1: Request claim code -->
<template v-if="claimStep === 0">
<div class="rounded-xl border border-[#00E5A0]/30 bg-[#00E5A0]/10 p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-[#00E5A0] mt-0.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-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div class="flex-1">
<p class="text-sm text-[#00E5A0] font-medium mb-2">{{ t('company.orphanedPanel') }}</p>
<button
@click="requestClaimCode"
:disabled="isClaimRequestLoading"
class="inline-flex items-center gap-2 rounded-xl bg-[#00E5A0]/20 px-4 py-2 text-sm text-[#00E5A0] font-medium border border-[#00E5A0]/30 hover:bg-[#00E5A0]/30 transition-all disabled:opacity-50"
>
<svg v-if="isClaimRequestLoading" class="animate-spin h-4 w-4" 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>
{{ t('company.claimRequestButton') }}
</button>
</div>
</div>
</div>
</template>
<!-- Claim Step 2: Verify OTP -->
<template v-if="claimStep === 1">
<div class="rounded-xl border border-[#00E5A0]/30 bg-[#00E5A0]/10 p-4">
<p class="text-sm text-[#00E5A0] font-medium mb-3">{{ t('company.claimCodeSent') }}</p>
<p class="text-xs text-white/60 mb-3">{{ t('company.claimVerifyTitle') }}</p>
<div class="flex gap-2 items-end">
<div class="flex-1">
<input
v-model="claimOtp"
type="text"
maxlength="6"
placeholder="000000"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all text-sm tracking-widest text-center"
/>
</div>
<button
@click="verifyClaimCode"
:disabled="isClaimVerifyLoading || claimOtp.length !== 6"
class="inline-flex items-center gap-2 rounded-xl bg-[#00E5A0]/20 px-4 py-2.5 text-sm text-[#00E5A0] font-medium border border-[#00E5A0]/30 hover:bg-[#00E5A0]/30 transition-all disabled:opacity-50"
>
<svg v-if="isClaimVerifyLoading" class="animate-spin h-4 w-4" 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>
{{ t('company.claimVerifyButton') }}
</button>
</div>
<p v-if="claimError" class="text-xs text-red-400 mt-2">{{ claimError }}</p>
<p v-if="claimSuccess" class="text-xs text-[#00E5A0] mt-2">{{ t('company.claimSuccess') }}</p>
</div>
</template>
<!-- Manual Fallback -->
<div class="rounded-xl border border-white/10 bg-white/5 p-4 mt-3">
<p class="text-sm text-white/80 font-medium mb-1">{{ t('company.manualFallbackTitle') }}</p>
<p class="text-xs text-white/50 mb-3">{{ t('company.manualFallbackDescription') }}</p>
<div class="flex gap-2 items-end">
<div class="flex-1">
<label class="block text-xs text-white/50 mb-1">{{ t('company.uploadDocumentLabel') }}</label>
<input
ref="manualFileInput"
type="file"
accept=".pdf,.jpg,.jpeg,.png"
@change="onManualFileSelected"
class="w-full text-xs text-white/60 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-medium file:bg-white/10 file:text-white/80 hover:file:bg-white/20 cursor-pointer"
/>
</div>
<button
@click="submitManualClaim"
:disabled="isManualSubmitting || !manualFile"
class="inline-flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2.5 text-sm text-white/80 font-medium border border-white/20 hover:bg-white/20 transition-all disabled:opacity-50"
>
<svg v-if="isManualSubmitting" class="animate-spin h-4 w-4" 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>
{{ t('company.manualSubmitButton') }}
</button>
</div>
<p v-if="manualSubmitSuccess" class="text-xs text-[#00E5A0] mt-2">{{ t('company.manualSubmitSuccess') }}</p>
</div>
</template>
</div>
</div>
@@ -500,6 +617,22 @@ const isTaxLookupLoading = ref(false)
const taxLookedUp = ref(false)
const isDuplicateCompany = ref(false)
// ── Smart Company Claiming / Joining State ──
const companyStatus = ref<string | null>(null) // 'active_exists' | 'orphaned' | null
const companyId = ref<number | null>(null) // ID of the existing company
const isJoinRequestLoading = ref(false)
const joinRequestSent = ref(false)
const claimStep = ref(0) // 0 = request code, 1 = verify OTP
const isClaimRequestLoading = ref(false)
const claimOtp = ref('')
const isClaimVerifyLoading = ref(false)
const claimError = ref('')
const claimSuccess = ref(false)
const manualFileInput = ref<HTMLInputElement | null>(null)
const manualFile = ref<File | null>(null)
const isManualSubmitting = ref(false)
const manualSubmitSuccess = ref(false)
// ── CorpOnboardIn Form ──
const form = reactive({
full_name: '',
@@ -619,11 +752,21 @@ function validateStep(): boolean {
return true
}
// ── Reset duplicate flag when tax number changes ──
// ── Reset flags when tax number changes ──
function onTaxNumberChange() {
if (isDuplicateCompany.value) {
isDuplicateCompany.value = false
}
// Reset claiming/joining state
companyStatus.value = null
companyId.value = null
joinRequestSent.value = false
claimStep.value = 0
claimOtp.value = ''
claimError.value = ''
claimSuccess.value = false
manualSubmitSuccess.value = false
manualFile.value = null
}
// ── Navigation ──
@@ -670,10 +813,40 @@ async function lookupTaxNumber() {
isTaxLookupLoading.value = true
errorMessage.value = null
isDuplicateCompany.value = false
// Reset claiming/joining state
companyStatus.value = null
companyId.value = null
joinRequestSent.value = false
claimStep.value = 0
claimOtp.value = ''
claimError.value = ''
claimSuccess.value = false
manualSubmitSuccess.value = false
manualFile.value = null
try {
const res = await api.get('/organizations/lookup-tax/' + form.tax_number)
const data = res.data
// Auto-fill form fields from response
// Check for smart company claiming/joining statuses
if (data.status === 'active_exists') {
companyStatus.value = 'active_exists'
companyId.value = data.id || null
// Auto-fill company name for display
if (data.full_name) form.full_name = data.full_name
if (data.name) form.name = data.name
return // Stop — don't reveal onboarding fields
}
if (data.status === 'orphaned') {
companyStatus.value = 'orphaned'
companyId.value = data.id || null
// Auto-fill company name for display
if (data.full_name) form.full_name = data.full_name
if (data.name) form.name = data.name
return // Stop — don't reveal onboarding fields
}
// Normal flow: company not found, proceed with onboarding
if (data.full_name) form.full_name = data.full_name
if (data.name) {
form.name = data.name
@@ -706,6 +879,96 @@ async function lookupTaxNumber() {
}
}
// ── Smart Company Claiming / Joining Functions ──
/**
* Send a join request to an active company's administrator.
*/
async function sendJoinRequest() {
if (!companyId.value) return
isJoinRequestLoading.value = true
try {
await api.post(`/organizations/${companyId.value}/join-request`)
joinRequestSent.value = true
} catch (err: any) {
errorMessage.value = err.response?.data?.detail || t('company.lookupError')
} finally {
isJoinRequestLoading.value = false
}
}
/**
* Request a claim code for an orphaned company.
* The code is sent to the company's official email.
*/
async function requestClaimCode() {
if (!companyId.value) return
isClaimRequestLoading.value = true
claimError.value = ''
try {
await api.post(`/organizations/${companyId.value}/claim/request`)
claimStep.value = 1 // Move to OTP verification step
} catch (err: any) {
claimError.value = err.response?.data?.detail || t('company.claimError')
} finally {
isClaimRequestLoading.value = false
}
}
/**
* Verify the claim code and complete the company claim.
*/
async function verifyClaimCode() {
if (!companyId.value || claimOtp.value.length !== 6) return
isClaimVerifyLoading.value = true
claimError.value = ''
try {
await api.post(`/organizations/${companyId.value}/claim/verify`, {
otp: claimOtp.value,
})
claimSuccess.value = true
// Refresh organizations and navigate to company garage
await authStore.fetchMyOrganizations()
setTimeout(() => {
router.push('/company/garage')
}, 1500)
} catch (err: any) {
claimError.value = err.response?.data?.detail || t('company.claimError')
} finally {
isClaimVerifyLoading.value = false
}
}
/**
* Handle file selection for manual claim.
*/
function onManualFileSelected(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
manualFile.value = target.files[0]
}
}
/**
* Submit a manual claim with uploaded documentation.
*/
async function submitManualClaim() {
if (!companyId.value || !manualFile.value) return
isManualSubmitting.value = true
try {
const formData = new FormData()
formData.append('document', manualFile.value)
await api.post(`/organizations/${companyId.value}/claim/manual`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
manualSubmitSuccess.value = true
} catch (err: any) {
errorMessage.value = err.response?.data?.detail || t('company.claimError')
} finally {
isManualSubmitting.value = false
}
}
// ── Submit ──
async function handleSubmit() {
if (!validateStep()) return

View File

@@ -0,0 +1,445 @@
# Költség/Billing Rendszer Elemzés & Módosítási Javaslat (v2)
## Premium szintű költségbontás — Subscription-alapú kategória láthatóság + Km tracking
**Készült:** 2026.06.12.
**Verzió:** v2.0 (User feedback alapján átdolgozva)
**Státusz:** Jóváhagyásra vár
---
## 1. Jelenlegi Állapot Elemzés
### 1.1 Előfizetési Architektúra
| Komponens | Tábla | Mező | Státusz |
|-----------|-------|------|---------|
| [`User`](backend/app/models/identity/identity.py:122) | `identity.users` | `subscription_plan: str` (default="FREE") | ✅ Megvan |
| [`User`](backend/app/models/identity/identity.py:122) | `identity.users` | `subscription_expires_at: datetime` | ✅ Megvan |
| [`User`](backend/app/models/identity/identity.py:122) | `identity.users` | `ui_mode: str` ("personal"/"business") | ✅ Megvan |
| [`Organization`](backend/app/models/marketplace/organization.py:35) | `marketplace.organizations` | `subscription_plan: str` (default="FREE") | ✅ Megvan |
| [`SubscriptionTier`](backend/app/models/core_logic.py:12) | `system.subscription_tiers` | `name: str`, `rules: JSONB` | ✅ Megvan |
| [`subscription_service.py`](backend/app/services/) | **Hiányzó fájl** | Gitea #239 | ❌ **Nincs meg** |
| [`subscription_worker.py`](backend/app/workers/system/subscription_worker.py:32) | Cron robot | Auto-downgrade expired → FREE | ✅ Megvan |
### 1.2 Költség Modellek
#### [`AssetCost`](backend/app/models/vehicle/asset.py:239) (`vehicle.asset_costs`) — **Modern/Aktív tábla**
- **Használja:** [`POST /expenses/`](backend/app/api/v1/endpoints/expenses.py:12), [`CostService.record_cost()`](backend/app/services/cost_service.py:23)
- **Mezői:** `id` (UUID), `asset_id`, `organization_id`, `category_id`, `amount_net`, `currency`, `date`, `invoice_number`, `data` (JSONB)
- **`data` JSONB tárol:** `mileage_at_cost`, `description`
- **⚠️ Nincs subscription check** — bármely felhasználó bármilyen adatot rögzíthet
#### [`CostCategory`](backend/app/models/vehicle/vehicle.py:19) (`vehicle.cost_categories`)
- **Meglévő mezők:** `id`, `parent_id` (hierarchia), `code`, `name`, `description`, `is_system`, `visibility` ("both"/"b2b"), `accounting_code`
- **Jelenlegi visibility map:** FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, OTHER → `"both"` ; FINANCE, ADMIN → `"b2b"`
- **❌ Nincs `subscription_tier` mező** — nem lehet megkülönböztetni FREE és PREMIUM kategóriákat
#### [`Asset.current_mileage`](backend/app/models/vehicle/asset.py:111) — **Meglévő km mező**
- `current_mileage: Mapped[int] = mapped_column(Integer, default=0, index=True)` — Az Asset modellen
- **❌ Probléma:** Nincs automatizmus, ami frissítené amikor költséget rögzítenek
- **❌ Probléma:** Nincs history tábla a km állás változásainak követésére
#### [`AssetTelemetry`](backend/app/models/vehicle/asset.py:336) (`vehicle.asset_telemetry`)
- 1:1 kapcsolat Asset-tel, `current_mileage: Mapped[int]` mezővel
- **Technikai adósság:** Duplikálja az `Asset.current_mileage` mezőt
#### [`VehicleOdometerState`](backend/app/models/vehicle/vehicle.py:111) (`vehicle.vehicle_odometer_states`)
- Legacy tábla — `vehicle_model_definitions.id`-hez kötődik (nem az Asset UUID-hez)
- Tárol: `last_recorded_odometer`, `daily_avg_distance`, `estimated_current_odometer`
- **Nem használható** az új Asset-alapú architektúrában
### 1.3 API Végpontok Állapota
| Végpont | Fájl | Subscription Check | Megjegyzés |
|---------|------|-------------------|------------|
| `POST /expenses/` | [`expenses.py:12`](backend/app/api/v1/endpoints/expenses.py:12) | ❌ | Csak DRAFT limit check van |
| `GET /{vehicle_id}/summary` | [`analytics.py:66`](backend/app/api/v1/endpoints/analytics.py:66) | ❌ | TCO summary |
| `GET /dashboard` | [`analytics.py:199`](backend/app/api/v1/endpoints/analytics.py:199) | ❌ | Mock data |
| `POST /upgrade` | [`billing.py:20`](backend/app/api/v1/endpoints/billing.py:20) | ✅ | Megvan |
### 1.4 Hiányosságok (Gap Analysis)
| # | Hiányosság | Leírás | Súlyosság |
|---|-----------|--------|-----------|
| 1 | **Nincs subscription gating** | A `POST /expenses/` nem ellenőrzi a subscription_plan-t | 🔴 Kritikus |
| 2 | **Hiányzó `subscription_service.py`** | Gitea #239 — entitlement mátrix hiányzik | 🔴 Blokkoló |
| 3 | **Nincs `CostCategory.min_tier` mező** | Nem lehet megkülönböztetni FREE/PREMIUM kategóriákat | 🟡 Magas |
| 4 | **Km tracking nincs centralizálva** | `Asset.current_mileage` létezik, de nincs automatikus frissítés | 🟡 Magas |
| 5 | **Nincs OdometerLog history** | Nincs audit trail a km állás változásokról | 🟡 Közepes |
| 6 | **Párhuzamos telemetry tábla** | `AssetTelemetry` duplikálja `Asset.current_mileage`-t | 🟠 Alacsony |
---
## 2. Módosítási Javaslat (Logic Spec v2)
### 2.1 CostCategory Bővítés — `min_tier` mező
**Nem kell új `UserCostCategory` modell!** A meglévő [`CostCategory`](backend/app/models/vehicle/vehicle.py:19) kap egy új mezőt:
```python
class CostCategory(Base):
# ... meglévő mezők ...
# ÚJ: Minimum előfizetési szint a kategória használatához
min_tier: Mapped[str] = mapped_column(
String(20),
default="free",
server_default="'free'"
)
```
**CostCategory seed adat — kategória szintek:**
| Kategória CSoport | `code` értékek | `visibility` | `min_tier` | Leírás |
|-------------------|---------------|-------------|-----------|--------|
| **Alap (FREE)** | FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, OTHER | `both` | `free` | Mindenki számára elérhető |
| **Kiterjesztett (PREMIUM)** | PARKING, TOLL, FINANCE, ADMIN | `both`/`b2b` | `premium` | Csak PREMIUM+ tagoknak |
| **Vállalati (VIP)** | *(speciális account kódok)* | `b2b` | `vip` | Csak VIP ügyfeleknek |
### 2.2 Odometer Rendszer — Centralizált Km Tracking
#### 2.2.1 Új modell: [`OdometerReading`](backend/app/models/vehicle/asset.py)
```python
class OdometerReading(Base):
"""
Kilométeróra állások history audit trail.
Minden esemény (költség, szerviz, telemetria) itt rögzíti a km állást.
Csak APPEND-only — soha nem törlődik, csak új rekord kerül hozzá.
"""
__tablename__ = "odometer_readings"
__table_args__ = {"schema": "vehicle"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("vehicle.assets.id", ondelete="CASCADE"),
index=True,
nullable=False
)
mileage: Mapped[int] = mapped_column(Integer, nullable=False) # km állás
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False
)
source: Mapped[str] = mapped_column(String(50), nullable=False)
# source lehet: 'cost_entry', 'manual', 'telemetry', 'import', 'service_event'
source_id: Mapped[Optional[str]] = mapped_column(String(100))
# source_id lehet: a kapcsolódó AssetCost UUID-ja, vagy esemény ID
asset: Mapped["Asset"] = relationship("Asset", back_populates="odometer_readings")
```
#### 2.2.2 [`Asset`](backend/app/models/vehicle/asset.py:69) — Bővítés
```python
class Asset(Base):
# ... meglévő mezők ...
# MEGLÉVŐ — marad, de automatikusan frissül:
current_mileage: Mapped[int] = mapped_column(Integer, default=0, index=True)
# ÚJ kapcsolat:
odometer_readings: Mapped[List["OdometerReading"]] = relationship(
"OdometerReading", back_populates="asset"
)
```
#### 2.2.3 [`CostService.record_cost()`](backend/app/services/cost_service.py:23) — Km frissítés
```python
class CostService:
async def record_cost(self, db: AsyncSession, cost_in: AssetCostCreate, user_id: int):
# 1. Meglévő logika (OCR, XP, currency conversion) ...
mileage = cost_in.data.get("mileage_at_cost") if cost_in.data else None
# 2. NEW: OdometerReading log + Asset.current_mileage frissítés
if mileage is not None:
# a) Logold a km állást
reading = OdometerReading(
asset_id=cost_in.asset_id,
mileage=mileage,
source="cost_entry",
source_id=str(new_cost.id)
)
db.add(reading)
# b) Frissítsd az Asset.current_mileage mezőt (ha nagyobb)
asset = await db.get(Asset, cost_in.asset_id)
if asset and mileage > asset.current_mileage:
asset.current_mileage = mileage
# 3. Meglévő logika folytatása ...
```
### 2.3 `subscription_service.py` — Entitlement Mátrix (Gitea #239)
```python
"""
subscription_service.py — Gitea #239
Feature entitlement mátrix subscription szintek szerint.
"""
from enum import Enum
class Feature(str, Enum):
EXTENDED_CATEGORIES = "extended_categories" # PARKING, TOLL, FINANCE, ADMIN
DOCUMENT_ATTACHMENT = "document_attachment"
VAT_TRACKING = "vat_tracking"
TCO_BREAKDOWN = "tco_breakdown"
EXPORT_CSV = "export_csv"
ANALYTICS_DASHBOARD = "analytics_dashboard"
FEATURE_ENTITLEMENTS = {
"FREE": [
Feature.TCO_BREAKDOWN, # Basic összesített TCO
],
"PREMIUM": [
Feature.EXTENDED_CATEGORIES, # Kiterjesztett kategóriák
Feature.DOCUMENT_ATTACHMENT,
Feature.VAT_TRACKING,
Feature.TCO_BREAKDOWN, # Részletes bontás
Feature.EXPORT_CSV,
Feature.ANALYTICS_DASHBOARD,
],
"VIP": [
Feature.EXTENDED_CATEGORIES,
Feature.DOCUMENT_ATTACHMENT,
Feature.VAT_TRACKING,
Feature.TCO_BREAKDOWN,
Feature.EXPORT_CSV,
Feature.ANALYTICS_DASHBOARD,
],
}
async def check_feature_access(db, user_id, feature):
"""Ellenőrzi a feature elérést a user subscription alapján."""
user = await db.get(User, user_id)
if not user:
return False
return feature in FEATURE_ENTITLEMENTS.get(user.subscription_plan, [])
async def require_feature(db, user_id, feature):
"""403-as hibát dob ha nincs hozzáférés."""
if not await check_feature_access(db, user_id, feature):
raise HTTPException(
status_code=403,
detail=f"Feature '{feature.value}' requires PREMIUM subscription"
)
async def get_available_categories(db, user_id):
"""Visszaadja a user számára elérhető CostCategory listát."""
user = await db.get(User, user_id)
plan = user.subscription_plan if user else "FREE"
query = """
SELECT * FROM vehicle.cost_categories
WHERE min_tier IN ('free', :plan)
ORDER BY parent_id NULLS FIRST, sort_order
"""
result = await db.execute(text(query), {"plan": plan.lower()})
return result.mappings().all()
```
### 2.4 Végpont Módosítások
#### 2.4.1 [`POST /expenses/`](backend/app/api/v1/endpoints/expenses.py:12)
```python
@router.post("/", status_code=201)
async def create_expense(
expense: AssetCostCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# 1. Ellenőrizd, hogy a kategória elérhető-e a user számára
category = await db.get(CostCategory, expense.category_id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
if category.min_tier != "free" and current_user.subscription_plan == "FREE":
raise HTTPException(
status_code=403,
detail=f"Category '{category.name}' requires PREMIUM subscription"
)
# 2. PREMIUM+ dokumentum csatolás check
if expense.document_id:
await require_feature(db, current_user.id, Feature.DOCUMENT_ATTACHMENT)
# 3. Létrehozás
new_cost = AssetCost(
asset_id=expense.asset_id,
organization_id=organization_id,
category_id=expense.category_id,
amount_net=expense.amount_net,
currency=expense.currency,
date=expense.date,
invoice_number=expense.invoice_number,
data=expense.data
)
db.add(new_cost)
await db.flush() # Kell az ID-hoz
# 4. Odometer frissítés (ha van mileage_at_cost)
if expense.data and expense.data.get("mileage_at_cost"):
mileage = int(expense.data["mileage_at_cost"])
reading = OdometerReading(
asset_id=expense.asset_id,
mileage=mileage,
source="cost_entry",
source_id=str(new_cost.id)
)
db.add(reading)
asset = await db.get(Asset, expense.asset_id)
if asset and mileage > asset.current_mileage:
asset.current_mileage = mileage
await db.commit()
await db.refresh(new_cost)
```
#### 2.4.2 `GET /cost-categories/` — ÚJ végpont
```python
@router.get("/cost-categories/")
async def list_cost_categories(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Visszaadja a user számára elérhető költségkategóriákat."""
categories = await get_available_categories(db, current_user.id)
return categories
```
### 2.5 [`AssetCost`](backend/app/models/vehicle/asset.py:239) — Bővítés (minimális)
Csak a `document_id` mező kerül hozzáadásra (az Evidence Storeba mutató hivatkozás).
A többi funkció (VAT, notes) a meglévő `data` JSONB-ben tárolható, és csak PREMIUM-nak elérhető a frontenden.
```python
class AssetCost(Base):
# ... meglévő mezők ...
# ÚJ (opcionális, PREMIUM feature):
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True))
```
### 2.6 Admin Kontroll (SystemParameter)
| Paraméter | Scope | Default | Leírás |
|-----------|-------|---------|--------|
| `COST_EXTENDED_CATEGORIES_ENABLED` | GLOBAL | `true` | Kiterjesztett kategóriák bekapcsolása |
| `COST_VAT_TRACKING_ENABLED` | GLOBAL | `false` | ÁFA követés bekapcsolása |
| `COST_ODOMETER_REQUIRED` | GLOBAL | `true` | Km állás kötelező legyen-e |
---
## 3. Folyamatábra
```mermaid
flowchart TD
subgraph REQUEST["POST /expenses/"]
A[Bejövő kérés] --> B{Van category_id?}
B -->|Igen| C[CostCategory lekérése]
C --> D{min_tier == free?}
D -->|Igen| E[Engedélyezve]
D -->|Nem| F{User subscription?}
F -->|FREE| G[403 - Premium feature]
F -->|PREMIUM+| H[Engedélyezve]
E --> I[AssetCost létrehozása]
H --> I
I --> J{Van mileage_at_cost?}
J -->|Igen| K[OdometerReading log]
K --> L{ mileage > Asset.current_mileage ?}
L -->|Igen| M[Asset.current_mileage frissítés]
L -->|Nem| N[Nincs frissítés]
M --> O[Commit]
J -->|Nem| O
N --> O
end
subgraph READ["GET /cost-categories/"]
P[User subscription] --> Q{subscription_plan}
Q -->|FREE| R[Csak free kategóriák]
Q -->|PREMIUM+| S[Free + premium kategóriák]
end
```
---
## 4. Implementációs Terv
### 4.1 Lépések
| # | Lépés | Érintett fájlok | Függőség |
|---|-------|-----------------|----------|
| 1 | **`subscription_service.py`** létrehozása | `backend/app/services/subscription_service.py` | — |
| 2 | **`CostCategory.min_tier`** mező hozzáadása | [`backend/app/models/vehicle/vehicle.py:19`](backend/app/models/vehicle/vehicle.py:19) | #1 |
| 3 | **`OdometerReading`** modell létrehozása | [`backend/app/models/vehicle/asset.py`](backend/app/models/vehicle/asset.py) | — |
| 4 | **`Asset`** kapcsolat + `AssetCost.document_id` | [`backend/app/models/vehicle/asset.py:69`](backend/app/models/vehicle/asset.py:69) | #3 |
| 5 | **Pydantic sémák bővítése** | [`backend/app/schemas/asset_cost.py`](backend/app/schemas/asset_cost.py) | #2, #4 |
| 6 | **`CostService.record_cost()`** — km frissítés | [`backend/app/services/cost_service.py:23`](backend/app/services/cost_service.py:23) | #3 |
| 7 | **`POST /expenses/`** — subscription gating + km log | [`backend/app/api/v1/endpoints/expenses.py:12`](backend/app/api/v1/endpoints/expenses.py:12) | #1, #2, #6 |
| 8 | **`GET /cost-categories/`** — új végpont | [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py) | #2 |
| 9 | **Seed script**`min_tier` értékek beállítása | `backend/scripts/seed_cost_category_tiers.py` | #2 |
| 10 | **Sync engine + tesztek** | `backend/app/scripts/sync_engine.py` | #2-#5 |
### 4.2 Adatbázis Szinkron
```bash
# Modellek módosítása után:
docker compose exec sf_api python3 /app/backend/app/scripts/sync_engine.py
```
### 4.3 Seed Script — Kategória szintek beállítása
```python
# backend/scripts/seed_cost_category_tiers.py
"""CostCategory min_tier mező seedelése."""
TIER_MAP = {
# FREE kategóriák (mindenki számára)
"FUEL": "free",
"MAINTENANCE": "free",
"SERVICE": "free",
"TIRE": "free",
"INSURANCE": "free",
"OTHER": "free",
# PREMIUM kategóriák (csak előfizetőknek)
"PARKING": "premium",
"TOLL": "premium",
"FINANCE": "premium",
"ADMIN": "premium",
# VIP kategóriák (speciális)
# ...
}
```
---
## 5. Összefoglalás
### Mit változtat a terv:
1. **FREE felhasználók:** Csak az alábbi kategóriákba rögzíthetnek költséget: FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, OTHER. A km állást továbbra is megadhatják, és az automatikusan frissíti az `Asset.current_mileage` mezőt.
2. **PREMIUM+ felhasználók:** Minden kategória elérhető (FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, FINANCE, ADMIN, OTHER). Plusz: dokumentum csatolás, ÁFA követés, részletes TCO bontás.
3. **Km tracking:** Az `OdometerReading` tábla minden költségrögzítéskor naplózza a km állást. Az `Asset.current_mileage` automatikusan frissül (ha az új érték nagyobb). Ez folyamatos, megbízható audit trail-et biztosít.
### Amit NEM kell megvalósítani (v2 törlések):
-`UserCostCategory` modell — a felhasználók nem hoznak létre saját kategóriákat
-`CostTag` / `AssetCostTag` — nem kell külön címke rendszer
-`AssetCost.user_category_id` — kategória kiválasztás nem szükséges
### Blokkoló:
- **Gitea #239 (`subscription_service.py`)** — ezt mindenképp létre kell hozni az entitlement mátrixhoz
---
**Jóváhagyásra vár:** Kérem a fenti v2 terv áttekintését. Ha elfogadásra kerül, létrehozom a Gitea kártyákat és megkezdjük az implementációt.

View File

@@ -0,0 +1,179 @@
# 🏗️ Logic Spec: Identity-Preserving Soft Delete & Asset Validation
## 1. Modul Célja és Masterbook 2 Illeszkedés
**Mérföldkő:** Identity & Security Hardening
**Masterbook 2 Referencia:** [`docs/v201/05_AUTH_AND_IDENTITY_SPEC.md`](docs/v201/05_AUTH_AND_IDENTITY_SPEC.md) (Soft Delete / Anonymization szekció)
**Masterbook 2 Referencia:** [`docs/v201/18_ASSET_AND_FLEET_SPECIFICATION.md`](docs/v201/18_ASSET_AND_FLEET_SPECIFICATION.md) (Asset specifikáció)
Jelenlegi állapot a Masterbook szerint:
- A Soft Delete már létezik, de **hiányos**: nincs `deleted_at` timestamp, nincs token invalidáció, nem védi a Person rekordot.
- Az Asset modellben nincs `CheckConstraint` a VIN/Rendszám "Vagy-Vagy" szabályhoz.
- A Pydantic sémákban nincs `model_validator` a kötelező azonosító validációhoz.
---
## 2. Adatmodell változások
### 2.1 Asset CheckConstraint (`backend/app/models/vehicle/asset.py`)
**Jelenlegi állapot:** A [`Asset`](backend/app/models/vehicle/asset.py:69) osztály `__table_args__`-ja csak a sémát adja meg:
```python
__table_args__ = {"schema": "vehicle"}
```
**Módosítás:** Bővítsük ki a `__table_args__`-t egy `CheckConstraint`-tel:
```python
__table_args__ = (
CheckConstraint(
"vin IS NOT NULL OR license_plate IS NOT NULL",
name="ck_asset_vin_or_plate_required"
),
{"schema": "vehicle"}
)
```
**Import:** A [`CheckConstraint`](backend/app/models/vehicle/asset.py:7) már elérhető az SQLAlchemy importok között? Ellenőrizendő: jelenleg csak `UniqueConstraint` van importálva. Ki kell egészíteni:
```python
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, CheckConstraint, BigInteger, Integer, Float
```
### 2.2 deleted_at oszlop a User modellhez (`backend/app/models/identity/identity.py`)
**Jelenlegi állapot:** A [`User`](backend/app/models/identity/identity.py:122) osztályban már van `is_deleted: Mapped[bool]` (149. sor), de hiányzik a `deleted_at`.
**Módosítás:** Add hozzá a `deleted_at` mezőt a `User` osztályhoz a `created_at` után:
```python
# === SOFT DELETE ===
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
```
### 2.3 deleted_at oszlop a Person modellhez (opcionális, struktúra miatt)
**Jelenlegi állapot:** A [`Person`](backend/app/models/identity/identity.py:36) osztályban nincs `deleted_at`.
**Módosítás:** Add hozzá a struktúra konzisztencia miatt:
```python
# === SOFT DELETE (structure only - never delete Person data) ===
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
```
---
## 3. Pydantic Séma Módosítások
### 3.1 AssetCreate séma (`backend/app/schemas/asset.py`)
**Jelenlegi állapot:** A [`license_plate`](backend/app/schemas/asset.py:116) kötelező (`Field(...)`), a `vin` opcionális.
**Módosítás:** Mindkét mező legyen `Optional[str] = None`:
```python
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám (opcionális)")
```
**Validátor:** Adj hozzá egy `@model_validator` (Pydantic V2 mód) vagy `@root_validator` (Pydantic V1 mód) metódust, ami:
1. Üres string (`""`) átalakítása `None`-ra mindkét mezőnél
2. Ha mindkettő `None`, dobjon `ValueError`-t (422-es HTTP válasz)
### 3.2 AssetUpdate séma (`backend/app/schemas/asset.py`)
**Jelenlegi állapot:** A [`license_plate`](backend/app/schemas/asset.py:203) és `vin` már `Optional[str] = None`.
**Módosítás:** Adj hozzá egy `@model_validator`-t vagy `@root_validator`-t, ami csak akkor dob hibát, ha a felhasználó explicitly `None`-ra akarja állítani mindkettőt.
---
## 4. Backend Service Logika: Person-Preserving Soft Delete
### 4.1 AuthService.soft_delete_user módosítása (`backend/app/services/auth_service.py`)
**Jelenlegi kód:** [`soft_delete_user`](backend/app/services/auth_service.py:486-503)
**Módosítások sorrendben:**
1. **`deleted_at` beállítás:**
```python
user.deleted_at = datetime.now(timezone.utc)
```
2. **E-mail átírás** (már létezik, de pontosítva):
```python
old_email = user.email
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
user.email = f"deleted_{user.id}_{timestamp}_{old_email}"
```
3. **Person rekord érintetlenül hagyása** (CRITICAL - maradjon ahogy van, NE módosítsuk)
4. **Token invalidáció:** Töröljük az összes aktív Refresh Token-t
5. **Naplózás** bővítése `deleted_at`-tal
---
## 5. API Végpont: DELETE /api/v1/users/me
### 5.1 Új endpoint (`backend/app/api/v1/endpoints/users.py`)
```python
@router.delete("/me", status_code=200)
async def delete_my_account(
reason: Optional[str] = Body(None, description="Törlés oka (opcionális)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Saját fiók soft-delete.
- Anonimizálja az e-mail címet
- is_active = False, is_deleted = True, deleted_at = timestamp
- Person rekordot NEM bántja
- Érvényteleníti az összes refresh token-t
- Wallet és Gamification adatokat megőrzi (inaktív user miatt freeze)
"""
success = await AuthService.soft_delete_user(
db=db,
user_id=current_user.id,
reason=reason or "user_requested_self_delete",
actor_id=current_user.id
)
if not success:
raise HTTPException(
status_code=400,
detail="A felhasználó már törölve van."
)
return {"status": "ok", "message": "Fiók sikeresen törölve."}
```
---
## 6. Adatbázis Szinkronizáció
A módosítások után futtatni kell a sync_engine-t:
```bash
docker exec -it sf_api python -m app.scripts.sync_engine
```
---
## 7. Tesztterv
### 7.1 Asset validáció tesztelése
1. Hozz létre járművet **csak VIN-nel** → sikeres
2. Hozz létre járművet **csak rendszámmal** → sikeres
3. Hozz létre járművet **mindkettővel** → sikeres
4. Hozz létre járművet **egyik nélkül sem** → 422-es hiba
### 7.2 Soft Delete tesztelése
1. Hozz létre usert, authentikálj
2. DELETE `/api/v1/users/me` hívás
3. Ellenőrizd: `User.is_deleted = True`, `User.is_active = False`, `User.deleted_at` beállítva
4. Ellenőrizd: `Person` rekord adatai változatlanok, `Person.is_active` változatlan
5. Ellenőrizd: Refresh token törölve (régi session nem működik)