admin frontend elkezdése, járművek tisztázása, frontend fejleszts
This commit is contained in:
1193
.roo/history.md
1193
.roo/history.md
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
@@ -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."
|
||||
}
|
||||
319
backend/app/api/v1/endpoints/admin_packages.py
Normal file
319
backend/app/api/v1/endpoints/admin_packages.py
Normal 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,
|
||||
}
|
||||
110
backend/app/api/v1/endpoints/admin_services.py
Normal file
110
backend/app/api/v1/endpoints/admin_services.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
33
backend/app/schemas/service_catalog.py
Normal file
33
backend/app/schemas/service_catalog.py
Normal 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)
|
||||
143
backend/app/schemas/subscription.py
Normal file
143
backend/app/schemas/subscription.py
Normal 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]
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
241
backend/app/scripts/cleanup_user.py
Normal file
241
backend/app/scripts/cleanup_user.py
Normal 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()
|
||||
363
backend/app/scripts/migrate_subscriptions.py
Normal file
363
backend/app/scripts/migrate_subscriptions.py
Normal 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())
|
||||
269
backend/app/scripts/seed_packages.py
Normal file
269
backend/app/scripts/seed_packages.py
Normal 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())
|
||||
94
backend/app/scripts/seed_services.py
Normal file
94
backend/app/scripts/seed_services.py
Normal 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())
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
162
backend/app/services/subscription_service.py
Normal file
162
backend/app/services/subscription_service.py
Normal 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
|
||||
82
backend/backend/scripts/test_delete_me.py
Normal file
82
backend/backend/scripts/test_delete_me.py
Normal 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())
|
||||
31
backend/scripts/check_constraint.py
Normal file
31
backend/scripts/check_constraint.py
Normal 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.')
|
||||
249
backend/scripts/repair_kyc_user.py
Normal file
249
backend/scripts/repair_kyc_user.py
Normal 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())
|
||||
97
backend/scripts/seed_cost_category_tiers.py
Normal file
97
backend/scripts/seed_cost_category_tiers.py
Normal 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())
|
||||
245
backend/scripts/test_kyc_e2e.py
Normal file
245
backend/scripts/test_kyc_e2e.py
Normal 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)
|
||||
269
backend/scripts/test_soft_delete_and_asset_validation.py
Normal file
269
backend/scripts/test_soft_delete_and_asset_validation.py
Normal 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)
|
||||
285
docs/gitea_state_analysis_2026-06-15.md
Normal file
285
docs/gitea_state_analysis_2026-06-15.md
Normal 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
|
||||
132
docs/odometer_duplication_audit.md
Normal file
132
docs/odometer_duplication_audit.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
81
frontend/src/components/ModeSwitcher.vue
Normal file
81
frontend/src/components/ModeSwitcher.vue
Normal 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>
|
||||
482
frontend/src/components/admin/PackageEditModal.vue
Normal file
482
frontend/src/components/admin/PackageEditModal.vue
Normal 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>
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
318
frontend/src/components/organization/CompanyDataModal.vue
Normal file
318
frontend/src/components/organization/CompanyDataModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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}',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
244
frontend/src/stores/adminPackages.ts
Normal file
244
frontend/src/stores/adminPackages.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
142
frontend/src/stores/adminServices.ts
Normal file
142
frontend/src/stores/adminServices.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
129
frontend/src/stores/adminUsers.ts
Normal file
129
frontend/src/stores/adminUsers.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
95
frontend/src/views/admin/AdminDashboardView.vue
Normal file
95
frontend/src/views/admin/AdminDashboardView.vue
Normal 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>
|
||||
327
frontend/src/views/admin/AdminPackagesView.vue
Normal file
327
frontend/src/views/admin/AdminPackagesView.vue
Normal 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>
|
||||
349
frontend/src/views/admin/AdminServicesView.vue
Normal file
349
frontend/src/views/admin/AdminServicesView.vue
Normal 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>
|
||||
594
frontend/src/views/admin/AdminUsersView.vue
Normal file
594
frontend/src/views/admin/AdminUsersView.vue
Normal 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>
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
445
plans/cost_billing_analysis_premium_breakdown_v1.md
Normal file
445
plans/cost_billing_analysis_premium_breakdown_v1.md
Normal 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.
|
||||
179
plans/logic_spec_identity_preserving_soft_delete.md
Normal file
179
plans/logic_spec_identity_preserving_soft_delete.md
Normal 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)
|
||||
Reference in New Issue
Block a user