frontend költség beállítások

This commit is contained in:
Roo
2026-06-23 21:11:21 +00:00
parent 5b437b220d
commit 71ef33bb85
90 changed files with 11850 additions and 1053 deletions

View File

@@ -1,74 +1,204 @@
# Service Finder Fejlesztési Történet # Service Finder Fejlesztési Történet
## 2026-06-14 - Admin Search Bugs Fix & Sticky UX ## 2026-06-21 - P0 Deep Audit: Database Consistency & Zombie API Hunt
### 🎯 Cél ### 🎯 Cél
Backend SQL hibák javítása a GET /admin/users végponton (Address outerjoin, phone search), valamint frontend UX fejlesztések (sticky bulk action bar, clear/X gomb, üres állapot üzenet, HeaderProfile az AdminLayout-ban). Teljes körű adatbázis konzisztencia ellenőrzés és zombie API végpontok felderítése a `vehicle`, `finance`, `fleet_finance` sémákban, mielőtt a frontend fejlesztés elkezdődik.
### 🔧 Változtatások ### 🔧 Eredmények
**1. BACKEND - [`admin.py`](backend/app/api/v1/endpoints/admin.py:502):** **1. ADATBÁZIS TISZTASÁG:** ✅ PASS
- `GET /admin/users` SQL javítás: `outerjoin(Person).outerjoin(Address)``outerjoin(Person).outerjoin(Address, ...)` (explicit ON clause a person_id-n) - API modul import: ✅ Sikeres (2 route: GET, POST)
- `phone` keresés hozzáadva a `search_category`-hez (LIKE `person.phone`) - Sync Engine: ✅ 1210 OK, 0 Fixed, 0 Extra
- `is_active` és `is_deleted` filterek külön kezelése (nem zárják ki egymást) - E2E test: ⚠️ Pre-existing conftest hiba (verification token timeout - nem kapcsolódó)
**2. FRONTEND - [`AdminUsersView.vue`](frontend/src/views/admin/AdminUsersView.vue):** ## 2026-06-23 - Cost Entry Wizard (CostEntryWizard.vue) - 4-lépéses Számla Űrlap
- Bulk action bar: `sticky` pozícionálás (top-0, z-30)
- Clear/X gomb a keresőmezőben
- Üres állapot kezelés: "No users match your search" / "No users in the database yet."
- HeaderProfile komponens hozzáadva az AdminLayout-hoz
### ✅ Verifikáció
- `docker compose exec sf_api python3 -m pytest backend/tests/test_04_business_onboarding.py -v` → 7 passed
- Manuális teszt: GET /admin/users?search_term=...&search_category=phone működik
## 2026-06-19 - P0 BUG FIX: VehicleDetails Blank Page & OverviewTab
### 🎯 Cél ### 🎯 Cél
A VehicleDetailsView üres oldalt (blank page) mutatott, amikor a Dashboardról vagy FleetView-ról navigáltak rá. A hiba oka a nem-reaktív tab definíciók és a hiányzó loading/error állapotkezelés volt. Emellett az OverviewTab csak egy placeholdert tartalmazott. 4-lépéses stepper wizard építése részletes számla/invoice költségek rögzítésére a CostsActionsCard-ból indíthatóan.
### 🔧 Változtatások ### 🔧 Módosított fájlok
- **`frontend/src/components/cost/CostEntryWizard.vue`** (ÚJ) - 4-step stepper wizard komponens
- **`frontend/src/components/dashboard/CostsActionsCard.vue`** - "🧾 Részletes Számla" gomb hozzáadva, CostEntryWizard import és modal state
- **`frontend/src/i18n/en.ts`** - costWizard i18n szekció hozzáadva
- **`frontend/src/i18n/hu.ts`** - costWizard i18n szekció hozzáadva
**1. [`VehicleDetailsView.vue`](frontend/src/views/vehicles/VehicleDetailsView.vue) - Blank Page Fix:** ### 🧩 CostEntryWizard lépések
- **Root Cause:** A `tabs` tömb `const`-ként volt definiálva, benne `computed()` ref-ekkel, amelyek sosem frissültek. A `v-for="tab in tabs"` nem volt reaktív. 1. **Alapadatok**: Jármű (read-only), kategória cascade (fő+alkategória), dátum
- **Fix:** A `tabs` tömböt `computed<TabDefinition[]>(() => [...])`-re cseréltük, így a teljes tömb reaktív. 2. **Pénzügyek**: Nettó összeg, ÁFA kulcs (0/5/10/20/27%), Bruttó összeg (GROSS-FIRST), Pénznem
- **Loading State:** `v-if="isLoading"` spinner hozzáadva, ami akkor jelenik meg, ha `vehicleStore.isLoading` true VAGY a vehicles tömb üres és a vehicle még nincs betöltve. 3. **Admin & Szállító**: Dinamikus vendor autocomplete (GET /organizations?org_type=SERVICE_PROVIDER) szabad szöveges fallbackkel, számlaszám, számla dátuma, fizetési határidő
- **Error/Empty State:** `v-else-if="!vehicle"` üres állapot ikonnal és `t('vehicleDetail.notFound')` üzenettel. 4. **Fájlok**: Drag & drop fájl feltöltő UI (csak UI szintű file picker)
- **Vehicle Data Flow:** `provide('vehicle', vehicle)` használatával a jármű adatokat átadjuk a child tab komponenseknek.
- **Async onMounted:** `await vehicleStore.fetchVehicles()` hívás, hogy direkt URL-es navigációnál (F5) is betöltődjenek az adatok.
**2. [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue) - Full Layout:** ### 🔌 API Integráció
- **Photo Section:** 16:9 / négyzet alakú placeholder szürke háttérrel, kamera ikonnal és "Photo upload coming soon" felirattal. - POST `/expenses/` a `costStore.addExpense()`-on keresztül AssetCostBase payload-dal
- **Basic Info:** Nagy, hangsúlyos rendszám (4xl), alatta brand/model (2xl), évjárat, majd elválasztó után VIN, üzemanyag típus, futásteljesítmény. - Payload tartalmazza: amount_gross (GROSS-FIRST), category_id, currency, date, data JSONB (net_amount, vat_rate, invoice_number, invoice_date, payment_deadline, vendor_id, vendor_name, file_names)
- **Vital Signs (3 kártya):**
- **MOT Expiry:** Naptár ikon, dátum megjelenítés, színkódolás (piros=lejárt, sárga=<30 nap, zöld=rendben).
- **Current Mileage:** Villám ikon, formázott km érték.
- **Next Service:** Dokumentum ikon, "Coming soon" placeholder (később a service book-hoz kapcsolva).
- **Data Flow:** `inject<computed<Vehicle | null>>('vehicle')` a szülő komponensből.
**3. [`en.ts`](frontend/src/i18n/en.ts) - New i18n keys:**
- `vehicleDetail.notFound`, `vehicleDetail.noPlate`, `vehicleDetail.photoPlaceholder`, `vehicleDetail.vin`, `vehicleDetail.fuelType`, `vehicleDetail.mileage`, `vehicleDetail.motExpiry`, `vehicleDetail.nextService`, `vehicleDetail.noData`, `vehicleDetail.comingSoon`
### ✅ Verifikáció ### ✅ Verifikáció
- A tab navigáció most már reaktív (`computed`-be csomagolt `tabDefinitions`) - Vite build: ✅ Sikeres (253 modules, 6.94s)
- A loading spinner megjelenik adatok betöltése közben - Gitea kártya: #277
- Az OverviewTab teljes layout-ja renderelődik: fotó placeholder + alapadatok + 3 vital signs kártya
- A jármű adatok `provide`/`inject` mechanizmussal jutnak el a tab komponensekhez
## 2026-06-19 - FleetView jármű részletek navigációs hiba javítása ## 2026-06-23 - Dashboard Cost Card Navigation Fix & Dedicated Costs Page
### 🎯 Cél ### 🎯 Cél
A privát jármű oldalon (FleetView) a kártyákra kattintva nem nyílt meg a jármű teljes adatlapja az 5 aloldallal (Overview, TechData, ServiceBook, Financials, Documents). A dashboard költség kártya üres területére kattintva és a "További költségek" gombbal is a költségek oldalra navigálás. Dedikált CostsView oldal létrehozása vissza gombbal és garázs háttérrel.
### 🔧 Változtatások ### 🔧 Módosított fájlok
- **`frontend/src/views/costs/CostsView.vue`** (ÚJ) - Teljes költség oldal garázs háttérrel, vissza gombbal, költség táblázattal és paginációval
**1. [`FleetView.vue`](frontend/src/views/vehicles/FleetView.vue:62):** A `@click` handler `router.push(\`/vehicles/\${vehicle.id}\`)` útvonalat használt, ami nem egyezik egyetlen regisztrált route-pal sem. A helyes útvonalak: - **`frontend/src/router/index.ts`** - `/dashboard/costs` route hozzáadva a CostsView-hoz
- Privát mód: `/dashboard/vehicles/:id` - **`frontend/src/components/dashboard/CostsActionsCard.vue`** - Header bar kattinthatóvá téve + "További költségek" gomb átirányítva a `/dashboard/costs` oldalra
- Céges mód: `/organization/:id/vehicles/:id` - **`frontend/src/layouts/PrivateLayout.vue`** - Hamburger menü "Költségek" navigáció `/dashboard/costs`-ra módosítva
- **`frontend/src/i18n/hu.ts`** - `costs.backToDashboard` kulcs hozzáadva
Javítás: Az `openVehicleDetail()` függvény az `authStore.isCorporateMode` és `active_organization_id` alapján dinamikusan választja ki a megfelelő útvonalat, megegyező logikával, mint a [`MyVehiclesCard.vue`](frontend/src/components/dashboard/MyVehiclesCard.vue:72)-ben lévő `openVehicleDetail()`. - **`frontend/src/i18n/en.ts`** - `costs.backToDashboard` kulcs hozzáadva
### ✅ Verifikáció ### ✅ Verifikáció
- A kártyára kattintás után a böngésző a `/dashboard/vehicles/:id` (privát) vagy `/organization/:id/vehicles/:id` (céges) útvonalra navigál - Vite build: ✅ Sikeres (CostsView-DaFsqPnD.js chunk generálva)
- A `VehicleDetailsView.vue` betöltődik és megjeleníti az 5 tabot
- A TypeScript fordítás hibátlan (csak pre-existing `.js` fájl warningok) ## 2026-06-23 - Hotfix: Backend Expenses GET / endpoint & AssetCost.organization FK fix
### 🎯 Cél
Két kritikus backend hiba javítása:
1. A `GET /api/v1/expenses/` hívás 404-et adott (nem létezett `GET /` végpont), amit a reverse proxy 307-es HTTPS→HTTP átirányítással válaszolt, ami Mixed Content blokkolást okozott a böngészőben.
2. A backend indításkor `InvalidRequestError`-ral összeomlott az `AssetCost.organization` relationship hibás konfigurációja miatt (két FK ugyanarra a táblára, de a `foreign_keys` paraméter hiányzott).
### 🔧 Módosított fájlok
- **`backend/app/api/v1/endpoints/expenses.py`** - `GET /` végpont hozzáadva: list_all_expenses() - szervezet összes költségének listázása paginációval, Asset, CostCategory, Organization JOIN-okkal
- **`backend/app/models/fleet_finance/models.py`** - `AssetCost.organization` relationship javítva: `foreign_keys=[organization_id]` paraméter hozzáadva a kétértelmű FK kapcsolat feloldásához
### 🔧 Eredmények
- Backend konténer: ✅ Tisztán indul, nincs SQLAlchemy hiba
- Login: ✅ Működik (nem omlik össze)
- `GET /api/v1/expenses/`: ✅ Végpont elérhető, paginált költség listát ad vissza
## 2026-06-23 - Hotfix #2: Expenses API 307 redirect / Mixed Content - trailing slash fix
### 🎯 Cél
A `GET /api/v1/expenses` hívás 307-es HTTPS→HTTP átirányítást kapott, mert a backend route `@router.get("/")` (perjellel) volt definiálva, de a frontend `/expenses` (perjel nélkül) hívta. A Starlette automatikus 307 redirect-je a külső openresty proxy miatt `http://` címmé alakult → Mixed Content blokk.
### 🔧 Módosított fájlok
- **`backend/app/api/v1/endpoints/expenses.py`** - `@router.get("/")``@router.get("")` (perjel eltávolítva a route-ból)
### 🔧 Eredmények
- Backend konténer: ✅ Tisztán indul
- `GET /api/v1/expenses` most direkt egyezik a route-tal → nincs 307 redirect → nincs Mixed Content
## 2026-06-23 - Hotfix #3: Expenses API 500 Internal Server Error - AssetCost.cost_date → AssetCost.date
### 🎯 Cél
A `GET /api/v1/expenses` végpont 500-as hibát dobott: `AttributeError: type object 'AssetCost' has no attribute 'cost_date'`. Az `AssetCost` modellben a mező neve `date` (110. sor), de a kód `cost_date`-t használt.
### 🔧 Módosított fájlok
- **`backend/app/api/v1/endpoints/expenses.py`** - 2 helyen javítva:
- 157. sor: `AssetCost.cost_date``AssetCost.date`
- 193. sor: `cost.cost_date.isoformat()``cost.date.isoformat()`
### 🔧 Eredmények
- Backend konténer: ✅ Tisztán indul
- `GET /api/v1/expenses`: ✅ Várhatóan 200 OK (nem 500)
---
## 2026-06-23: Dashboard üres AdPlacementWidget elrejtése
### 🎯 Cél
A felhasználó kérésére: a dashboardon a "PRO" feliratú `SubscriptionStatusWidget` mellett egy üres csempe volt látható (`AdPlacementWidget`), mert nincs hirdetés beállítva a rendszerben. A kérés az volt, hogy rejtsük el ezt az üres csempét.
### 🔧 Módosított fájlok
- **`frontend/src/components/dashboard/AdPlacementWidget.vue`** - `defineEmits` hozzáadva: `ads-loaded` esemény, amely jelzi a szülőnek, hogy van-e hirdetés (`hasAds: boolean`). Az esemény a `fetchAds` siker és hiba ágában is meghívásra kerül.
- **`frontend/src/views/DashboardView.vue`** - `adWidgetHasAds` ref hozzáadva (alapértelmezett: `false`). A hirdetési csempe wrapper `<div>`-je `v-if="adWidgetHasAds"` feltételt kapott, így csak akkor jelenik meg, ha az API ténylegesen adott vissza hirdetéseket. Az `@ads-loaded` esemény frissíti a ref értékét.
### ✅ Verifikáció
- Frontend build: ✅ Sikeres (Vite build, 0 hiba)
## 2026-06-23 - P0 Fix: cost_date vs date mezőnév mismatch + CostsView.vue upgrade
### 🎯 Cél
Két produkciós hiba javítása a cost rögzítésben:
1. **P0 - 500 Internal Server Error** POST `/api/v1/expenses/` végponton: négyrétegű mezőnév-eltérés (`cost_date` vs `date`) a frontend, Pydantic séma, endpoint és SQLAlchemy model között.
2. **P1 - `/dashboard/costs` oldal** nem mutatott semmilyen információt és nem volt költség hozzáadási lehetőség.
### 🔧 Módosított fájlok
**Fix 1a - Backend endpoint** (`backend/app/api/v1/endpoints/expenses.py:464`):
- `cost_date=expense.cost_date``date=expense.cost_date` (SQLAlchemy model mezőneve `date`)
**Fix 1b - Backend Pydantic schema** (`backend/app/schemas/asset_cost.py:37`):
- `validation_alias="date"` hozzáadva a `cost_date` mezőhöz, hogy mind a `date`, mind a `cost_date` kulcsot elfogadja
**Fix 2 - Frontend CostsView.vue** (`frontend/src/views/costs/CostsView.vue`):
- Vezérlősáv hozzáadva járműszűrő dropdown-nal
- " Új Költség" gomb hozzáadva
- CostEntryWizard integráció (modal formában)
- Mezőnevek javítva: `cost_type``category_name`, `amount``amount_gross`, `vehicle_label``vehicle_name`
- `useVehicleStore` importálva a járművek listájához
### ✅ Verifikáció
- Python szintaxis: ✅ asset_cost.py OK, expenses.py OK
- Backend indítás: ✅ Uvicorn fut, nincs hiba
- Frontend build: ✅ Sikeres (Vite build 6.77s, 0 hiba)
- Gitea kártyák: #278 (P0) és #279 (P1) lezárva
## 2026-06-23 - #280 Vizsgálat: Miért nem jelennek meg költségek?
### 🎯 Cél
Annak kivizsgálása, hogy az admin@profibot.hu teszt felhasználó miért nem lát költségeket.
### 🔍 Vizsgálat
- `fleet_finance.asset_costs` táblában összesen **1 rekord** van (org_id=43, asset=PTB162 Mazda 2, 18118 HUF, "Finanszírozás")
- admin@profibot.hu (user_id=2) tagja: org 1 (Test Company), 57, 58, 62, 67
- admin **NEM tagja** org 43-nak ("Accipe garage"), ahova az egyetlen költség tartozik
- admin szervezeteiben (1, 57, 58, 62, 67) összesen **0 db költség** van
### ✅ Következtetés
**NINCS backend/frontend hiba.** A probléma egyszerű **adathiány**:
1. Nincs rögzítve költség admin egyetlen járművéhez sem
2. Az egyetlen DB-ben lévő költség egy másik szervezethez tartozik
3. A #278 és #279 javítások után most már lehetővé vált a költségrögzítés
- Dokumentáció: `/opt/docker/docs/cost_visibility_investigation_280.md`
## 2026-06-23 - #281 Hotfix: Hiányos #278 javítás - expenses.py:533 new_cost.cost_date → new_cost.date
### 🎯 Cél
A #278-as javítás hiányos volt: a konstruktor paramétert kijavítottuk (expenses.py:464: cost_date= → date=), de a response dict-ben (expenses.py:533) továbbra is `new_cost.cost_date`-et használtunk az SQLAlchemy AssetCost modellen, aminek a mezőneve `date`. Ez `AttributeError: 'AssetCost' object has no attribute 'cost_date'` hibát okozott minden POST /api/v1/expenses/ híváskor.
### 🔧 Módosított fájlok
- **`backend/app/api/v1/endpoints/expenses.py:533`** - `new_cost.cost_date.isoformat()``new_cost.date.isoformat()`
### ✅ Verifikáció
- Python szintaxis: ✅ OK
- Backend újraindítás: ✅ OK (Application startup complete)
- POST /api/v1/expenses/ teszt: ✅ **201 Created** - sikeres költség rögzítés a QWE432 járműre
- Response tartalmazza: `"date":"2026-06-23T13:07:34.785065+00:00"` (helyes)
- Gitea kártya: #281 lezárva
## 2026-06-23 - P0 Fix: User 28 Data Anomaly Resolution
### 🎯 Cél
User 28 (tester_pro@profibot.hu) adat-anomália javítása ORM szkript segítségével.
### 🔧 Végrehajtott műveletek
**A) Org 1 javítása:** `fleet.organizations` ID=1 org_type átállítva `fleet_owner``individual`
**B) Tag hozzáadása:** `fleet.organization_members` rekord létrehozva: org=1, user=28, role=OWNER, status=active. Org 1 owner_id=28 beállítva.
**C) Scope törlése:** User 28 `scope_level` visszaállítva `individual`-ra (NOT NULL constraint miatt), `scope_id` None-ra.
**D) Fantom garázs törlése:** Org 68 ("Tester Garázsa") és hozzá tartozó OrganizationMember rekordok törlése.
### ✅ Verifikáció
- Org 1: org_type=individual, owner_id=28
- Org 1 Member: role=OWNER, status=active, is_verified=True
- User 28: scope_level=individual, scope_id=None
- Org 68: nem létezik (deleted)
- Ideiglenes szkript törölve: `backend/scripts/fix_user_28.py`
## 2026-06-23 - P0 Cost Pipeline Visibility Audit (#282)
### 🔍 Felfedezés
A `/dashboard/costs` oldal üres User 28 számára, annak ellenére, hogy Org 1-ben 5 db asset_cost rekord van (BMW123, QWE432).
### 🐛 Gyökér Ok
A `GET /expenses/` endpoint (`backend/app/api/v1/endpoints/expenses.py:130-139`) a user első aktív tagságát használja org feloldáshoz (`.limit(1)` ORDER BY nélkül). User 28-nak 3 tagsága van: org 63 (ADMIN, nincs költség), org 21 (OWNER), org 1 (OWNER, 5 költség). Az első tagság org 63-ra mutat, ami üres.
### 📄 Dokumentáció
`docs/cost_pipeline_audit.md` - Teljes pipeline audit a frontendtől a DB-ig.

View File

@@ -12,11 +12,17 @@ from sqlalchemy.orm import selectinload
from app.db.session import get_db from app.db.session import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole
from app.models.fleet_finance import AssetFinancials, VehicleInsurancePolicy, VehicleTaxObligation
from app.models.identity import User from app.models.identity import User
from app.services.cost_service import cost_service from app.services.cost_service import cost_service
from app.services.asset_service import AssetService from app.services.asset_service import AssetService
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEventCreate, AssetEventResponse from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEventCreate, AssetEventResponse
from app.schemas.fleet_finance import (
AssetFinancialsUpdate, AssetFinancialsResponse,
VehicleInsurancePolicyCreate, VehicleInsurancePolicyUpdate, VehicleInsurancePolicyResponse,
VehicleTaxObligationCreate, VehicleTaxObligationUpdate, VehicleTaxObligationResponse,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1228,4 +1234,363 @@ async def archive_vehicle(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Belső szerverhiba a jármű archiválásakor" detail="Belső szerverhiba a jármű archiválásakor"
) )
# =============================================================================
# Fleet Finance endpoints
# =============================================================================
@router.get(
"/vehicles/{asset_id}/financials",
response_model=AssetFinancialsResponse,
)
async def get_vehicle_financials(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jármű pénzügyi adatainak lekérése.
GET /api/v1/assets/vehicles/{asset_id}/financials
Visszaadja a jármű beszerzési, finanszírozási és lízing adatait.
"""
asset = await _check_asset_access(db, asset_id, current_user)
stmt = select(AssetFinancials).where(AssetFinancials.asset_id == asset_id)
result = await db.execute(stmt)
financials = result.scalar_one_or_none()
if not financials:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A járműhöz nem található pénzügyi adat"
)
return financials
@router.patch(
"/vehicles/{asset_id}/financials",
response_model=AssetFinancialsResponse,
)
async def update_vehicle_financials(
asset_id: uuid.UUID,
payload: AssetFinancialsUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jármű pénzügyi adatainak frissítése.
PATCH /api/v1/assets/vehicles/{asset_id}/financials
Csak a megadott mezőket frissíti (partial update).
"""
asset = await _check_asset_access(db, asset_id, current_user)
stmt = select(AssetFinancials).where(AssetFinancials.asset_id == asset_id)
result = await db.execute(stmt)
financials = result.scalar_one_or_none()
if not financials:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A járműhöz nem található pénzügyi adat"
)
# Partial update: csak a megadott mezőket frissítjük
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(financials, field, value)
await db.commit()
await db.refresh(financials)
return financials
@router.get(
"/vehicles/{asset_id}/insurance",
response_model=List[VehicleInsurancePolicyResponse],
)
async def list_vehicle_insurance_policies(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jármű biztosítási kötvényeinek listázása.
GET /api/v1/assets/vehicles/{asset_id}/insurance
Visszaadja a járműhöz tartozó összes biztosítási kötvényt.
"""
asset = await _check_asset_access(db, asset_id, current_user)
stmt = (
select(VehicleInsurancePolicy)
.where(VehicleInsurancePolicy.asset_id == asset_id)
.order_by(VehicleInsurancePolicy.start_date.desc())
)
result = await db.execute(stmt)
policies = result.scalars().all()
return policies
@router.post(
"/vehicles/{asset_id}/insurance",
response_model=VehicleInsurancePolicyResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_vehicle_insurance_policy(
asset_id: uuid.UUID,
payload: VehicleInsurancePolicyCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Új biztosítási kötvény létrehozása a járműhöz.
POST /api/v1/assets/vehicles/{asset_id}/insurance
"""
asset = await _check_asset_access(db, asset_id, current_user)
policy = VehicleInsurancePolicy(
asset_id=asset_id,
provider_id=payload.provider_id,
insurance_type=payload.insurance_type,
policy_number=payload.policy_number,
start_date=payload.start_date,
expiry_date=payload.expiry_date,
premium_amount=payload.premium_amount,
currency=payload.currency,
)
db.add(policy)
await db.commit()
await db.refresh(policy)
return policy
@router.get(
"/vehicles/{asset_id}/tax",
response_model=List[VehicleTaxObligationResponse],
)
async def list_vehicle_tax_obligations(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jármű adókötelezettségeinek listázása.
GET /api/v1/assets/vehicles/{asset_id}/tax
Visszaadja a járműhöz tartozó összes adókötelezettséget.
"""
asset = await _check_asset_access(db, asset_id, current_user)
stmt = (
select(VehicleTaxObligation)
.where(VehicleTaxObligation.asset_id == asset_id)
.order_by(VehicleTaxObligation.tax_year.desc())
)
result = await db.execute(stmt)
obligations = result.scalars().all()
return obligations
@router.post(
"/vehicles/{asset_id}/tax",
response_model=VehicleTaxObligationResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_vehicle_tax_obligation(
asset_id: uuid.UUID,
payload: VehicleTaxObligationCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Új adókötelezettség létrehozása a járműhöz.
POST /api/v1/assets/vehicles/{asset_id}/tax
"""
asset = await _check_asset_access(db, asset_id, current_user)
obligation = VehicleTaxObligation(
asset_id=asset_id,
tax_type=payload.tax_type,
tax_year=payload.tax_year,
amount=payload.amount,
currency=payload.currency,
due_date=payload.due_date,
payment_status=payload.payment_status,
)
db.add(obligation)
await db.commit()
await db.refresh(obligation)
return obligation
# =============================================================================
# VehicleInsurancePolicy PUT / DELETE
# =============================================================================
@router.put(
"/vehicles/{asset_id}/insurance/{policy_id}",
response_model=VehicleInsurancePolicyResponse,
)
async def update_vehicle_insurance_policy(
asset_id: uuid.UUID,
policy_id: uuid.UUID,
payload: VehicleInsurancePolicyUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Biztosítási kötvény módosítása.
PUT /api/v1/assets/vehicles/{asset_id}/insurance/{policy_id}
Csak a megadott mezőket frissíti (partial update).
"""
await _check_asset_access(db, asset_id, current_user)
stmt = select(VehicleInsurancePolicy).where(
VehicleInsurancePolicy.id == policy_id,
VehicleInsurancePolicy.asset_id == asset_id,
)
result = await db.execute(stmt)
policy = result.scalar_one_or_none()
if not policy:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosítási kötvény nem található",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(policy, field, value)
await db.commit()
await db.refresh(policy)
return policy
@router.delete(
"/vehicles/{asset_id}/insurance/{policy_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_vehicle_insurance_policy(
asset_id: uuid.UUID,
policy_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Biztosítási kötvény törlése.
DELETE /api/v1/assets/vehicles/{asset_id}/insurance/{policy_id}
"""
await _check_asset_access(db, asset_id, current_user)
stmt = select(VehicleInsurancePolicy).where(
VehicleInsurancePolicy.id == policy_id,
VehicleInsurancePolicy.asset_id == asset_id,
)
result = await db.execute(stmt)
policy = result.scalar_one_or_none()
if not policy:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosítási kötvény nem található",
)
await db.delete(policy)
await db.commit()
# =============================================================================
# VehicleTaxObligation PUT / DELETE
# =============================================================================
@router.put(
"/vehicles/{asset_id}/tax/{tax_id}",
response_model=VehicleTaxObligationResponse,
)
async def update_vehicle_tax_obligation(
asset_id: uuid.UUID,
tax_id: uuid.UUID,
payload: VehicleTaxObligationUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Adókötelezettség módosítása.
PUT /api/v1/assets/vehicles/{asset_id}/tax/{tax_id}
Csak a megadott mezőket frissíti (partial update).
"""
await _check_asset_access(db, asset_id, current_user)
stmt = select(VehicleTaxObligation).where(
VehicleTaxObligation.id == tax_id,
VehicleTaxObligation.asset_id == asset_id,
)
result = await db.execute(stmt)
obligation = result.scalar_one_or_none()
if not obligation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Adókötelezettség nem található",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(obligation, field, value)
await db.commit()
await db.refresh(obligation)
return obligation
@router.delete(
"/vehicles/{asset_id}/tax/{tax_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_vehicle_tax_obligation(
asset_id: uuid.UUID,
tax_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Adókötelezettség törlése.
DELETE /api/v1/assets/vehicles/{asset_id}/tax/{tax_id}
"""
await _check_asset_access(db, asset_id, current_user)
stmt = select(VehicleTaxObligation).where(
VehicleTaxObligation.id == tax_id,
VehicleTaxObligation.asset_id == asset_id,
)
result = await db.execute(stmt)
obligation = result.scalar_one_or_none()
if not obligation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Adókötelezettség nem található",
)
await db.delete(obligation)
await db.commit()

View File

@@ -2,15 +2,21 @@
""" """
Szótárak és katalógusok végpontjai. Szótárak és katalógusok végpontjai.
- GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve - GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve
- CRUD /dictionaries/insurance-providers: Biztosítók katalógusa
""" """
import logging import logging
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, desc
from app.db.session import get_db from app.db.session import get_db
from app.api import deps from app.api import deps
from app.models.vehicle import CostCategory from app.models.fleet_finance import CostCategory, InsuranceProvider
from app.schemas.fleet_finance import (
InsuranceProviderResponse,
InsuranceProviderCreate,
InsuranceProviderUpdate,
)
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -66,3 +72,172 @@ async def list_cost_categories(
root_categories.append(cat_dict) root_categories.append(cat_dict)
return root_categories return root_categories
# =============================================================================
# InsuranceProvider CRUD
# =============================================================================
@router.get(
"/insurance-providers",
response_model=List[InsuranceProviderResponse],
)
async def list_insurance_providers(
active_only: bool = Query(True, description="Csak az aktív biztosítók listázása"),
country_code: Optional[str] = Query(None, max_length=2, description="ISO 3166-1 alpha-2 országkód szerinti szűrés (pl. 'HU'). Visszaadja az adott ország és a globális (NULL) szolgáltatókat."),
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Biztosítók listázása.
Alapértelmezés szerint csak az aktív biztosítókat adja vissza (frontend dropdown számára).
Opcionálisan szűrhető country_code alapján: ha meg van adva, visszaadja az adott ország
szolgáltatóit ÉS a globális (country_code IS NULL) szolgáltatókat is.
"""
stmt = select(InsuranceProvider).order_by(InsuranceProvider.name)
if active_only:
stmt = stmt.where(InsuranceProvider.is_active == True)
if country_code:
stmt = stmt.where(
(InsuranceProvider.country_code == country_code) |
(InsuranceProvider.country_code.is_(None))
)
result = await db.execute(stmt)
providers = result.scalars().all()
return providers
@router.get(
"/insurance-providers/{provider_id}",
response_model=InsuranceProviderResponse,
)
async def get_insurance_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Egy biztosító adatainak lekérése ID alapján.
"""
stmt = select(InsuranceProvider).where(InsuranceProvider.id == provider_id)
result = await db.execute(stmt)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosító nem található",
)
return provider
@router.post(
"/insurance-providers",
response_model=InsuranceProviderResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_insurance_provider(
payload: InsuranceProviderCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Új biztosító hozzáadása a katalógushoz.
"""
# Check for duplicate name
existing_stmt = select(InsuranceProvider).where(
InsuranceProvider.name == payload.name
)
existing_result = await db.execute(existing_stmt)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Már létezik biztosító ezzel a névvel: '{payload.name}'",
)
provider = InsuranceProvider(
name=payload.name,
claim_phone=payload.claim_phone,
claim_url=payload.claim_url,
services_offered=payload.services_offered or {},
is_active=payload.is_active,
)
db.add(provider)
await db.commit()
await db.refresh(provider)
return provider
@router.put(
"/insurance-providers/{provider_id}",
response_model=InsuranceProviderResponse,
)
async def update_insurance_provider(
provider_id: int,
payload: InsuranceProviderUpdate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Biztosító adatainak módosítása.
"""
stmt = select(InsuranceProvider).where(InsuranceProvider.id == provider_id)
result = await db.execute(stmt)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosító nem található",
)
# Check for duplicate name if name is being changed
if payload.name is not None and payload.name != provider.name:
dup_stmt = select(InsuranceProvider).where(
InsuranceProvider.name == payload.name,
InsuranceProvider.id != provider_id,
)
dup_result = await db.execute(dup_stmt)
if dup_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Már létezik biztosító ezzel a névvel: '{payload.name}'",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(provider, field, value)
await db.commit()
await db.refresh(provider)
return provider
@router.delete(
"/insurance-providers/{provider_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_insurance_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Biztosító törlése a katalógusból.
"""
stmt = select(InsuranceProvider).where(InsuranceProvider.id == provider_id)
result = await db.execute(stmt)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosító nem található",
)
await db.delete(provider)
await db.commit()

View File

@@ -1,11 +1,13 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
import logging import logging
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func, desc
from app.api.deps import get_db, get_current_user, RequireOrgCapability from app.api.deps import get_db, get_current_user, RequireOrgCapability
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
from app.schemas.asset_cost import AssetCostCreate from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -110,6 +112,236 @@ def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decim
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01")) return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
@router.get("")
async def list_all_expenses(
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
asset_id: Optional[str] = Query(None, description="Filter by asset UUID"),
organization_id: Optional[int] = Query(None, description="Filter by organization ID. If provided, user must be a member."),
):
"""
List all expenses across all assets for the current user's organization,
ordered by date descending with pagination.
Supports optional asset_id filter for vehicle-specific cost views.
Supports optional organization_id filter for cross-org views (user must be a member).
Returns enriched expense data including vehicle info, category name, and vendor.
"""
# Resolve organization: use explicit organization_id if provided, otherwise fallback to user's active org
if organization_id is not None:
# Verify user is a member of the requested organization
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
membership = org_result.scalar_one_or_none()
if not membership:
raise HTTPException(
status_code=403,
detail="You are not an active member of the requested organization."
)
org_id = organization_id
else:
# Fallback: resolve user's first active organization
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
membership = org_result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=403, detail="No active organization membership found.")
org_id = membership.organization_id
# Calculate offset
offset = (page - 1) * page_size
# Build base query with joins
base_select = (
select(
AssetCost,
CostCategory.code,
CostCategory.name,
Organization.name,
Asset.license_plate,
Asset.brand,
Asset.model,
)
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
.join(Asset, AssetCost.asset_id == Asset.id)
)
# Apply filters
filters = [AssetCost.organization_id == org_id]
if asset_id:
filters.append(AssetCost.asset_id == asset_id)
expense_stmt = (
base_select
.where(*filters)
.order_by(desc(AssetCost.date))
.offset(offset)
.limit(page_size)
)
expense_result = await db.execute(expense_stmt)
rows = expense_result.all()
# Build enriched response
expenses = []
for row in rows:
cost = row[0]
cat_code = row[1]
cat_name = row[2]
vendor_name = row[3]
license_plate = row[4]
brand = row[5]
model = row[6]
data = cost.data or {}
description = data.get("description")
mileage_at_cost = data.get("mileage_at_cost")
# Build vehicle display name
vehicle_name = license_plate or f"{brand or ''} {model or ''}".strip() or "Unknown"
expenses.append({
"id": str(cost.id),
"asset_id": str(cost.asset_id),
"organization_id": cost.organization_id,
"category_id": cost.category_id,
"category_code": cat_code,
"category_name": cat_name,
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
"amount_net": str(cost.amount_net),
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
"currency": cost.currency,
"date": cost.date.isoformat() if cost.date else None,
"status": cost.status,
"description": description,
"mileage_at_cost": mileage_at_cost,
"invoice_number": cost.invoice_number,
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
"vendor_organization_id": cost.vendor_organization_id,
"external_vendor_name": cost.external_vendor_name,
"vendor_name": vendor_name,
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
# Vehicle info
"vehicle_name": vehicle_name,
"license_plate": license_plate,
})
# Count total for pagination (respect asset_id filter)
count_filters = [AssetCost.organization_id == org_id]
if asset_id:
count_filters.append(AssetCost.asset_id == asset_id)
count_stmt = (
select(func.count(AssetCost.id))
.where(*count_filters)
)
count_result = await db.execute(count_stmt)
total = count_result.scalar()
total_pages = max(1, (total + page_size - 1) // page_size)
return {
"data": expenses,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
@router.get("/{asset_id}")
async def list_asset_expenses(
asset_id: UUID,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user),
limit: int = Query(50, ge=1, le=200, description="Maximum number of expenses to return"),
offset: int = Query(0, ge=0, description="Number of expenses to skip"),
):
"""
List all expenses for a specific asset, ordered by date descending.
Returns enriched expense data including category name, code, and vendor info.
Used by the OverviewTab and CostManagerModal to display real expense data.
"""
# Validate asset exists
stmt = select(Asset).where(Asset.id == asset_id)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found.")
# Fetch expenses with category join and vendor organization join
expense_stmt = (
select(AssetCost, CostCategory.code, CostCategory.name, Organization.name)
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
.where(AssetCost.asset_id == asset_id)
.order_by(desc(AssetCost.date))
.offset(offset)
.limit(limit)
)
expense_result = await db.execute(expense_stmt)
rows = expense_result.all()
# Build enriched response
expenses = []
for row in rows:
cost = row[0] # AssetCost instance
cat_code = row[1] # CostCategory.code
cat_name = row[2] # CostCategory.name
vendor_name = row[3] # Organization.name (resolved from vendor_organization_id)
# Extract description and mileage from data JSONB
data = cost.data or {}
description = data.get("description")
mileage_at_cost = data.get("mileage_at_cost")
expenses.append({
"id": str(cost.id),
"asset_id": str(cost.asset_id),
"organization_id": cost.organization_id,
"category_id": cost.category_id,
"category_code": cat_code,
"category_name": cat_name,
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
"amount_net": str(cost.amount_net),
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
"currency": cost.currency,
"date": cost.date.isoformat() if cost.date else None,
"status": cost.status,
"description": description,
"mileage_at_cost": mileage_at_cost,
"invoice_number": cost.invoice_number,
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
# === B2B VENDOR FIELDS ===
"vendor_organization_id": cost.vendor_organization_id,
"external_vendor_name": cost.external_vendor_name,
"vendor_name": vendor_name, # Enriched from fleet.organizations.name
# === INVOICE DATES ===
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
})
# Count total for pagination
count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset_id)
count_result = await db.execute(count_stmt)
total = count_result.scalar()
return {
"data": expenses,
"total": total,
"limit": limit,
"offset": offset,
}
@router.post("/", status_code=201) @router.post("/", status_code=201)
async def create_expense( async def create_expense(
expense: AssetCostCreate, expense: AssetCostCreate,
@@ -247,10 +479,16 @@ async def create_expense(
amount_gross=amount_gross, amount_gross=amount_gross,
vat_rate=vat_rate, vat_rate=vat_rate,
currency=expense.currency, currency=expense.currency,
date=expense.date, date=expense.cost_date,
invoice_number=data.get("invoice_number"), invoice_number=data.get("invoice_number"),
status=expense_status, status=expense_status,
data=data data=data,
# === B2B VENDOR FIELDS ===
vendor_organization_id=expense.vendor_organization_id,
external_vendor_name=expense.external_vendor_name,
# === INVOICE DATES ===
invoice_date=expense.invoice_date,
fulfillment_date=expense.fulfillment_date,
) )
db.add(new_cost) db.add(new_cost)
@@ -279,7 +517,7 @@ async def create_expense(
cost_id=new_cost.id, cost_id=new_cost.id,
linked_expense_id=new_cost.id, # Bidirectional link linked_expense_id=new_cost.id, # Bidirectional link
status=event_status, status=event_status,
event_date=expense.date or datetime.now(timezone.utc), event_date=expense.cost_date or datetime.now(timezone.utc),
) )
db.add(new_event) db.add(new_event)
await db.flush() # Flush to get new_event.id await db.flush() # Flush to get new_event.id

View File

@@ -184,7 +184,11 @@ async def get_my_organizations(
): ):
""" """
A bejelentkezett felhasználóhoz tartozó szervezetek listázása. A bejelentkezett felhasználóhoz tartozó szervezetek listázása.
P0: Minden szervezethez visszaadja a valós max_vehicles és max_garages limiteket
a subscription_tier JSONB rules alapján.
""" """
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
subq = ( subq = (
select(Organization.id) select(Organization.id)
.outerjoin(OrganizationMember, OrganizationMember.organization_id == Organization.id) .outerjoin(OrganizationMember, OrganizationMember.organization_id == Organization.id)
@@ -206,6 +210,31 @@ async def get_my_organizations(
) )
result = await db.execute(stmt) result = await db.execute(stmt)
orgs = result.scalars().all() orgs = result.scalars().all()
# ── P0: Pre-fetch all org subscription tiers for limit resolution ──
org_ids = [o.id for o in orgs]
org_tier_map: dict[int, tuple[int, int]] = {}
if org_ids:
org_subs_stmt = (
select(OrganizationSubscription.org_id, SubscriptionTier)
.join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id)
.where(
OrganizationSubscription.org_id.in_(org_ids),
OrganizationSubscription.is_active == True
)
.distinct(OrganizationSubscription.org_id)
.order_by(OrganizationSubscription.org_id, OrganizationSubscription.valid_from.desc())
)
org_subs_result = await db.execute(org_subs_stmt)
for row in org_subs_result:
o_id = row.org_id
tier = row[1]
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
org_tier_map[o_id] = (
int(allowances.get("max_vehicles", 1)),
int(allowances.get("max_garages", 1)),
)
return [ return [
{ {
@@ -220,6 +249,11 @@ async def get_my_organizations(
"is_active": o.is_active, "is_active": o.is_active,
"is_deleted": o.is_deleted, "is_deleted": o.is_deleted,
"subscription_plan": o.subscription_plan, "subscription_plan": o.subscription_plan,
"subscription_expires_at": o.subscription_expires_at.isoformat() if o.subscription_expires_at else None,
"subscription_tier_id": o.subscription_tier_id,
# P0: Real limits from subscription tier
"max_vehicles": org_tier_map.get(o.id, (o.base_asset_limit or 1, 1))[0],
"max_garages": org_tier_map.get(o.id, (1, 1))[1],
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type), "org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None, "visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
"user_role": _get_user_role(o, current_user.id), "user_role": _get_user_role(o, current_user.id),

View File

@@ -1,56 +1,97 @@
from fastapi import APIRouter, Depends # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/reports.py
"""
Reports endpoints — Zombie API Cleanup.
Replaced raw SQL (FROM vehicle.vehicle_expenses) with SQLAlchemy ORM queries
using fleet_finance.asset_costs (AssetCost) + CostCategory.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from app.api.deps import get_db, get_current_user from app.api.deps import get_db, get_current_user
from app.models.fleet_finance import AssetCost, CostCategory
from app.models.identity import User
router = APIRouter()
router = APIRouter() # EZ HIÁNYZOTT!
@router.get("/summary/{vehicle_id}") @router.get("/summary/{vehicle_id}")
async def get_vehicle_summary(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)): async def get_vehicle_summary(
vehicle_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
""" """
Összesített jelentés egy járműhöz: kategóriánkénti költségek. Összesített jelentés egy járműhöz: kategóriánkénti költségek.
Uses fleet_finance.asset_costs (AssetCost) joined with CostCategory.
""" """
query = text(""" # Aggregate costs by category using ORM
SELECT stmt = (
category, select(
SUM(amount) as total_amount, CostCategory.name.label("category"),
COUNT(*) as transaction_count func.sum(AssetCost.amount_gross).label("total_amount"),
FROM vehicle.vehicle_expenses func.count(AssetCost.id).label("transaction_count"),
WHERE vehicle_id = :v_id )
GROUP BY category .join(CostCategory, AssetCost.category_id == CostCategory.id)
""") .where(AssetCost.asset_id == vehicle_id)
.group_by(CostCategory.name)
result = await db.execute(query, {"v_id": vehicle_id}) )
rows = result.fetchall() result = await db.execute(stmt)
rows = result.all()
total_cost = sum(row.total_amount for row in rows) if rows else 0
total_cost = sum(float(row.total_amount) for row in rows) if rows else 0.0
return { return {
"vehicle_id": vehicle_id, "vehicle_id": vehicle_id,
"total_cost": float(total_cost), "total_cost": total_cost,
"breakdown": [dict(row._mapping) for row in rows] "breakdown": [
{
"category": row.category,
"total_amount": float(row.total_amount),
"transaction_count": row.transaction_count,
}
for row in rows
],
} }
@router.get("/trends/{vehicle_id}") @router.get("/trends/{vehicle_id}")
async def get_monthly_trends(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)): async def get_monthly_trends(
vehicle_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
""" """
Visszaadja az utolsó 6 hónap költéseit havi bontásban. Visszaadja az utolsó 6 hónap költéseit havi bontásban.
Uses fleet_finance.asset_costs (AssetCost) with date truncation.
""" """
query = text(""" # Monthly aggregation using ORM-compatible date truncation
SELECT from sqlalchemy import text as sql_text
TO_CHAR(date, 'YYYY-MM') as month,
SUM(amount) as monthly_total stmt = (
FROM vehicle.vehicle_expenses select(
WHERE vehicle_id = :v_id func.to_char(AssetCost.date, "YYYY-MM").label("month"),
GROUP BY month func.sum(AssetCost.amount_gross).label("monthly_total"),
ORDER BY month DESC )
LIMIT 6 .where(AssetCost.asset_id == vehicle_id)
""") .group_by(sql_text("month"))
result = await db.execute(query, {"v_id": vehicle_id}) .order_by(desc(sql_text("month")))
return [dict(row._mapping) for row in result.fetchall()] .limit(6)
)
result = await db.execute(stmt)
rows = result.all()
return [
{"month": row.month, "monthly_total": float(row.monthly_total)}
for row in rows
]
@router.get("/summary/latest") @router.get("/summary/latest")
async def get_latest_summary(db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)): async def get_latest_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
""" """
Returns a simple summary for the dashboard (mock data for now). Returns a simple summary for the dashboard (mock data for now).
This endpoint is called by the frontend dashboard. This endpoint is called by the frontend dashboard.
@@ -61,5 +102,5 @@ async def get_latest_summary(db: AsyncSession = Depends(get_db), current_user =
"total_cost_this_month": 1250.50, "total_cost_this_month": 1250.50,
"most_expensive_category": "Fuel", "most_expensive_category": "Fuel",
"trend": "down", "trend": "down",
"trend_percentage": -5.2 "trend_percentage": -5.2,
} }

View File

@@ -46,6 +46,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
Segédfüggvény a UserResponse dict előállításához. Segédfüggvény a UserResponse dict előállításához.
Beágyazza a Person és Address adatokat a 'person' mezőbe. Beágyazza a Person és Address adatokat a 'person' mezőbe.
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba. RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba.
P0: max_vehicles és max_garages a subscription_tier JSONB rules-ból.
""" """
from app.models.marketplace.organization import Organization, OrganizationMember from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole from app.models.marketplace.organization import OrgUserRole
@@ -60,8 +61,6 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
active_org_id = None active_org_id = None
if active_org_id is None: if active_org_id is None:
# 1. Check if user is a member of any organization with ADMIN/OWNER role
# (This is a helper - in real usage the caller should pass active_org_id)
pass pass
person = user.person person = user.person
@@ -110,10 +109,13 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
# ── RBAC Phase 3: Resolve org capabilities ── # ── RBAC Phase 3: Resolve org capabilities ──
org_capabilities: Dict[str, Dict[str, bool]] = {} org_capabilities: Dict[str, Dict[str, bool]] = {}
if db is not None: if db is not None:
# We need to run this synchronously since _build_user_response is not async
# The async version is handled in read_users_me directly
pass pass
# ── P0: Resolve subscription limits ──
# Default fallback values
max_vehicles = 1
max_garages = 1
return { return {
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,
@@ -124,6 +126,8 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
"person_id": user.person_id, "person_id": user.person_id,
"role": role_key, "role": role_key,
"subscription_plan": user.subscription_plan, "subscription_plan": user.subscription_plan,
"max_vehicles": max_vehicles,
"max_garages": max_garages,
"scope_level": user.scope_level or "individual", "scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None, "scope_id": str(active_org_id) if active_org_id else None,
"ui_mode": user.ui_mode or "personal", "ui_mode": user.ui_mode or "personal",
@@ -198,9 +202,60 @@ async def read_users_me(
# Check if user is the last admin in any organization # Check if user is the last admin in any organization
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id) is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
# ── P0: Resolve real subscription limits from the assigned tier ──
from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription
max_vehicles = 1
max_garages = 1
if active_org_id is not None:
# Try org-level subscription first
org_sub_stmt = (
select(SubscriptionTier)
.select_from(OrganizationSubscription)
.join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True
)
.order_by(OrganizationSubscription.valid_from.desc())
.limit(1)
)
tier = (await db.execute(org_sub_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
else:
# Fallback: read base_asset_limit from Organization record
org_stmt = select(Organization).where(Organization.id == active_org_id)
org = (await db.execute(org_stmt)).scalar_one_or_none()
if org:
max_vehicles = org.base_asset_limit or 1
max_garages = 1 # No base_garage_limit column, default to 1
else:
# Personal mode: try user-level subscription
user_sub_stmt = (
select(SubscriptionTier)
.select_from(UserSubscription)
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
.where(
UserSubscription.user_id == current_user.id,
UserSubscription.is_active == True
)
.order_by(UserSubscription.valid_from.desc())
.limit(1)
)
tier = (await db.execute(user_sub_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
# ── RBAC Phase 3: Build base response ── # ── RBAC Phase 3: Build base response ──
response_data = _build_user_response(current_user, active_org_id) response_data = _build_user_response(current_user, active_org_id)
response_data["is_last_admin"] = is_last_admin response_data["is_last_admin"] = is_last_admin
response_data["max_vehicles"] = max_vehicles
response_data["max_garages"] = max_garages
# ── RBAC Phase 3: Resolve org_capabilities ── # ── RBAC Phase 3: Resolve org_capabilities ──
# Get all organizations the user is a member of # Get all organizations the user is a member of

View File

@@ -214,24 +214,16 @@ MOTORCYCLE_FEATURES: dict[str, dict[str, str]] = {
"catalyst": "Katalizátor", "catalyst": "Katalizátor",
"electric_starter": "Önindító", "electric_starter": "Önindító",
"all_wheel_drive": "Összkerékhajtás", "all_wheel_drive": "Összkerékhajtás",
"alarm": "Riasztó",
"sport_exhaust": "Sport kipufogó", "sport_exhaust": "Sport kipufogó",
"sport_air_filter": "Sport légszűrő", "sport_air_filter": "Sport légszűrő",
"cruise_control": "Tempomat",
"turbo": "Turbó", "turbo": "Turbó",
"12v_system": "12 V rendszer", "12v_system": "12 V rendszer",
"heated_grip": "Markolat fűtés", "heated_grip": "Markolat fűtés",
"abs": "ABS (blokkolásgátló)",
"seat_belt": "Biztonsági öv", "seat_belt": "Biztonsági öv",
"dtc": "DTC", "dtc": "DTC",
"fog_light": "Ködlámpa",
"airbag": "Légzsák",
"xenon_headlight": "Xenon fényszóró",
}, },
"frame_body": { "frame_body": {
"full_extra": "Full extra",
"leather_seat": "Bőrülés", "leather_seat": "Bőrülés",
"heated_seat": "Fűthető ülés",
"backrest": "Háttámla", "backrest": "Háttámla",
"center_stand": "Középsztender", "center_stand": "Középsztender",
"footrest": "Lábtartó", "footrest": "Lábtartó",
@@ -243,7 +235,6 @@ MOTORCYCLE_FEATURES: dict[str, dict[str, str]] = {
"crash_bar": "Bukócső / bukógomba", "crash_bar": "Bukócső / bukógomba",
"hand_guards": "Kézvédők", "hand_guards": "Kézvédők",
"heated_mirror": "Fűthető tükör", "heated_mirror": "Fűthető tükör",
"tow_hitch": "Vonóhorog",
}, },
"luggage": { "luggage": {
"factory_cases": "Gyári dobozok", "factory_cases": "Gyári dobozok",
@@ -269,15 +260,12 @@ MOTORCYCLE_FEATURES: dict[str, dict[str, str]] = {
"showroom_vehicle": "Bemutató jármű", "showroom_vehicle": "Bemutató jármű",
"orderable": "Rendelhető", "orderable": "Rendelhető",
"car_trade_in_possible": "Autóbeszámítás lehetséges", "car_trade_in_possible": "Autóbeszámítás lehetséges",
"first_owner": "Első tulajdonostól",
"garage_kept": "Garázsban tartott",
"female_owner": "Hölgy tulajdonostól", "female_owner": "Hölgy tulajdonostól",
"low_mileage": "Keveset futott", "low_mileage": "Keveset futott",
"second_owner": "Második tulajdonostól", "second_owner": "Második tulajdonostól",
"motorcycle_trade_in_possible": "Motorbeszámítás lehetséges", "motorcycle_trade_in_possible": "Motorbeszámítás lehetséges",
"track_fairing": "Pályaidom", "track_fairing": "Pályaidom",
"regularly_maintained": "Rendszeresen karbantartott", "regularly_maintained": "Rendszeresen karbantartott",
"service_book": "Szervizkönyv",
"title_certificate": "Törzskönyv", "title_certificate": "Törzskönyv",
}, },
} }
@@ -309,53 +297,22 @@ LCV_BODY_TYPES: dict[str, str] = {
LCV_FEATURES: dict[str, dict[str, str]] = { LCV_FEATURES: dict[str, dict[str, str]] = {
"technical": { "technical": {
"abs": "ABS",
"asr": "ASR (Kipörgésgátló)", "asr": "ASR (Kipörgésgátló)",
"gps_tracker": "GPS nyomkövető",
"immobiliser": "Immobiliser", "immobiliser": "Immobiliser",
"alarm": "Riasztó",
"cruise_control": "Tempomat",
"parking_sensors_rear": "Hátsó parkolóérzékelők",
}, },
"interior": { "interior": {
"curtain_airbag": "Függönylégzsák",
"rear_side_airbag": "Hátsó oldallégzsák",
"switchable_airbag": "Kikapcsolható utaslégzsák",
"side_airbag": "Oldallégzsák",
"passenger_airbag": "Utaslégzsák",
"driver_airbag": "Vezető légzsák",
"roll_bar": "Bukókeret", "roll_bar": "Bukókeret",
"cargo_tie_down": "Rögzítő pontok a raktérben", "cargo_tie_down": "Rögzítő pontok a raktérben",
"isofix": "Isofix gyerekülés rögzítés",
"full_extra": "Full extra",
"auxiliary_heating": "Kiegészítő fűtés (Webasto)", "auxiliary_heating": "Kiegészítő fűtés (Webasto)",
"leather_interior": "Bőr belső", "leather_interior": "Bőr belső",
"heated_seat": "Fűthető ülés",
"partition_wall": "Válaszfal", "partition_wall": "Válaszfal",
"seat_height_adjustment": "Ülésmagasság állítás", "seat_height_adjustment": "Ülésmagasság állítás",
"adjustable_steering_wheel": "Állítható kormány", "adjustable_steering_wheel": "Állítható kormány",
"central_locking": "Központi zár",
"trip_computer": "Fedélzeti számítógép",
"power_steering": "Szervokormány",
}, },
"exterior": { "exterior": {
"power_windows": "Elektromos ablakemelő",
"power_mirrors": "Elektromos tükör állítás", "power_mirrors": "Elektromos tükör állítás",
"heated_mirrors": "Fűthető tükör",
"alloy_wheels": "Könnyűfém felni",
"tinted_glass": "Sötétített üvegek",
"tow_bar": "Vontatóhorog",
"sunroof": "Napfénytető",
"fog_lights": "Ködlámpa",
"xenon_headlights": "Xenon fényszóró",
},
"multimedia": {
"cd_changer": "CD váltó",
"cd_radio": "CD rádió",
"gps": "GPS navigáció",
"hifi": "Hi-Fi hangrendszer",
"radio_cassette": "Rádiós kazettás magnó",
}, },
"multimedia": {},
} }
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
@@ -529,20 +486,16 @@ HGV_FEATURES: dict[str, dict[str, str]] = {
"engine_brake": "Motorfék (Engine Brake)", "engine_brake": "Motorfék (Engine Brake)",
"exhaust_brake": "Kipufogófék (Exhaust Brake)", "exhaust_brake": "Kipufogófék (Exhaust Brake)",
"auxiliary_brake": "Segédfék", "auxiliary_brake": "Segédfék",
"abs": "ABS (Blokkolásgátló)",
"ebs": "EBS (Elektronikus fékrendszer)", "ebs": "EBS (Elektronikus fékrendszer)",
"esp": "ESP (Menetstabilizáló)", "esp": "ESP (Menetstabilizáló)",
"traction_control": "Kipörgésgátló (ASR/TCS)",
"roll_stability": "Borulásgátló (RSP)", "roll_stability": "Borulásgátló (RSP)",
"stability_control": "Stabilitás szabályzó (ESP)", "stability_control": "Stabilitás szabályzó (ESP)",
"hill_holder": "Hegymeneti tartó (Hill Holder)", "hill_holder": "Hegymeneti tartó (Hill Holder)",
"hill_descent": "Lejtmenetvezérlő (Hill Descent)", "hill_descent": "Lejtmenetvezérlő (Hill Descent)",
"hill_descent_control": "Lejtmenetvezérlő",
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
# 2. Vezetéstámogató rendszerek (ADAS) # 2. Vezetéstámogató rendszerek (ADAS)
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
"adaptive_cruise": "Adaptív tempomat (ACC)",
"lane_departure": "Sávelhagyás figyelő (LDWS)", "lane_departure": "Sávelhagyás figyelő (LDWS)",
"lane_assist": "Sávtartó asszisztens", "lane_assist": "Sávtartó asszisztens",
"blind_spot": "Holtpont-figyelő (Blind Spot)", "blind_spot": "Holtpont-figyelő (Blind Spot)",
@@ -552,7 +505,6 @@ HGV_FEATURES: dict[str, dict[str, str]] = {
"emergency_brake_assist": "Vészfék asszisztens", "emergency_brake_assist": "Vészfék asszisztens",
"traffic_sign": "Táblafelismerő rendszer", "traffic_sign": "Táblafelismerő rendszer",
"traffic_sign_recognition": "Táblafelismerő", "traffic_sign_recognition": "Táblafelismerő",
"driver_alert": "Fáradtságfigyelő (Driver Alert)",
"crosswind_assist": "Keresztszél asszisztens", "crosswind_assist": "Keresztszél asszisztens",
"tyre_pressure_monitor": "Guminyomás monitor (TPMS)", "tyre_pressure_monitor": "Guminyomás monitor (TPMS)",
"tire_pressure": "Guminyomás-figyelő (TPMS)", "tire_pressure": "Guminyomás-figyelő (TPMS)",
@@ -563,7 +515,6 @@ HGV_FEATURES: dict[str, dict[str, str]] = {
"tachograph": "Menetíró (Tachograph)", "tachograph": "Menetíró (Tachograph)",
"digital_tachograph": "Digitális menetíró", "digital_tachograph": "Digitális menetíró",
"smart_tachograph": "Smart Tachográf (G2V2)", "smart_tachograph": "Smart Tachográf (G2V2)",
"gps_tracker": "GPS nyomkövető",
"fleet_management": "Flottamenedzsment rendszer", "fleet_management": "Flottamenedzsment rendszer",
"fuel_monitoring": "Üzemanyag monitorozás", "fuel_monitoring": "Üzemanyag monitorozás",
"fuel_card_reader": "Üzemanyagkártya-olvasó", "fuel_card_reader": "Üzemanyagkártya-olvasó",
@@ -576,10 +527,8 @@ HGV_FEATURES: dict[str, dict[str, str]] = {
# 4. Látás & Kamerák # 4. Látás & Kamerák
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
"reverse_camera": "Tolatókamera", "reverse_camera": "Tolatókamera",
"rear_camera": "Hátsó kamera",
"front_camera": "Első kamera", "front_camera": "Első kamera",
"side_camera": "Oldalsó kamera", "side_camera": "Oldalsó kamera",
"360_camera": "360°-os kamera",
"parking_sensors": "Parkolóérzékelők", "parking_sensors": "Parkolóérzékelők",
"front_parking_sensors": "Első parkolóérzékelők", "front_parking_sensors": "Első parkolóérzékelők",
"rear_parking_sensors": "Hátsó parkolóérzékelők", "rear_parking_sensors": "Hátsó parkolóérzékelők",
@@ -589,14 +538,9 @@ HGV_FEATURES: dict[str, dict[str, str]] = {
# 5. Kötelező tartozékok & Egyéb # 5. Kötelező tartozékok & Egyéb
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
"fire_extinguisher": "Tűzoltó készülék",
"first_aid_kit": "Elsősegély csomag",
"warning_triangle": "Figyelmeztető háromszög",
"wheel_chocks": "Ékek (kerékrögzítő)", "wheel_chocks": "Ékek (kerékrögzítő)",
"reflective_vest": "Reflexiós mellény",
"roof_hatch": "Tetőablak", "roof_hatch": "Tetőablak",
"roof_ladder": "Tetőlétra", "roof_ladder": "Tetőlétra",
"mudguards": "Sárvédők",
"spare_wheel": "Pótkerék", "spare_wheel": "Pótkerék",
"snow_chains": "Hólánc", "snow_chains": "Hólánc",
"toolbox": "Szerszámos láda", "toolbox": "Szerszámos láda",
@@ -653,9 +597,6 @@ HGV_FEATURES: dict[str, dict[str, str]] = {
"seat_heating": "Ülésfűtés", "seat_heating": "Ülésfűtés",
"seat_ventilation": "Ülésszellőzés", "seat_ventilation": "Ülésszellőzés",
"armrest": "Karfák", "armrest": "Karfák",
"adjustable_steering": "Állítható kormány",
"electric_windows": "Elektromos ablakok",
"central_locking": "Központi zár",
"storage_box": "Tároló doboz", "storage_box": "Tároló doboz",
"wardrobe": "Gardrób / ruhatároló", "wardrobe": "Gardrób / ruhatároló",
"bed_extension": "Ágy kihúzó panel", "bed_extension": "Ágy kihúzó panel",
@@ -667,22 +608,11 @@ HGV_FEATURES: dict[str, dict[str, str]] = {
# MULTIMEDIA (Multimédia) # MULTIMEDIA (Multimédia)
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
"multimedia": { "multimedia": {
"navigation": "Navigáció",
"apple_carplay": "Apple CarPlay",
"android_auto": "Android Auto",
"bluetooth": "Bluetooth",
"usb_charging": "USB töltő",
"wireless_charging": "Vezeték nélküli töltő",
"dab_radio": "DAB+ digitális rádió",
"cb_radio": "CB rádió", "cb_radio": "CB rádió",
"satellite_radio": "Műholdas rádió", "satellite_radio": "Műholdas rádió",
"digital_tv": "Digitális TV", "digital_tv": "Digitális TV",
"dvb_t": "DVB-T (földi digitális TV)", "dvb_t": "DVB-T (földi digitális TV)",
"dvb_s": "DVB-S (műholdas TV)", "dvb_s": "DVB-S (műholdas TV)",
"premium_sound": "Prémium hangrendszer",
"subwoofer": "Mélysugárzó",
"rear_entertainment": "Hátsó szórakoztató rendszer",
"wifi_hotspot": "WiFi hotspot",
}, },
} }

View File

@@ -15,8 +15,17 @@ from app.models import VehicleType, VehicleModelDefinition, FeatureDefinition #
from app.models import SecurityAuditLog, OperationalLog, FinancialLedger # noqa <--- KRITIKUS! from app.models import SecurityAuditLog, OperationalLog, FinancialLedger # noqa <--- KRITIKUS!
from app.models import ( # noqa from app.models import ( # noqa
Asset, AssetCatalog, AssetCost, AssetEvent, Asset, AssetCatalog, AssetEvent,
AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate AssetTelemetry, AssetReview, ExchangeRate
)
from app.models.fleet_finance import ( # noqa
AssetCost, CostCategory, AssetFinancials,
InsuranceProvider, VehicleInsurancePolicy, VehicleTaxObligation
)
from app.models.fleet import ( # noqa
OrganizationRelationship, ContactPerson
) )
from app.models import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger # noqa from app.models import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger # noqa
@@ -30,6 +39,6 @@ from app.models import Document # noqa
from app.models import Translation # noqa from app.models import Translation # noqa
from app.models.core_logic import ( # noqa from app.models.core_logic import ( # noqa
SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
) )
from app.models import PendingAction # noqa from app.models import PendingAction # noqa

View File

@@ -8,18 +8,24 @@ from .identity.identity import Person, User, Wallet, VerificationToken, SocialAc
# 2. Szervezeti felépítés (MUST be before Address/Rating due to FK references) # 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, OrgRole, Branch from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, OrgRole, Branch
# 2b. B2B kapcsolatok és kapcsolattartók (fleet schema)
from .fleet import OrganizationRelationship, ContactPerson
# 3. Földrajzi adatok és címek # 3. Földrajzi adatok és címek
from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
# 4. Jármű definíciók # 4. Jármű definíciók
from .vehicle.vehicle_definitions import VehicleModelDefinition, VehicleType, FeatureDefinition, ModelFeatureMap from .vehicle.vehicle_definitions import VehicleModelDefinition, VehicleType, FeatureDefinition, ModelFeatureMap
from .reference_data import ReferenceLookup from .reference_data import ReferenceLookup
from .vehicle.vehicle import CostCategory, VehicleCost, GbCatalogDiscovery from .vehicle.vehicle import VehicleUserRating, GbCatalogDiscovery
from .vehicle.external_reference import ExternalReferenceLibrary from .vehicle.external_reference import ExternalReferenceLibrary
from .vehicle.external_reference_queue import ExternalReferenceQueue from .vehicle.external_reference_queue import ExternalReferenceQueue
# 5. Eszközök és katalógusok # 5. Eszközök és katalógusok
from .vehicle.asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetAssignment, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership, OdometerReading from .vehicle.asset import Asset, AssetCatalog, AssetEvent, AssetAssignment, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership, OdometerReading
# 5b. Flotta pénzügyek (fleet_finance schema - áthelyezve vehicle-ból)
from .fleet_finance import AssetCost, CostCategory, AssetFinancials, InsuranceProvider, VehicleInsurancePolicy, VehicleTaxObligation
# 6. Üzleti logika és előfizetések # 6. Üzleti logika és előfizetések
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
@@ -27,7 +33,6 @@ from .marketplace.payment import PaymentIntent, PaymentIntentStatus
from .marketplace.finance import Issuer, IssuerType from .marketplace.finance import Issuer, IssuerType
# 7. Szolgáltatások és staging # 7. Szolgáltatások és staging
# JAVÍTVA: ServiceStaging és társai a staged_data-ból jönnek!
from .marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise from .marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise
from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData
from .marketplace.service_request import ServiceRequest from .marketplace.service_request import ServiceRequest
@@ -38,12 +43,10 @@ from .identity.social import ServiceProvider, Vote, Competition, UserScore, Serv
# 9. Rendszer, Gamification és egyebek # 9. Rendszer, Gamification és egyebek
from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season
# --- 2.2 ÚJDONSÁG: InternalNotification hozzáadása ---
from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging
from .system.document import Document from .system.document import Document
from .system.translation import Translation from .system.translation import Translation
# Direct import from audit module
from .system.audit import SecurityAuditLog, OperationalLog, ProcessLog, FinancialLedger, WalletType, LedgerStatus, LedgerEntryType from .system.audit import SecurityAuditLog, OperationalLog, ProcessLog, FinancialLedger, WalletType, LedgerStatus, LedgerEntryType
from .vehicle.history import AuditLog, LogSeverity from .vehicle.history import AuditLog, LogSeverity
from .identity.security import PendingAction, ActionStatus from .identity.security import PendingAction, ActionStatus
@@ -67,30 +70,30 @@ ServiceRecord = AssetEvent
__all__ = [ __all__ = [
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount", "Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole", "OrgRole", "Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole", "OrgRole",
"OrganizationRelationship", "ContactPerson",
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials", "Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading", "AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch", "Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution", "PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution",
# --- 2.2 ÚJDONSÁG KIEGÉSZÍTÉS ---
"SystemParameter", "ParameterScope", "InternalNotification", "SystemParameter", "ParameterScope", "InternalNotification",
# Social models (Social 3)
"ServiceProvider", "Vote", "Competition", "UserScore", "ServiceReview", "ModerationStatus", "SourceType", "ServiceProvider", "Vote", "Competition", "UserScore", "ServiceReview", "ModerationStatus", "SourceType",
"Document", "Translation", "PendingAction", "ActionStatus", "Document", "Translation", "PendingAction", "ActionStatus",
"SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty", "SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty",
"PaymentIntent", "PaymentIntentStatus", "PaymentIntent", "PaymentIntentStatus",
"AuditLog", "VehicleOwnership", "LogSeverity", "AuditLog", "VehicleOwnership", "LogSeverity",
"SecurityAuditLog", "OperationalLog", "ProcessLog", "SecurityAuditLog", "OperationalLog", "ProcessLog",
"FinancialLedger", "WalletType", "LedgerStatus", "LedgerEntryType", "FinancialLedger", "WalletType", "LedgerStatus", "LedgerEntryType",
"ServiceProfile", "ExpertiseTag", "ServiceExpertise", "ServiceStaging", "DiscoveryParameter", "ServiceRequest", "ServiceProfile", "ExpertiseTag", "ServiceExpertise", "ServiceStaging", "DiscoveryParameter", "ServiceRequest",
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup", "Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup",
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance", "VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
"Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "VehicleCost", "ExternalReferenceLibrary", "ExternalReferenceQueue", "Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "ExternalReferenceLibrary", "ExternalReferenceQueue",
"GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword", "GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword",
"InsuranceProvider", "VehicleInsurancePolicy", "VehicleTaxObligation",
# Marketing / Ad Engine # Marketing / Ad Engine
"Campaign", "Creative", "Placement", "CampaignCreative", "Campaign", "Creative", "Placement", "CampaignCreative",
"CampaignPlacement", "AdImpression", "AdClick", "CampaignPlacement", "AdImpression", "AdClick",
"CampaignStatus", "CreativeType", "PlacementType", "CampaignStatus", "CreativeType", "PlacementType",
] ]

View File

@@ -0,0 +1,13 @@
# /opt/docker/dev/service_finder/backend/app/models/fleet/__init__.py
"""
fleet schema: B2B kapcsolatok és kapcsolattartók.
- OrganizationRelationship: Két szervezet közötti B2B kapcsolat (vevő, beszállító, partner)
- ContactPerson: Szervezethez tartozó kapcsolattartó személy
"""
from .organization import OrganizationRelationship, ContactPerson
__all__ = [
"OrganizationRelationship",
"ContactPerson",
]

View File

@@ -0,0 +1,146 @@
# /opt/docker/dev/service_finder/backend/app/models/fleet/organization.py
"""
fleet schema: B2B kapcsolatok és kapcsolattartók.
- OrganizationRelationship: Két szervezet közötti B2B kapcsolat (vevő, beszállító, partner)
- ContactPerson: Szervezethez tartozó kapcsolattartó személy
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from sqlalchemy import (
String, Boolean, DateTime, ForeignKey, Numeric, Text, Integer, BigInteger, text
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
if TYPE_CHECKING:
from app.models.marketplace.organization import Organization
from app.models.identity.identity import Person
# =============================================================================
# OrganizationRelationship
# =============================================================================
class OrganizationRelationship(Base):
"""
Két szervezet közötti B2B kapcsolat.
Lehetővé teszi a vevő-beszállító, partneri kapcsolatok nyilvántartását
anélkül, hogy a szervezet rekordokat duplikálni kellene.
"""
__tablename__ = "org_relationships"
__table_args__ = {"schema": "fleet"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# A kapcsolat két oldala
source_org_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fleet.organizations.id"), nullable=False, index=True
)
target_org_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fleet.organizations.id"), nullable=False, index=True
)
# Kapcsolat típusa
relationship_type: Mapped[str] = mapped_column(
String(30), nullable=False, comment="CUSTOMER, SUPPLIER, PARTNER"
)
# Státusz
status: Mapped[str] = mapped_column(
String(20), default="ACTIVE", server_default=text("'ACTIVE'")
)
# Szerződéses adatok
contract_ref: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
valid_from: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Pénzügyi feltételek
credit_limit: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
payment_terms: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
# Megjegyzések
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Időbélyegek
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
)
# Kapcsolatok
source_org: Mapped["Organization"] = relationship(
"Organization", foreign_keys=[source_org_id]
)
target_org: Mapped["Organization"] = relationship(
"Organization", foreign_keys=[target_org_id]
)
def __repr__(self) -> str:
return (
f"OrganizationRelationship(id={self.id}, "
f"source={self.source_org_id}, target={self.target_org_id}, "
f"type='{self.relationship_type}')"
)
# =============================================================================
# ContactPerson
# =============================================================================
class ContactPerson(Base):
"""
Kapcsolattartó személy egy szervezethez.
A személy rekord az identity.persons táblában él,
ez a modell csak a szervezeti kapcsolatot és szerepkört tárolja.
"""
__tablename__ = "contact_persons"
__table_args__ = {"schema": "fleet"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# Melyik szervezethez tartozik
organization_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fleet.organizations.id"), nullable=False, index=True
)
# Hivatkozás a személy rekordra (identity sémában)
person_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("identity.persons.id"), nullable=False, index=True
)
# Szerepkör a szervezetben (pl. "CEO", "Fleet Manager", "Accountant")
role: Mapped[str] = mapped_column(String(50), nullable=False)
# Elsődleges kapcsolattartó?
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
# Részleg
department: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Megjegyzések
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Időbélyegek
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
)
# Kapcsolatok
organization: Mapped["Organization"] = relationship(
"Organization", foreign_keys=[organization_id]
)
person: Mapped["Person"] = relationship(
"Person", foreign_keys=[person_id]
)
def __repr__(self) -> str:
return (
f"ContactPerson(id={self.id}, org_id={self.organization_id}, "
f"person_id={self.person_id}, role='{self.role}')"
)

View File

@@ -0,0 +1,25 @@
# /opt/docker/dev/service_finder/backend/app/models/fleet_finance/__init__.py
"""
fleet_finance schema: Flotta pénzügyi adatok (költségek, finanszírozás, biztosítás, adók).
Áthelyezve a vehicle sémából: AssetCost, CostCategory, AssetFinancials.
"""
from .models import (
AssetCost,
AssetCostStatusEnum,
CostCategory,
AssetFinancials,
InsuranceProvider,
VehicleInsurancePolicy,
VehicleTaxObligation,
)
__all__ = [
"AssetCost",
"AssetCostStatusEnum",
"CostCategory",
"AssetFinancials",
"InsuranceProvider",
"VehicleInsurancePolicy",
"VehicleTaxObligation",
]

View File

@@ -0,0 +1,316 @@
# /opt/docker/dev/service_finder/backend/app/models/fleet_finance/models.py
"""
fleet_finance schema: Flotta pénzügyi adatok.
- CostCategory: Standardizált költségkategóriák hierarchiája (áthelyezve vehicle -> fleet_finance)
- AssetCost: Eszközhöz kapcsolódó tényleges költségnapló (áthelyezve vehicle -> fleet_finance)
- AssetFinancials: Beszerzés és értékcsökkenés (áthelyezve vehicle -> fleet_finance, kibővítve finanszírozási adatokkal)
- InsuranceProvider: Biztosítók katalógusa (ÚJ)
- VehicleInsurancePolicy: Jármű biztosítási kötvények (ÚJ)
- VehicleTaxObligation: Jármű adókötelezettségek (ÚJ)
"""
from __future__ import annotations
import uuid
import enum
from datetime import datetime, date
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import (
String, Boolean, DateTime, ForeignKey, Numeric, text, Text,
UniqueConstraint, Integer, Date
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.sql import func
from app.database import Base
if TYPE_CHECKING:
from app.models.vehicle.asset import Asset
from app.models.marketplace.organization import Organization
from app.models.system.document import Document
# =============================================================================
# CostCategory (áthelyezve vehicle -> fleet_finance)
# =============================================================================
class CostCategory(Base):
"""
Standardizált költségkategóriák hierarchikus fája.
Rendszerkategóriák (is_system=True) nem törölhetők, csak felhasználói kategóriák.
Áthelyezve a vehicle sémából a fleet_finance sémába.
"""
__tablename__ = "cost_categories"
__table_args__ = {"schema": "fleet_finance", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
parent_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("fleet_finance.cost_categories.id", ondelete="SET NULL"),
nullable=True,
index=True
)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'")
min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'")
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())
# Hierarchikus kapcsolatok
parent: Mapped[Optional["CostCategory"]] = relationship(
"CostCategory",
remote_side=[id],
back_populates="children",
foreign_keys=[parent_id]
)
children: Mapped[list["CostCategory"]] = relationship(
"CostCategory",
back_populates="parent",
foreign_keys=[parent_id]
)
# Kapcsolódó költségek (AssetCost)
asset_costs: Mapped[list["AssetCost"]] = relationship("AssetCost", back_populates="category")
def __repr__(self) -> str:
return f"CostCategory(id={self.id}, code='{self.code}', name='{self.name}')"
# =============================================================================
# AssetCostStatusEnum
# =============================================================================
class AssetCostStatusEnum(str, enum.Enum):
"""Költség státuszok a jóváhagyási munkafolyamathoz."""
DRAFT = "DRAFT"
PENDING_APPROVAL = "PENDING_APPROVAL"
APPROVED = "APPROVED"
# =============================================================================
# AssetCost (áthelyezve vehicle -> fleet_finance)
# =============================================================================
class AssetCost(Base):
""" II. Üzemeltetés és TCO kimutatás. Áthelyezve a vehicle sémából. """
__tablename__ = "asset_costs"
__table_args__ = {"schema": "fleet_finance"}
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)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=False)
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet_finance.cost_categories.id"), index=True, nullable=False)
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
amount_gross: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
vat_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2), nullable=True)
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)
# Státusz a jóváhagyási munkafolyamathoz
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
# Kétirányú hivatkozás az AssetEvent felé (kettős könyvelés elkerülése)
linked_asset_event_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("vehicle.asset_events.id"),
nullable=True,
index=True
)
# === BESZÁLLÍTÓI HIVATKOZÁSOK (B2B expansion) ===
# Hivatkozás a fleet.organizations táblában lévő beszállítóra
vendor_organization_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("fleet.organizations.id"), nullable=True, index=True
)
# Szabadon gépelhető beszállító név (ha nincs a rendszerben)
external_vendor_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
# === KÖNYVELÉSI DÁTUMOK ===
invoice_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
fulfillment_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
organization: Mapped["Organization"] = relationship("Organization", foreign_keys=[organization_id])
category: Mapped["CostCategory"] = relationship("CostCategory", back_populates="asset_costs")
# Kapcsolat a hivatkozott eseményhez
linked_event: Mapped[Optional["AssetEvent"]] = relationship(
"AssetEvent",
foreign_keys=[linked_asset_event_id],
post_update=True
)
# Beszállító kapcsolat
vendor_organization: Mapped[Optional["Organization"]] = relationship(
"Organization", foreign_keys=[vendor_organization_id]
)
# =============================================================================
# AssetFinancials (áthelyezve vehicle -> fleet_finance, kibővítve)
# =============================================================================
class AssetFinancials(Base):
"""
I. Beszerzés és IV. Értékcsökkenés (Amortizáció).
Áthelyezve a vehicle sémából a fleet_finance sémába.
Kibővítve finanszírozási adatokkal (lízing, hitel).
"""
__tablename__ = "asset_financials"
__table_args__ = {"schema": "fleet_finance"}
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"), unique=True)
# === BESZERZÉS ===
purchase_price_net: Mapped[float] = mapped_column(Numeric(18, 2))
purchase_price_gross: Mapped[float] = mapped_column(Numeric(18, 2))
vat_rate: Mapped[float] = mapped_column(Numeric(5, 2), default=27.00)
activation_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
verified_purchase_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
financing_type: Mapped[str] = mapped_column(String(50))
accounting_details: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
# === FINANSZÍROZÁS (ÚJ) ===
down_payment: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
monthly_installment: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
residual_value: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
contract_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
financing_provider: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
interest_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2), nullable=True)
total_contract_value: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
lease_start_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
lease_end_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
# === KAPCSOLATOK ===
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
# =============================================================================
# InsuranceProvider (ÚJ)
# =============================================================================
class InsuranceProvider(Base):
"""
Biztosítók katalógusa.
Tárolja a biztosítók elérhetőségeit és szolgáltatásait.
"""
__tablename__ = "insurance_providers"
__table_args__ = {"schema": "fleet_finance"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
country_code: Mapped[Optional[str]] = mapped_column(String(2), index=True, nullable=True, comment="ISO 3166-1 alpha-2 országkód (pl. 'HU'). Ha NULL, akkor globális.")
claim_phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
claim_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
services_offered: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true"))
# Kapcsolatok
policies: Mapped[List["VehicleInsurancePolicy"]] = relationship(
"VehicleInsurancePolicy", back_populates="provider"
)
def __repr__(self) -> str:
return f"InsuranceProvider(id={self.id}, name='{self.name}')"
# =============================================================================
# VehicleInsurancePolicy (ÚJ)
# =============================================================================
class VehicleInsurancePolicy(Base):
"""
Jármű biztosítási kötvények.
Minden kötvény egy járműhöz (asset) és egy biztosítóhoz (provider) tartozik.
"""
__tablename__ = "vehicle_insurance_policies"
__table_args__ = {"schema": "fleet_finance"}
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
)
provider_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fleet_finance.insurance_providers.id"), nullable=False, index=True
)
insurance_type: Mapped[str] = mapped_column(
String(20), nullable=False, comment="KGFB, CASCO"
)
policy_number: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
expiry_date: Mapped[date] = mapped_column(Date, nullable=False)
premium_amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="HUF")
# Dokumentum kapcsolat (PDF kötvény)
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True
)
# Kapcsolatok
asset: Mapped["Asset"] = relationship("Asset")
provider: Mapped["InsuranceProvider"] = relationship(
"InsuranceProvider", back_populates="policies"
)
document: Mapped[Optional["Document"]] = relationship("Document")
def __repr__(self) -> str:
return (
f"VehicleInsurancePolicy(id={self.id}, asset_id={self.asset_id}, "
f"type='{self.insurance_type}', policy='{self.policy_number}')"
)
# =============================================================================
# VehicleTaxObligation (ÚJ)
# =============================================================================
class VehicleTaxObligation(Base):
"""
Jármű adókötelezettségek.
Tárolja a különböző típusú adókat (súlyadó, cégautóadó, stb.).
"""
__tablename__ = "vehicle_tax_obligations"
__table_args__ = {"schema": "fleet_finance"}
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
)
tax_type: Mapped[str] = mapped_column(
String(30), nullable=False, comment="WEIGHT_TAX, COMPANY_CAR_TAX"
)
tax_year: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="HUF")
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
payment_status: Mapped[str] = mapped_column(
String(20), default="pending", server_default=text("'pending'")
)
# Dokumentum kapcsolat (PDF adóbevallás)
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True
)
# Kapcsolatok
asset: Mapped["Asset"] = relationship("Asset")
document: Mapped[Optional["Document"]] = relationship("Document")
def __repr__(self) -> str:
return (
f"VehicleTaxObligation(id={self.id}, asset_id={self.asset_id}, "
f"type='{self.tax_type}', year={self.tax_year})"
)

View File

@@ -139,6 +139,7 @@ class Organization(Base):
# ── 📦 ELŐFIZETÉS / SUBSCRIPTION ── # ── 📦 ELŐFIZETÉS / SUBSCRIPTION ──
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True) subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
subscription_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1")) base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0")) purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
@@ -208,8 +209,6 @@ class Organization(Base):
# Kapcsolat az örök személy rekordhoz # Kapcsolat az örök személy rekordhoz
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities") legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
# Kapcsolat a jármű költségekhez (TCO rendszer)
vehicle_costs: Mapped[List["VehicleCost"]] = relationship("VehicleCost", back_populates="organization")
class OrganizationFinancials(Base): class OrganizationFinancials(Base):
__tablename__ = "organization_financials" __tablename__ = "organization_financials"

View File

@@ -8,8 +8,6 @@ from .vehicle_definitions import (
) )
from .vehicle import ( from .vehicle import (
CostCategory,
VehicleCost,
VehicleUserRating, VehicleUserRating,
GbCatalogDiscovery, GbCatalogDiscovery,
) )
@@ -19,15 +17,17 @@ from .external_reference_queue import ExternalReferenceQueue
from .asset import ( from .asset import (
Asset, Asset,
AssetCatalog, AssetCatalog,
AssetCost,
AssetEvent, AssetEvent,
AssetFinancials, AssetAssignment,
AssetTelemetry, AssetTelemetry,
AssetReview, AssetReview,
ExchangeRate, ExchangeRate,
CatalogDiscovery, CatalogDiscovery,
VehicleOwnership, VehicleOwnership,
OdometerReading, OdometerReading,
AssetEventTypeEnum,
AssetEventStatusEnum,
VehicleTransferRequest,
) )
from .history import AuditLog, LogSeverity from .history import AuditLog, LogSeverity
@@ -40,17 +40,14 @@ __all__ = [
"VehicleType", "VehicleType",
"FeatureDefinition", "FeatureDefinition",
"ModelFeatureMap", "ModelFeatureMap",
"CostCategory",
"VehicleCost",
"VehicleUserRating", "VehicleUserRating",
"GbCatalogDiscovery", "GbCatalogDiscovery",
"ExternalReferenceLibrary", "ExternalReferenceLibrary",
"ExternalReferenceQueue", "ExternalReferenceQueue",
"Asset", "Asset",
"AssetCatalog", "AssetCatalog",
"AssetCost",
"AssetEvent", "AssetEvent",
"AssetFinancials", "AssetAssignment",
"AssetTelemetry", "AssetTelemetry",
"AssetReview", "AssetReview",
"ExchangeRate", "ExchangeRate",
@@ -58,8 +55,10 @@ __all__ = [
"VehicleOwnership", "VehicleOwnership",
"AuditLog", "AuditLog",
"LogSeverity", "LogSeverity",
# --- EXPORT LISTA KIEGÉSZÍTÉSE --- "OdometerReading",
"AssetEventTypeEnum",
"AssetEventStatusEnum",
"VehicleTransferRequest",
"MotorcycleSpecs", "MotorcycleSpecs",
"BodyTypeDictionary", "BodyTypeDictionary",
"OdometerReading", ]
]

View File

@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
import enum import enum
from datetime import datetime from datetime import datetime, date
from typing import List, Optional, TYPE_CHECKING from typing import List, Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, CheckConstraint, BigInteger, Integer, Float from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, CheckConstraint, BigInteger, Integer, Float, Date
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -129,6 +129,46 @@ class Asset(Base):
is_under_warranty: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text('false')) is_under_warranty: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text('false'))
warranty_expiry_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) warranty_expiry_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# === REGISTRATION DOCUMENTS ===
registration_certificate_number: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True, default=None,
comment="Forgalmi engedély száma"
)
registration_certificate_validity: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, default=None,
comment="Forgalmi engedély érvényességi ideje"
)
vehicle_registration_document_number: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True, default=None,
comment="Törzskönyv száma"
)
# === INTERNATIONALIZATION (ÚJ) ===
registration_country: Mapped[Optional[str]] = mapped_column(
String(2), nullable=True, index=True,
comment="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')"
)
first_domestic_registration_date: Mapped[Optional[date]] = mapped_column(
Date, nullable=True,
comment="Első belföldi forgalomba helyezés dátuma"
)
import_country: Mapped[Optional[str]] = mapped_column(
String(2), nullable=True,
comment="Importország (ISO 3166-1 alpha-2), ha importált jármű"
)
title_document_number: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True,
comment="Törzskönyv szám (title document)"
)
engine_number: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True,
comment="Motorszám"
)
number_of_previous_owners: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
comment="Előző tulajdonosok száma"
)
# === SALES MODULE === # === SALES MODULE ===
is_for_sale: Mapped[bool] = mapped_column(Boolean, default=False, index=True) is_for_sale: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
price: Mapped[Optional[float]] = mapped_column(Numeric(15, 2)) price: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
@@ -147,7 +187,9 @@ class Asset(Base):
# --- KAPCSOLATOK --- # --- KAPCSOLATOK ---
catalog: Mapped["AssetCatalog"] = relationship("AssetCatalog", back_populates="assets") catalog: Mapped["AssetCatalog"] = relationship("AssetCatalog", back_populates="assets")
financials: Mapped[Optional["AssetFinancials"]] = relationship("AssetFinancials", back_populates="asset", uselist=False) financials: Mapped[Optional["AssetFinancials"]] = relationship(
"AssetFinancials", back_populates="asset", uselist=False
)
costs: Mapped[List["AssetCost"]] = relationship("AssetCost", back_populates="asset") costs: Mapped[List["AssetCost"]] = relationship("AssetCost", back_populates="asset")
events: Mapped[List["AssetEvent"]] = relationship("AssetEvent", back_populates="asset") events: Mapped[List["AssetEvent"]] = relationship("AssetEvent", back_populates="asset")
logbook: Mapped[List["VehicleLogbook"]] = relationship("VehicleLogbook", back_populates="asset") logbook: Mapped[List["VehicleLogbook"]] = relationship("VehicleLogbook", back_populates="asset")
@@ -229,71 +271,6 @@ class Asset(Base):
return min(total_score, 100) return min(total_score, 100)
class AssetFinancials(Base):
""" I. Beszerzés és IV. Értékcsökkenés (Amortizáció). """
__tablename__ = "asset_financials"
__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"), unique=True)
purchase_price_net: Mapped[float] = mapped_column(Numeric(18, 2))
purchase_price_gross: Mapped[float] = mapped_column(Numeric(18, 2))
vat_rate: Mapped[float] = mapped_column(Numeric(5, 2), default=27.00)
activation_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
verified_purchase_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
financing_type: Mapped[str] = mapped_column(String(50))
accounting_details: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
class AssetCostStatusEnum(str, enum.Enum):
"""Költség státuszok a jóváhagyási munkafolyamathoz."""
DRAFT = "DRAFT"
PENDING_APPROVAL = "PENDING_APPROVAL"
APPROVED = "APPROVED"
class AssetCost(Base):
""" II. Üzemeltetés és TCO kimutatás. """
__tablename__ = "asset_costs"
__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)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=False)
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False)
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
amount_gross: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
vat_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2), nullable=True)
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)
# Státusz a jóváhagyási munkafolyamathoz
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
# Kétirányú hivatkozás az AssetEvent felé (kettős könyvelés elkerülése)
linked_asset_event_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("vehicle.asset_events.id"),
nullable=True,
index=True
)
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
organization: Mapped["Organization"] = relationship("Organization")
category: Mapped["CostCategory"] = relationship("CostCategory")
# Kapcsolat a hivatkozott eseményhez
linked_event: Mapped[Optional["AssetEvent"]] = relationship(
"AssetEvent",
foreign_keys=[linked_asset_event_id],
post_update=True
)
class VehicleLogbook(Base): class VehicleLogbook(Base):
""" Útnyilvántartás (NAV, Kiküldetés, Munkábajárás). """ """ Útnyilvántartás (NAV, Kiküldetés, Munkábajárás). """
@@ -391,11 +368,14 @@ class OdometerReading(Base):
reading: Mapped[int] = mapped_column(Integer, nullable=False) # km óra állás 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) 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 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) cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("fleet_finance.asset_costs.id"), nullable=True)
# Relationships # Relationships
asset: Mapped["Asset"] = relationship("Asset", back_populates="odometer_readings") asset: Mapped["Asset"] = relationship("Asset", back_populates="odometer_readings")
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost") cost: Mapped[Optional["AssetCost"]] = relationship(
"AssetCost",
primaryjoin="OdometerReading.cost_id == AssetCost.id"
)
class AssetAssignment(Base): class AssetAssignment(Base):
@@ -443,7 +423,7 @@ class AssetEvent(Base):
event_type: Mapped[str] = mapped_column(String(50), nullable=False) # AssetEventTypeEnum értékek event_type: Mapped[str] = mapped_column(String(50), nullable=False) # AssetEventTypeEnum értékek
odometer_reading: Mapped[Optional[int]] = mapped_column(Integer) # Km óra állás az eseménykor odometer_reading: Mapped[Optional[int]] = mapped_column(Integer) # Km óra állás az eseménykor
description: Mapped[Optional[str]] = mapped_column(Text) description: Mapped[Optional[str]] = mapped_column(Text)
cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True) cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("fleet_finance.asset_costs.id"), nullable=True)
# Státusz a feldolgozási munkafolyamathoz # Státusz a feldolgozási munkafolyamathoz
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True) status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
@@ -451,7 +431,7 @@ class AssetEvent(Base):
# Kétirányú hivatkozás az AssetCost felé (kettős könyvelés elkerülése) # Kétirányú hivatkozás az AssetCost felé (kettős könyvelés elkerülése)
linked_expense_id: Mapped[Optional[uuid.UUID]] = mapped_column( linked_expense_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True), PG_UUID(as_uuid=True),
ForeignKey("vehicle.asset_costs.id"), ForeignKey("fleet_finance.asset_costs.id"),
nullable=True, nullable=True,
index=True index=True
) )
@@ -511,23 +491,6 @@ class CatalogDiscovery(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class VehicleExpenses(Base):
""" Jármű költségek a jelentésekhez. """
__tablename__ = "vehicle_expenses"
__table_args__ = {"schema": "vehicle"}
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
vehicle_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False, index=True)
category: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
description: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Relationship
asset: Mapped["Asset"] = relationship("Asset")
class VehicleTransferRequest(Base): class VehicleTransferRequest(Base):
"""Járműátadási kérelem - asset átruházás másik tulajdonosnak vagy szervezetnek.""" """Járműátadási kérelem - asset átruházás másik tulajdonosnak vagy szervezetnek."""

View File

@@ -1,8 +1,8 @@
# /opt/docker/dev/service_finder/backend/app/models/vehicle/vehicle.py # /opt/docker/dev/service_finder/backend/app/models/vehicle/vehicle.py
""" """
TCO (Total Cost of Ownership) alapmodelljei a 'vehicle' sémában. TCO (Total Cost of Ownership) alapmodelljei a 'vehicle' sémában.
- CostCategory: Standardizált költségkategóriák hierarchiája - CostCategory és VehicleCost áthelyezve a fleet_finance sémába.
- VehicleCost: Járműhöz kapcsolódó tényleges költségnapló - Itt marad: VehicleUserRating, GbCatalogDiscovery
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,99 +16,6 @@ from sqlalchemy.sql import func
from app.database import Base from app.database import Base
class CostCategory(Base):
"""
Standardizált költségkategóriák hierarchikus fája.
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", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
parent_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("vehicle.cost_categories.id", ondelete="SET NULL"),
nullable=True,
index=True
)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'")
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())
# Hierarchikus kapcsolatok
parent: Mapped[Optional["CostCategory"]] = relationship(
"CostCategory",
remote_side=[id],
back_populates="children",
foreign_keys=[parent_id]
)
children: Mapped[list["CostCategory"]] = relationship(
"CostCategory",
back_populates="parent",
foreign_keys=[parent_id]
)
# Kapcsolódó költségek
costs: Mapped[list["VehicleCost"]] = relationship("VehicleCost", back_populates="category")
def __repr__(self) -> str:
return f"CostCategory(id={self.id}, code='{self.code}', name='{self.name}')"
class VehicleCost(Base):
"""
Járműhöz kapcsolódó tényleges költségnapló.
Minden költséghez kötelező az odometer állás (km) és a dátum.
Az organization_id az Univerzális Flotta hivatkozás (fleet.organizations).
"""
__tablename__ = "costs"
__table_args__ = (
UniqueConstraint("vehicle_id", "category_id", "date", "odometer", name="uq_cost_unique_entry"),
{"schema": "vehicle"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
vehicle_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("vehicle.vehicle_model_definitions.id", ondelete="CASCADE"),
nullable=False,
index=True
)
organization_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("fleet.organizations.id", ondelete="SET NULL"),
nullable=True,
index=True
)
category_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("vehicle.cost_categories.id", ondelete="RESTRICT"),
nullable=False,
index=True
)
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False) # Összeg
currency: Mapped[str] = mapped_column(String(3), default="HUF", server_default="'HUF'") # ISO valutakód
odometer: Mapped[int] = mapped_column(Integer, nullable=False) # Kilométeróra állás (km)
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
notes: Mapped[Optional[str]] = mapped_column(Text, 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())
# Kapcsolatok
vehicle: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="costs")
organization: Mapped[Optional["Organization"]] = relationship("Organization", back_populates="vehicle_costs")
category: Mapped["CostCategory"] = relationship("CostCategory", back_populates="costs")
def __repr__(self) -> str:
return f"VehicleCost(id={self.id}, vehicle_id={self.vehicle_id}, amount={self.amount} {self.currency})"
class VehicleUserRating(Base): class VehicleUserRating(Base):
""" """
Jármű értékelési rendszer - User -> Vehicle kapcsolat. Jármű értékelési rendszer - User -> Vehicle kapcsolat.
@@ -173,4 +80,4 @@ class GbCatalogDiscovery(Base):
make: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) make: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
model: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) model: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(20), default='pending') status: Mapped[str] = mapped_column(String(20), default='pending')
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -12,7 +12,7 @@ from app.database import Base
# Típus ellenőrzés a körkörös importok elkerülésére # Típus ellenőrzés a körkörös importok elkerülésére
if TYPE_CHECKING: if TYPE_CHECKING:
from .asset import AssetCatalog from .asset import AssetCatalog
from .vehicle import VehicleCost, VehicleUserRating from .vehicle import VehicleUserRating
class VehicleType(Base): class VehicleType(Base):
""" Jármű kategóriák (pl. Személyautó, Motorkerékpár, Teherautó, Hajó) """ """ Jármű kategóriák (pl. Személyautó, Motorkerékpár, Teherautó, Hajó) """
@@ -142,7 +142,6 @@ class VehicleModelDefinition(Base):
# JAVÍTÁS: Ez a sor hiányzott az API indításához! # JAVÍTÁS: Ez a sor hiányzott az API indításához!
ratings: Mapped[List["VehicleUserRating"]] = relationship("VehicleUserRating", back_populates="vehicle", cascade="all, delete-orphan") 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")
class ModelFeatureMap(Base): class ModelFeatureMap(Base):

View File

@@ -2,7 +2,7 @@
from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator, field_validator from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator, field_validator
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from uuid import UUID from uuid import UUID
from datetime import datetime from datetime import datetime, date
class AssetCatalogResponse(BaseModel): class AssetCatalogResponse(BaseModel):
""" A technikai katalógus (Master Data) teljes adattartalma. """ """ A technikai katalógus (Master Data) teljes adattartalma. """
@@ -86,6 +86,19 @@ class AssetResponse(BaseModel):
is_under_warranty: bool = Field(default=False, description="Érvényes jótállás") is_under_warranty: bool = Field(default=False, description="Érvényes jótállás")
warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma") warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma")
# === REGISTRATION DOCUMENTS ===
registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma")
registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje")
vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma")
# === INTERNATIONALIZATION (ÚJ) ===
registration_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')")
first_domestic_registration_date: Optional[date] = Field(None, description="Első belföldi forgalomba helyezés dátuma")
import_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Importország (ISO 3166-1 alpha-2), ha importált jármű")
title_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv szám (title document)")
engine_number: Optional[str] = Field(None, max_length=100, description="Motorszám")
number_of_previous_owners: Optional[int] = Field(None, ge=0, description="Előző tulajdonosok száma")
# === SALES MODULE === # === SALES MODULE ===
is_for_sale: bool = Field(default=False) is_for_sale: bool = Field(default=False)
price: Optional[float] = None price: Optional[float] = None
@@ -228,12 +241,53 @@ class AssetCreate(BaseModel):
is_under_warranty: bool = Field(default=False, description="Érvényes jótállás") is_under_warranty: bool = Field(default=False, description="Érvényes jótállás")
warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma") warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma")
# === REGISTRATION DOCUMENTS ===
registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma")
registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje")
vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma")
# === INTERNATIONALIZATION (ÚJ) ===
registration_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')")
first_domestic_registration_date: Optional[date] = Field(None, description="Első belföldi forgalomba helyezés dátuma")
import_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Importország (ISO 3166-1 alpha-2), ha importált jármű")
title_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv szám (title document)")
engine_number: Optional[str] = Field(None, max_length=100, description="Motorszám")
number_of_previous_owners: Optional[int] = Field(None, ge=0, description="Előző tulajdonosok száma")
# === PRIMARY VEHICLE FLAG === # === PRIMARY VEHICLE FLAG ===
is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)") is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)")
# === DATA STATUS (for transfer/duplicate scenarios) === # === DATA STATUS (for transfer/duplicate scenarios) ===
data_status: Optional[str] = Field(None, description="Adat státusz (pl. 'draft' tulajdonosváltáskor)") data_status: Optional[str] = Field(None, description="Adat státusz (pl. 'draft' tulajdonosváltáskor)")
# ── Internationalization validators ──
@field_validator('registration_country', 'import_country')
@classmethod
def validate_iso_country(cls, v: Optional[str]) -> Optional[str]:
"""registration_country és import_country: pontosan 2 karakteres nagybetűs string (ISO 3166-1 alpha-2)."""
if v is None or not isinstance(v, str):
return v
cleaned = v.strip().upper()
if len(cleaned) != 2:
raise ValueError(f'Az országkódnak pontosan 2 karakter hosszúnak kell lennie (ISO 3166-1 alpha-2), kapott: "{v}"')
return cleaned
@field_validator('number_of_previous_owners')
@classmethod
def validate_previous_owners(cls, v: Optional[int]) -> Optional[int]:
"""number_of_previous_owners >= 0."""
if v is not None and v < 0:
raise ValueError('Az előző tulajdonosok száma nem lehet negatív')
return v
@field_validator('first_domestic_registration_date')
@classmethod
def validate_not_future_date(cls, v: Optional[date]) -> Optional[date]:
"""first_domestic_registration_date nem lehet jövőbeli dátum."""
if v is not None and v > date.today():
raise ValueError('Az első belföldi forgalomba helyezés dátuma nem lehet jövőbeli')
return v
# === STATUS VALIDATION === # === STATUS VALIDATION ===
@validator('status', pre=True, always=True) @validator('status', pre=True, always=True)
def determine_status(cls, v, values): def determine_status(cls, v, values):
@@ -370,6 +424,47 @@ class AssetUpdate(BaseModel):
is_under_warranty: Optional[bool] = Field(None, description="Érvényes jótállás") is_under_warranty: Optional[bool] = Field(None, description="Érvényes jótállás")
warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma") warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma")
# === REGISTRATION DOCUMENTS ===
registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma")
registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje")
vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma")
# === INTERNATIONALIZATION (ÚJ) ===
registration_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')")
first_domestic_registration_date: Optional[date] = Field(None, description="Első belföldi forgalomba helyezés dátuma")
import_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Importország (ISO 3166-1 alpha-2), ha importált jármű")
title_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv szám (title document)")
engine_number: Optional[str] = Field(None, max_length=100, description="Motorszám")
number_of_previous_owners: Optional[int] = Field(None, ge=0, description="Előző tulajdonosok száma")
# ── Internationalization validators ──
@field_validator('registration_country', 'import_country')
@classmethod
def validate_iso_country(cls, v: Optional[str]) -> Optional[str]:
"""registration_country és import_country: pontosan 2 karakteres nagybetűs string (ISO 3166-1 alpha-2)."""
if v is None or not isinstance(v, str):
return v
cleaned = v.strip().upper()
if len(cleaned) != 2:
raise ValueError(f'Az országkódnak pontosan 2 karakter hosszúnak kell lennie (ISO 3166-1 alpha-2), kapott: "{v}"')
return cleaned
@field_validator('number_of_previous_owners')
@classmethod
def validate_previous_owners(cls, v: Optional[int]) -> Optional[int]:
"""number_of_previous_owners >= 0."""
if v is not None and v < 0:
raise ValueError('Az előző tulajdonosok száma nem lehet negatív')
return v
@field_validator('first_domestic_registration_date')
@classmethod
def validate_not_future_date(cls, v: Optional[date]) -> Optional[date]:
"""first_domestic_registration_date nem lehet jövőbeli dátum."""
if v is not None and v > date.today():
raise ValueError('Az első belföldi forgalomba helyezés dátuma nem lehet jövőbeli')
return v
# === STATUS === # === STATUS ===
current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás") current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás")
is_primary: Optional[bool] = Field(None, description="Elsődleges jármű") is_primary: Optional[bool] = Field(None, description="Elsődleges jármű")

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py # /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from datetime import datetime from datetime import datetime, date
from decimal import Decimal from decimal import Decimal
from uuid import UUID from uuid import UUID
@@ -34,13 +34,21 @@ class AssetCostBase(BaseModel):
return data return data
vat_rate: Optional[Decimal] = Field(None, description="ÁFA kulcs (pl. 27.00)") vat_rate: Optional[Decimal] = Field(None, description="ÁFA kulcs (pl. 27.00)")
currency: str = "HUF" currency: str = "HUF"
date: datetime = Field(default_factory=datetime.now) cost_date: datetime = Field(default_factory=lambda: datetime.now(), validation_alias="date", description="Költség rögzítésének dátuma/időpontja")
invoice_number: Optional[str] = None invoice_number: Optional[str] = None
data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták
category_id: int # FK a CostCategory táblához category_id: int # FK a CostCategory táblához
mileage_at_cost: Optional[int] = None # Stored inside data JSONB mileage_at_cost: Optional[int] = None # Stored inside data JSONB
description: Optional[str] = None # Stored inside data JSONB description: Optional[str] = None # Stored inside data JSONB
# === BESZÁLLÍTÓI ADATOK (B2B expansion) ===
vendor_organization_id: Optional[int] = Field(None, description="Beszállító szervezet ID (FK fleet.organizations)")
external_vendor_name: Optional[str] = Field(None, max_length=200, description="Szabadon gépelhető beszállító név (ha nincs a rendszerben)")
# === KÖNYVELÉSI DÁTUMOK ===
invoice_date: Optional[date] = Field(None, description="Számla kiállításának dátuma")
fulfillment_date: Optional[date] = Field(None, description="Teljesítés dátuma")
@model_validator(mode='after') @model_validator(mode='after')
def validate_gross_first(self): def validate_gross_first(self):
"""Gross-First validation: ensure amount_gross is the primary source. """Gross-First validation: ensure amount_gross is the primary source.
@@ -86,5 +94,8 @@ class AssetCostResponse(AssetCostBase):
category_code: Optional[str] = None # Resolved from CostCategory relationship category_code: Optional[str] = None # Resolved from CostCategory relationship
description: Optional[str] = None # Extracted from data['description'] description: Optional[str] = None # Extracted from data['description']
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost'] mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
# Enriched vendor name (resolved from vendor_organization_id relationship)
vendor_name: Optional[str] = None # Resolved from Organization.name via vendor_organization_id
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,242 @@
# /opt/docker/dev/service_finder/backend/app/schemas/fleet_finance.py
"""
Pydantic sémák a flotta pénzügyi modelljeihez.
- AssetFinancials: Beszerzés, finanszírozás, értékcsökkenés
- InsuranceProvider: Biztosítók katalógusa
- VehicleInsurancePolicy: Jármű biztosítási kötvények
- VehicleTaxObligation: Jármű adókötelezettségek
"""
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, Dict, Any, List
from uuid import UUID
from datetime import datetime, date
# =============================================================================
# AssetFinancials sémák
# =============================================================================
class AssetFinancialsUpdate(BaseModel):
"""AssetFinancials frissítése - minden mező opcionális."""
purchase_price_net: Optional[float] = Field(None, ge=0, description="Nettó vételár")
purchase_price_gross: Optional[float] = Field(None, ge=0, description="Bruttó vételár")
vat_rate: Optional[float] = Field(None, ge=0, le=100, description="ÁFA kulcs (%)")
activation_date: Optional[datetime] = Field(None, description="Aktiválás dátuma")
verified_purchase_date: Optional[datetime] = Field(None, description="Ellenőrzött vásárlási dátum")
financing_type: Optional[str] = Field(None, max_length=50, description="Finanszírozás típusa (cash, loan, lease)")
accounting_details: Optional[Dict[str, Any]] = Field(None, description="Számviteli részletek (JSONB)")
# Finanszírozás
down_payment: Optional[float] = Field(None, ge=0, description="Önerő")
monthly_installment: Optional[float] = Field(None, ge=0, description="Havi törlesztő")
residual_value: Optional[float] = Field(None, ge=0, description="Maradványérték")
contract_number: Optional[str] = Field(None, max_length=100, description="Szerződésszám")
financing_provider: Optional[str] = Field(None, max_length=200, description="Finanszírozó")
interest_rate: Optional[float] = Field(None, ge=0, le=100, description="Kamatláb (%)")
total_contract_value: Optional[float] = Field(None, ge=0, description="Teljes szerződéses érték")
lease_start_date: Optional[date] = Field(None, description="Lízing kezdete")
lease_end_date: Optional[date] = Field(None, description="Lízing vége")
model_config = ConfigDict(from_attributes=True)
class AssetFinancialsResponse(BaseModel):
"""AssetFinancials teljes válaszmodell."""
id: int
asset_id: UUID
# Beszerzés
purchase_price_net: float
purchase_price_gross: float
vat_rate: float
activation_date: Optional[datetime] = None
verified_purchase_date: Optional[datetime] = None
financing_type: str
accounting_details: Dict[str, Any] = Field(default_factory=dict)
# Finanszírozás
down_payment: Optional[float] = None
monthly_installment: Optional[float] = None
residual_value: Optional[float] = None
contract_number: Optional[str] = None
financing_provider: Optional[str] = None
interest_rate: Optional[float] = None
total_contract_value: Optional[float] = None
lease_start_date: Optional[date] = None
lease_end_date: Optional[date] = None
model_config = ConfigDict(from_attributes=True)
# =============================================================================
# InsuranceProvider sémák
# =============================================================================
class InsuranceProviderResponse(BaseModel):
"""Biztosító teljes válaszmodell."""
id: int
name: str
country_code: Optional[str] = Field(None, max_length=2, description="ISO 3166-1 alpha-2 országkód (pl. 'HU'). Ha None, akkor globális.")
claim_phone: Optional[str] = None
claim_url: Optional[str] = None
services_offered: Dict[str, Any] = Field(default_factory=dict)
is_active: bool = True
model_config = ConfigDict(from_attributes=True)
class InsuranceProviderCreate(BaseModel):
"""Új biztosító létrehozása."""
name: str = Field(..., min_length=1, max_length=150, description="Biztosító neve")
country_code: Optional[str] = Field(None, max_length=2, description="ISO 3166-1 alpha-2 országkód (pl. 'HU'). Ha None, akkor globális.")
claim_phone: Optional[str] = Field(None, max_length=50, description="Kárbejelentő telefonszám")
claim_url: Optional[str] = Field(None, max_length=255, description="Kárbejelentő URL")
services_offered: Optional[Dict[str, Any]] = Field(None, description="Kínált szolgáltatások (JSONB)")
is_active: bool = Field(default=True, description="Aktív-e")
model_config = ConfigDict(from_attributes=True)
class InsuranceProviderUpdate(BaseModel):
"""Biztosító frissítése - minden mező opcionális."""
name: Optional[str] = Field(None, min_length=1, max_length=150, description="Biztosító neve")
country_code: Optional[str] = Field(None, max_length=2, description="ISO 3166-1 alpha-2 országkód (pl. 'HU'). Ha None, akkor globális.")
claim_phone: Optional[str] = Field(None, max_length=50, description="Kárbejelentő telefonszám")
claim_url: Optional[str] = Field(None, max_length=255, description="Kárbejelentő URL")
services_offered: Optional[Dict[str, Any]] = Field(None, description="Kínált szolgáltatások (JSONB)")
is_active: Optional[bool] = Field(None, description="Aktív-e")
model_config = ConfigDict(from_attributes=True)
# =============================================================================
# VehicleInsurancePolicy sémák
# =============================================================================
class VehicleInsurancePolicyCreate(BaseModel):
"""Új biztosítási kötvény létrehozása."""
provider_id: int = Field(..., description="Biztosító ID (insurance_providers tábla)")
insurance_type: str = Field(..., max_length=20, description="Biztosítás típusa (KGFB, CASCO)")
policy_number: str = Field(..., max_length=100, description="Kötvényszám")
start_date: date = Field(..., description="Biztosítás kezdete")
expiry_date: date = Field(..., description="Biztosítás lejárata")
premium_amount: float = Field(..., ge=0, description="Díj összege")
currency: str = Field(default="HUF", max_length=3, description="Pénznem")
document_id: Optional[UUID] = Field(None, description="Kapcsolódó dokumentum ID (PDF kötvény)")
@field_validator('insurance_type')
@classmethod
def validate_insurance_type(cls, v: str) -> str:
"""insurance_type csak KGFB vagy CASCO lehet."""
allowed = {"KGFB", "CASCO"}
cleaned = v.strip().upper()
if cleaned not in allowed:
raise ValueError(f'A biztosítás típusa csak {allowed} lehet, kapott: "{v}"')
return cleaned
@field_validator('expiry_date')
@classmethod
def validate_expiry_after_start(cls, v: date, info):
"""expiry_date nem lehet a start_date előtt."""
values = info.data
start = values.get('start_date')
if start and v <= start:
raise ValueError('A biztosítás lejárati dátuma nem lehet a kezdő dátum előtt vagy azzal egyenlő')
return v
model_config = ConfigDict(from_attributes=True)
class VehicleInsurancePolicyUpdate(BaseModel):
"""Biztosítási kötvény frissítése - minden mező opcionális."""
provider_id: Optional[int] = Field(None, description="Biztosító ID")
insurance_type: Optional[str] = Field(None, max_length=20, description="Biztosítás típusa")
policy_number: Optional[str] = Field(None, max_length=100, description="Kötvényszám")
start_date: Optional[date] = Field(None, description="Biztosítás kezdete")
expiry_date: Optional[date] = Field(None, description="Biztosítás lejárata")
premium_amount: Optional[float] = Field(None, ge=0, description="Díj összege")
currency: Optional[str] = Field(None, max_length=3, description="Pénznem")
document_id: Optional[UUID] = Field(None, description="Kapcsolódó dokumentum ID (PDF kötvény)")
model_config = ConfigDict(from_attributes=True)
class VehicleInsurancePolicyResponse(BaseModel):
"""Biztosítási kötvény teljes válaszmodell."""
id: UUID
asset_id: UUID
provider_id: int
insurance_type: str
policy_number: str
start_date: date
expiry_date: date
premium_amount: float
currency: str
document_id: Optional[UUID] = None
model_config = ConfigDict(from_attributes=True)
# =============================================================================
# VehicleTaxObligation sémák
# =============================================================================
class VehicleTaxObligationCreate(BaseModel):
"""Új adókötelezettség létrehozása."""
tax_type: str = Field(..., max_length=30, description="Adó típusa (WEIGHT_TAX, COMPANY_CAR_TAX)")
tax_year: int = Field(..., ge=2000, le=2100, description="Adóév")
amount: float = Field(..., ge=0, description="Adó összege")
currency: str = Field(default="HUF", max_length=3, description="Pénznem")
due_date: Optional[date] = Field(None, description="Fizetési határidő")
payment_status: str = Field(default="pending", max_length=20, description="Fizetési státusz")
document_id: Optional[UUID] = Field(None, description="Kapcsolódó dokumentum ID (PDF adóbevallás)")
@field_validator('tax_type')
@classmethod
def validate_tax_type(cls, v: str) -> str:
"""tax_type csak WEIGHT_TAX vagy COMPANY_CAR_TAX lehet."""
allowed = {"WEIGHT_TAX", "COMPANY_CAR_TAX"}
cleaned = v.strip().upper()
if cleaned not in allowed:
raise ValueError(f'Az adó típusa csak {allowed} lehet, kapott: "{v}"')
return cleaned
@field_validator('payment_status')
@classmethod
def validate_payment_status(cls, v: str) -> str:
"""payment_status csak pending, paid, overdue lehet."""
allowed = {"pending", "paid", "overdue"}
cleaned = v.strip().lower()
if cleaned not in allowed:
raise ValueError(f'A fizetési státusz csak {allowed} lehet, kapott: "{v}"')
return cleaned
model_config = ConfigDict(from_attributes=True)
class VehicleTaxObligationUpdate(BaseModel):
"""Adókötelezettség frissítése - minden mező opcionális."""
tax_type: Optional[str] = Field(None, max_length=30, description="Adó típusa")
tax_year: Optional[int] = Field(None, ge=2000, le=2100, description="Adóév")
amount: Optional[float] = Field(None, ge=0, description="Adó összege")
currency: Optional[str] = Field(None, max_length=3, description="Pénznem")
due_date: Optional[date] = Field(None, description="Fizetési határidő")
payment_status: Optional[str] = Field(None, max_length=20, description="Fizetési státusz")
document_id: Optional[UUID] = Field(None, description="Kapcsolódó dokumentum ID (PDF adóbevallás)")
model_config = ConfigDict(from_attributes=True)
class VehicleTaxObligationResponse(BaseModel):
"""Adókötelezettség teljes válaszmodell."""
id: UUID
asset_id: UUID
tax_type: str
tax_year: int
amount: float
currency: str
due_date: Optional[date] = None
payment_status: str
document_id: Optional[UUID] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,5 +1,6 @@
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Any from typing import Optional, List, Any
from datetime import datetime
class ContactCreate(BaseModel): class ContactCreate(BaseModel):
full_name: str full_name: str
@@ -78,7 +79,11 @@ class OrganizationResponse(BaseModel):
is_active: bool = True is_active: bool = True
is_deleted: bool = False is_deleted: bool = False
subscription_plan: str = "FREE" subscription_plan: str = "FREE"
subscription_expires_at: Optional[datetime] = None
subscription_tier_id: Optional[int] = None subscription_tier_id: Optional[int] = None
# P0: Real subscription limits from the assigned subscription_tier JSONB rules
max_vehicles: int = 1
max_garages: int = 1
visual_settings: Optional[dict] = None visual_settings: Optional[dict] = None
notification_settings: Optional[Any] = None notification_settings: Optional[Any] = None
external_integration_config: Optional[Any] = None external_integration_config: Optional[Any] = None

View File

@@ -56,6 +56,10 @@ class UserResponse(UserBase):
person_id: Optional[int] = None person_id: Optional[int] = None
role: str role: str
subscription_plan: str subscription_plan: str
subscription_expires_at: Optional[datetime] = None
# P0: Real subscription limits from the assigned subscription_tier JSONB rules
max_vehicles: int = 1
max_garages: int = 1
scope_level: str scope_level: str
scope_id: Optional[str] = None scope_id: Optional[str] = None
ui_mode: str = "personal" ui_mode: str = "personal"

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/services/analytics_service.py # /opt/docker/dev/service_finder/backend/app/services/analytics_service.py
""" """
TCO (Total Cost of Ownership) Analytics Service. TCO (Total Cost of Ownership) Analytics Service.
Számítások a vehicle.costs tábla alapján, árfolyam-átváltással a system_service segítségével. Számítások a fleet_finance.asset_costs tábla alapján, árfolyam-átváltással a system_service segítségével.
""" """
import logging import logging
@@ -10,7 +10,7 @@ from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models.vehicle import VehicleCost, CostCategory from app.models.fleet_finance import AssetCost, CostCategory
from app.models import VehicleModelDefinition from app.models import VehicleModelDefinition
from app.models.marketplace.organization import Organization from app.models.marketplace.organization import Organization
from app.services.system_service import SystemService from app.services.system_service import SystemService
@@ -56,22 +56,22 @@ class TCOAnalytics:
""" """
# Alap lekérdezés: organization_id szűrés # Alap lekérdezés: organization_id szűrés
stmt = select( stmt = select(
VehicleCost.amount, AssetCost.amount_net,
VehicleCost.currency, AssetCost.currency,
VehicleCost.category_id, AssetCost.category_id,
CostCategory.code, CostCategory.code,
CostCategory.name CostCategory.name
).join( ).join(
CostCategory, VehicleCost.category_id == CostCategory.id CostCategory, AssetCost.category_id == CostCategory.id
).where( ).where(
VehicleCost.organization_id == organization_id AssetCost.organization_id == organization_id
) )
# Dátum szűrés # Dátum szűrés
if start_date: if start_date:
stmt = stmt.where(VehicleCost.date >= start_date) stmt = stmt.where(AssetCost.date >= start_date)
if end_date: if end_date:
stmt = stmt.where(VehicleCost.date <= end_date) stmt = stmt.where(AssetCost.date <= end_date)
# Kategória szűrés # Kategória szűrés
if include_categories: if include_categories:
@@ -87,7 +87,7 @@ class TCOAnalytics:
category_totals = {} category_totals = {}
for row in rows: for row in rows:
amount = float(row.amount) amount = float(row.amount_net)
source_currency = row.currency source_currency = row.currency
# Átváltás célvalutára # Átváltás célvalutára
@@ -145,14 +145,14 @@ class TCOAnalytics:
""" """
# Összes költség lekérdezése a járműhöz # Összes költség lekérdezése a járműhöz
stmt = select( stmt = select(
VehicleCost.amount, AssetCost.amount_net,
VehicleCost.currency, AssetCost.currency,
VehicleCost.organization_id, AssetCost.organization_id,
Organization.name.label("org_name") Organization.name.label("org_name")
).outerjoin( ).outerjoin(
Organization, VehicleCost.organization_id == Organization.id Organization, AssetCost.organization_id == Organization.id
).where( ).where(
VehicleCost.vehicle_id == vehicle_model_id AssetCost.asset_id == vehicle_model_id
) )
result = await db.execute(stmt) result = await db.execute(stmt)
@@ -166,7 +166,7 @@ class TCOAnalytics:
owner_totals = {} owner_totals = {}
for row in rows: for row in rows:
amount = float(row.amount) amount = float(row.amount_net)
source_currency = row.currency source_currency = row.currency
# Átváltás célvalutára # Átváltás célvalutára
@@ -238,23 +238,22 @@ class TCOAnalytics:
""" """
# Alap lekérdezés: vehicle és cost összekapcsolása # Alap lekérdezés: vehicle és cost összekapcsolása
stmt = select( stmt = select(
VehicleCost.amount, AssetCost.amount_net,
VehicleCost.currency, AssetCost.currency,
VehicleCost.vehicle_id, AssetCost.asset_id,
VehicleCost.odometer,
CostCategory.code, CostCategory.code,
VehicleModelDefinition.make, VehicleModelDefinition.make,
VehicleModelDefinition.model, VehicleModelDefinition.model,
VehicleModelDefinition.fuel_type VehicleModelDefinition.fuel_type
).join( ).join(
VehicleModelDefinition, VehicleCost.vehicle_id == VehicleModelDefinition.id VehicleModelDefinition, AssetCost.asset_id == VehicleModelDefinition.id
).join( ).join(
CostCategory, VehicleCost.category_id == CostCategory.id CostCategory, AssetCost.category_id == CostCategory.id
) )
# Szűrés # Szűrés
if vehicle_model_id: if vehicle_model_id:
stmt = stmt.where(VehicleCost.vehicle_id == vehicle_model_id) stmt = stmt.where(AssetCost.asset_id == vehicle_model_id)
benchmark_type = "specific_model" benchmark_type = "specific_model"
else: else:
conditions = [] conditions = []
@@ -295,7 +294,7 @@ class TCOAnalytics:
category_counts = {} category_counts = {}
for row in rows: for row in rows:
amount = float(row.amount) amount = float(row.amount_net)
source_currency = row.currency source_currency = row.currency
# Átváltás # Átváltás
@@ -304,11 +303,7 @@ class TCOAnalytics:
) )
total_cost_sum += converted_amount total_cost_sum += converted_amount
vehicle_ids.add(row.vehicle_id) vehicle_ids.add(row.asset_id)
# Odometer összegzés (ha van)
if row.odometer:
total_odometer_sum += row.odometer
# Kategória összesítés # Kategória összesítés
category_code = row.code category_code = row.code
@@ -322,11 +317,6 @@ class TCOAnalytics:
vehicle_count = len(vehicle_ids) vehicle_count = len(vehicle_ids)
average_cost_per_vehicle = round(total_cost_sum / vehicle_count, 2) average_cost_per_vehicle = round(total_cost_sum / vehicle_count, 2)
# Kilométerenkénti átlag számítása
average_cost_per_km = None
if total_odometer_sum > 0:
average_cost_per_km = round(total_cost_sum / total_odometer_sum, 4)
# Kategóriánkénti átlagok # Kategóriánkénti átlagok
category_averages = {} category_averages = {}
for code, total in category_totals.items(): for code, total in category_totals.items():
@@ -342,7 +332,7 @@ class TCOAnalytics:
"vehicle_count": vehicle_count, "vehicle_count": vehicle_count,
"total_cost_sum": round(total_cost_sum, 2), "total_cost_sum": round(total_cost_sum, 2),
"average_cost_per_vehicle": average_cost_per_vehicle, "average_cost_per_vehicle": average_cost_per_vehicle,
"average_cost_per_km": average_cost_per_km, "average_cost_per_km": None,
"by_category": category_averages, "by_category": category_averages,
"currency": currency_target, "currency": currency_target,
"criteria": { "criteria": {

View File

@@ -216,6 +216,19 @@ class AssetService:
# Timeline # Timeline
'year_of_manufacture': asset_data.year_of_manufacture, 'year_of_manufacture': asset_data.year_of_manufacture,
'first_registration_date': asset_data.first_registration_date, 'first_registration_date': asset_data.first_registration_date,
# Registration Documents
'registration_certificate_number': asset_data.registration_certificate_number,
'registration_certificate_validity': asset_data.registration_certificate_validity,
'vehicle_registration_document_number': asset_data.vehicle_registration_document_number,
# Internationalization (ÚJ)
'registration_country': asset_data.registration_country,
'first_domestic_registration_date': asset_data.first_domestic_registration_date,
'import_country': asset_data.import_country,
'title_document_number': asset_data.title_document_number,
'engine_number': asset_data.engine_number,
'number_of_previous_owners': asset_data.number_of_previous_owners,
} }
# Remove None values from the dictionary # Remove None values from the dictionary

View File

@@ -16,7 +16,7 @@ import logging
from typing import Optional, List from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.models.vehicle.vehicle import CostCategory from app.models.fleet_finance import CostCategory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Seed script: fleet_finance dictionary tables (CostCategory & InsuranceProvider).
Idempotent seeder that populates:
1. fleet_finance.cost_categories - Hierarchical cost categories (5 parents + children)
2. fleet_finance.insurance_providers - Base insurance providers (HU/EU)
Usage:
docker compose exec sf_api python -m backend.scripts.seed_finance_dictionaries
"""
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.fleet_finance.models import CostCategory, InsuranceProvider
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# =============================================================================
# Cost Categories - Hierarchical structure
# =============================================================================
# Format: (code, name, description, children)
# Children format: (code, name, description)
COST_CATEGORY_TREE = [
{
"code": "FINANCING",
"name": "Finanszírozás",
"description": "Jármű finanszírozásával kapcsolatos költségek",
"children": [
("FINANCING_LEASE", "Lízingdíj", "Lízingdíj fizetés"),
("FINANCING_LOAN", "Hiteltörlesztő", "Hiteltörlesztő részlet"),
],
},
{
"code": "INSURANCE",
"name": "Biztosítás",
"description": "Biztosítási díjak és kötvények",
"children": [
("INSURANCE_KGFB", "KGFB", "Kötelező gépjármű-felelősségbiztosítás"),
("INSURANCE_CASCO", "Casco", "Casco biztosítás"),
("INSURANCE_ASSISTANCE", "Assistance", "Assistance szolgáltatás"),
],
},
{
"code": "ADMIN_FEES",
"name": "Hatósági díjak",
"description": "Hatósági és adminisztratív költségek",
"children": [
("ADMIN_FEES_VEHICLE_TAX", "Gépjárműadó", "Gépjárműadó fizetés"),
("ADMIN_FEES_MOT", "Műszaki vizsga", "Műszaki vizsgadíj"),
("ADMIN_FEES_DOCS", "Okmányok", "Okmányokkal kapcsolatos díjak"),
],
},
{
"code": "OPERATION",
"name": "Üzemeltetés",
"description": "Jármű üzemeltetésével kapcsolatos költségek",
"children": [
("OPERATION_FUEL", "Üzemanyag", "Üzemanyag költség"),
("OPERATION_ELECTRIC", "Elektromos töltés", "Elektromos töltési költség"),
("OPERATION_TOLL", "Autópálya", "Autópálya használati díj"),
("OPERATION_PARKING", "Parkolás", "Parkolási díj"),
("OPERATION_WASH", "Mosás", "Járműmosás és ápolás"),
],
},
{
"code": "SERVICE",
"name": "Szerviz",
"description": "Szerviz és karbantartási költségek",
"children": [
("SERVICE_SCHEDULED", "Kötelező szerviz", "Kötelező időszakos szerviz"),
("SERVICE_REPAIR", "Eseti javítás", "Eseti javítási költség"),
("SERVICE_TIRES", "Gumiabroncs", "Gumiabroncs csere és tárolás"),
],
},
]
# =============================================================================
# Insurance Providers
# =============================================================================
INSURANCE_PROVIDERS = [
{"name": "Allianz Hungária Zrt.", "claim_phone": "+36-1-301-7100", "claim_url": "https://www.allianz.hu/karterites"},
{"name": "Generali Biztosító Zrt.", "claim_phone": "+36-1-452-3200", "claim_url": "https://www.generali.hu/karterites"},
{"name": "K&H Biztosító Zrt.", "claim_phone": "+36-1-335-3300", "claim_url": "https://www.kh.hu/biztositas/karterites"},
{"name": "Groupama Biztosító Zrt.", "claim_phone": "+36-1-477-4800", "claim_url": "https://www.groupama.hu/karterites"},
{"name": "Uniqa Biztosító Zrt.", "claim_phone": "+36-1-301-7300", "claim_url": "https://www.uniqa.hu/karterites"},
{"name": "Aegon Magyarország / Alfa Vienna Insurance Group", "claim_phone": "+36-1-477-4700", "claim_url": "https://www.alfabiztosito.hu/karterites"},
{"name": "Signal Biztosító Zrt.", "claim_phone": "+36-1-457-4400", "claim_url": "https://www.signal.hu/karterites"},
{"name": "Wáberer Hungária Biztosító Zrt.", "claim_phone": "+36-1-237-7000", "claim_url": "https://www.whb.hu/karterites"},
{"name": "Posta Biztosító Zrt.", "claim_phone": "+36-1-477-4900", "claim_url": "https://www.postabiztosito.hu/karterites"},
{"name": "Magyar Posta Életbiztosító Zrt.", "claim_phone": "+36-1-477-5000", "claim_url": "https://www.mpelb.hu/karterites"},
]
async def seed_cost_categories(db: AsyncSession) -> int:
"""Seed cost_categories table with hierarchical data. Returns count of inserted parents."""
inserted_count = 0
for parent_def in COST_CATEGORY_TREE:
# Check if parent already exists by code
existing_parent = await db.execute(
select(CostCategory).where(CostCategory.code == parent_def["code"])
)
parent = existing_parent.scalar_one_or_none()
if not parent:
parent = CostCategory(
code=parent_def["code"],
name=parent_def["name"],
description=parent_def["description"],
is_system=True,
visibility="both",
min_tier="free",
parent_id=None,
)
db.add(parent)
await db.flush() # Get the parent ID
inserted_count += 1
logger.info(f" ✅ PARENT [{parent.code:20s}] {parent.name}")
# Process children
for child_code, child_name, child_desc in parent_def["children"]:
existing_child = await db.execute(
select(CostCategory).where(CostCategory.code == child_code)
)
child = existing_child.scalar_one_or_none()
if not child:
child = CostCategory(
code=child_code,
name=child_name,
description=child_desc,
is_system=True,
visibility="both",
min_tier="free",
parent_id=parent.id,
)
db.add(child)
await db.flush()
logger.info(f" └─ CHILD [{child.code:20s}] {child.name}")
else:
logger.info(f" └─ EXISTS [{child.code:20s}] {child.name}")
return inserted_count
async def seed_insurance_providers(db: AsyncSession) -> int:
"""Seed insurance_providers table. Returns count of inserted providers."""
inserted_count = 0
for prov_def in INSURANCE_PROVIDERS:
existing = await db.execute(
select(InsuranceProvider).where(InsuranceProvider.name == prov_def["name"])
)
provider = existing.scalar_one_or_none()
if not provider:
provider = InsuranceProvider(
name=prov_def["name"],
country_code="HU",
claim_phone=prov_def["claim_phone"],
claim_url=prov_def["claim_url"],
services_offered={"kgfb": True, "casco": True},
is_active=True,
)
db.add(provider)
inserted_count += 1
logger.info(f"{provider.name} (HU)")
else:
# Update existing provider's country_code if not set
if not provider.country_code:
provider.country_code = "HU"
logger.info(f" UPDATED {provider.name} → country_code='HU'")
else:
logger.info(f" EXISTS {provider.name} (country_code='{provider.country_code}')")
return inserted_count
async def main():
"""Main seeder entry point."""
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:
logger.info("=" * 60)
logger.info("📦 fleet_finance dictionary seeder")
logger.info("=" * 60)
# --- Cost Categories ---
logger.info("\n📁 Cost Categories (hierarchical):")
cat_count = await seed_cost_categories(db)
await db.commit()
logger.info(f"{cat_count} new parent categories inserted")
# --- Insurance Providers ---
logger.info("\n🏢 Insurance Providers:")
prov_count = await seed_insurance_providers(db)
await db.commit()
logger.info(f"{prov_count} new providers inserted")
# --- Summary ---
total_cats = await db.execute(
select(CostCategory).where(CostCategory.parent_id.is_(None))
)
parents = total_cats.scalars().all()
logger.info("\n" + "=" * 60)
logger.info("📊 SEED SUMMARY")
logger.info("=" * 60)
logger.info(f" CostCategory parents: {len(parents)}")
for p in parents:
child_count = await db.execute(
select(CostCategory).where(CostCategory.parent_id == p.id)
)
children = child_count.scalars().all()
logger.info(f" ├─ {p.code:20s} ({p.name}) → {len(children)} children")
total_providers = await db.execute(
select(InsuranceProvider).where(InsuranceProvider.is_active.is_(True))
)
providers = total_providers.scalars().all()
logger.info(f" InsuranceProviders (active): {len(providers)}")
for p in providers:
logger.info(f" ├─ {p.name}")
logger.info("\n✅ Seeding completed successfully!")
except Exception as e:
await db.rollback()
logger.error(f"❌ Seeding failed: {e}")
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -4,14 +4,21 @@
"SUCCESS": "Login successful. Welcome back!" "SUCCESS": "Login successful. Welcome back!"
}, },
"ERROR": { "ERROR": {
"EMAIL_EXISTS": "This email is already registered." "EMAIL_EXISTS": "This email is already registered.",
"UNAUTHORIZED": "Unauthorized access."
} }
}, },
"SENTINEL": { "SENTINEL": {
"LOCK": { "LOCK": {
"MSG": "Account locked for security reasons." "MSG": "Account locked for security reasons."
},
"APPROVAL": {
"REQUIRED": "Approval required"
} }
}, },
"COMMON": {
"SAVE_SUCCESS": "Saved successfully!"
},
"EMAIL": { "EMAIL": {
"REG": { "REG": {
"SUBJECT": "Confirm your registration - Service Finder", "SUBJECT": "Confirm your registration - Service Finder",
@@ -72,5 +79,11 @@
"OTP_SENT": "A 6-digit verification code has been sent to the organization owner's email.", "OTP_SENT": "A 6-digit verification code has been sent to the organization owner's email.",
"SUCCESS": "You have successfully taken over the organization. You are now the admin." "SUCCESS": "You have successfully taken over the organization. You are now the admin."
} }
},
"REGISTRATION_DOCUMENTS": {
"CERTIFICATE_NUMBER": "Registration Certificate Number",
"CERTIFICATE_VALIDITY": "Registration Certificate Validity",
"DOCUMENT_NUMBER": "Vehicle Registration Document Number",
"TITLE": "Registration Certificate & Vehicle Document"
} }
} }

View File

@@ -5,7 +5,7 @@
}, },
"ERROR": { "ERROR": {
"EMAIL_EXISTS": "Ez az e-mail cím már foglalt.", "EMAIL_EXISTS": "Ez az e-mail cím már foglalt.",
"UNAUTHORIZED": "Nincs jogosultságod a művelethez." "UNAUTHORIZED": "Jogosulatlan hozzáférés"
} }
}, },
"SENTINEL": { "SENTINEL": {
@@ -79,5 +79,11 @@
"OTP_SENT": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.", "OTP_SENT": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.",
"SUCCESS": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin." "SUCCESS": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin."
} }
},
"REGISTRATION_DOCUMENTS": {
"CERTIFICATE_NUMBER": "Forgalmi engedély száma",
"CERTIFICATE_VALIDITY": "Forgalmi engedély érvényessége",
"DOCUMENT_NUMBER": "Törzskönyv száma",
"TITLE": "Forgalmi engedély és Törzskönyv"
} }
} }

View File

@@ -0,0 +1,214 @@
# 🐛 BMW123 Jármű Adatmegjelenítési Hibajelentés
**Dátum:** 2026-06-20
**Vizsgált entitás:** `bcfed768-c8c2-4867-a7bb-38a6517dce36` (BMW123 / BMW / BMWM3)
**Scope:** Backend (`asset` modell + `AssetUpdate` séma) → Frontend (`OverviewTab`, `VehicleFormModal`)
**Hibák száma:** 4
---
## 🔍 Összefoglaló
A BMW123 rendszámú jármű adatbázisban tárolt `registration_certificate_validity` (2026-08-08) adata nem jelenik meg a frontend járműáttekintő oldalon. Emellett a `VehicleFormModal` adminisztrációs fülön beállítható mezők (`insurance_expiry_date`, `purchase_date`) értékei elvesznek a mentés során, mivel a backend Pydantic sémái és adatbázis modellje nem tartalmazza ezeket a mezőket.
---
## 🐛 BUG #1 — KRITIKUS: MOT/Regisztrációs Érvényesség Eltérés
### Adatbázis oldal
A `vehicle.assets` táblában a `registration_certificate_validity` mező **létezik** és **helyes értéket** tárol:
```
registration_certificate_validity = 2026-08-08 00:00:00+00
```
Ez a mező egy **top-level DateTime** oszlop az [`Asset`](backend/app/models/vehicle/asset.py:137-140) modellben:
```python
registration_certificate_validity: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
```
### Frontend hiba
Az [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue:273-277) a `registration_certificate_validity` helyett tévesen a `individual_equipment.dates.mot_expiry` JSONB mezőből olvassa ki a műszaki érvényességet:
```typescript
const motExpiryLabel = computed(() => {
const dates = vehicle?.value?.individual_equipment?.dates
if (!dates?.mot_expiry) return t('common.noData')
return formatDate(dates.mot_expiry)
})
```
### Gyökérok
A **JSONB `individual_equipment.dates`** objektum és a **top-level `registration_certificate_validity`** oszlop két különböző tárolási hely. A `VehicleFormModal` a `registration_certificate_validity` mezőt használja (helyesen), de az `OverviewTab` a JSONB-ből próbál olvasni. Mivel a `dates` objektum nem létezik a BMW123 adataiban (`individual_equipment` = `{"features": [...], "car_specs": {...}}`), mindig "Nincs adat" jelenik meg.
### Érintett fájlok
- [`frontend/src/components/vehicles/tabs/OverviewTab.vue:273`](frontend/src/components/vehicles/tabs/OverviewTab.vue:273) — hibás olvasás
- [`frontend/src/stores/vehicle.ts:89-92`](frontend/src/stores/vehicle.ts:89) — `dates` interfész definíció
- [`backend/app/models/vehicle/asset.py:137-140`](backend/app/models/vehicle/asset.py:137) — helyes adatbázis mező
### Javasolt javítás
Az `OverviewTab.vue`-ban a `motExpiryLabel` computed property-t át kell írni, hogy a `vehicle.value?.registration_certificate_validity`-ből olvasson, vagy alternatívaként a `VehicleFormModal` `handleSave()` függvényében a `registration_certificate_validity` értékét szinkronizálni kell a `individual_equipment.dates.mot_expiry`-be is.
---
## 🐛 BUG #2 — KRITIKUS: Biztosítási és Vásárlási Adatok Elvesznek
### Adatbázis modell hiányosság
Az [`Asset`](backend/app/models/vehicle/asset.py:69) modellben **NEM** léteznek az alábbi mezők:
- `insurance_expiry_date` (biztosítás lejárta)
- `purchase_date` (vásárlás dátuma)
Ezek a mezők teljesen hiányoznak az adatbázis sémából.
### Pydantic séma hiányosság
Az [`AssetUpdate`](backend/app/schemas/asset.py:269) és [`AssetCreate`](backend/app/schemas/asset.py:122) sémákban **NEM** szerepelnek:
- `insurance_expiry_date`
- `purchase_date`
### Frontend helyzet
A [`VehicleFormModal.vue`](frontend/src/components/dashboard/VehicleFormModal.vue) adminisztrációs fül (Tab 2) tartalmazza ezeket a mezőket:
- `form.insurance_expiry_date` — "Biztosítás lejárta" dátumválasztó
- `form.purchase_date` — "Vásárlás dátuma" dátumválasztó
- `form.registration_certificate_validity` — "Forgalmi engedély érvényessége" dátumválasztó
A [`handleSave()`](frontend/src/components/dashboard/VehicleFormModal.vue:507) függvény a payload-ba teszi ezeket:
```typescript
const payload: Record<string, any> = {
...form.value,
// ...
}
```
A [`populateForm()`](frontend/src/components/dashboard/VehicleFormModal.vue:440) függvény pedig a vehicle objektumból várja ezeket:
```typescript
form.value.insurance_expiry_date = vehicle.insurance_expiry_date || ''
form.value.purchase_date = vehicle.purchase_date || ''
```
### Gyökérok
Az [`update_vehicle`](backend/app/api/v1/endpoints/assets.py:660) endpoint `payload.model_dump(exclude_unset=True)`-t használ, ami **kizárólag** az `AssetUpdate` Pydantic sémában definiált mezőket adja át az adatbázisnak. Mivel `insurance_expiry_date` és `purchase_date` nem szerepelnek az `AssetUpdate`-ben, ezek az értékek **csendben elvesznek** a PUT kérés során.
### Érintett fájlok
- [`backend/app/models/vehicle/asset.py:69`](backend/app/models/vehicle/asset.py:69) — hiányzó modell mezők
- [`backend/app/schemas/asset.py:269`](backend/app/schemas/asset.py:269) — hiányzó séma mezők
- [`backend/app/schemas/asset.py:122`](backend/app/schemas/asset.py:122) — hiányzó séma mezők (Create)
- [`frontend/src/components/dashboard/VehicleFormModal.vue:440-460`](frontend/src/components/dashboard/VehicleFormModal.vue:440) — frontend várja ezeket
- [`frontend/src/components/dashboard/VehicleFormModal.vue:507`](frontend/src/components/dashboard/VehicleFormModal.vue:507) — frontend küldi ezeket
### Javasolt javítás
1. Hozzáadni az `Asset` modellhez: `insurance_expiry_date: Mapped[Optional[datetime]]` és `purchase_date: Mapped[Optional[datetime]]`
2. Hozzáadni az `AssetUpdate` és `AssetCreate` sémákhoz ezeket a mezőket
3. Futtatni a `sync_engine`-t a séma frissítéséhez
---
## 🐛 BUG #3 — HIÁNYZÓ FUNKCIÓ: Nincs Dedikált Biztosítási Adat Struktúra
### Hiányzó mezők
Sem az [`Asset`](backend/app/models/vehicle/asset.py:69) modellben, sem más kapcsolódó táblában (`asset_financials`, `asset_events`) **NEM** léteznek dedikált mezők az alábbiakra:
- **Kötelező felelősségbiztosítás** (liability insurance)
- Kötvény száma
- Lejárati dátuma
- Biztosító neve
- **CASCO biztosítás**
- Kötvény száma
- Lejárati dátuma
- Önrész összege
- Biztosító neve
### Javasolt megoldás
Új tábla létrehozása: `vehicle.insurance_policies`:
```sql
CREATE TABLE vehicle.insurance_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id UUID NOT NULL REFERENCES vehicle.assets(id) ON DELETE CASCADE,
insurance_type VARCHAR(20) NOT NULL CHECK (insurance_type IN ('liability', 'casco')),
policy_number VARCHAR(100),
insurer_name VARCHAR(255),
start_date DATE,
expiry_date DATE,
deductible_amount DECIMAL(12,2),
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
```
---
## 🐛 BUG #4 — KISEBB: "Következő Szerviz" Helyettesítő Szöveg
### Hiba
Az [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue:291-294) "Következő szerviz" mezője mindig "Coming Soon" szöveget mutat:
```typescript
const nextServiceLabel = computed(() => {
// Placeholder — will be connected to the service book / maintenance schedule later
return t('vehicleDetail.comingSoon')
})
```
### Érintett fájl
- [`frontend/src/components/vehicles/tabs/OverviewTab.vue:291`](frontend/src/components/vehicles/tabs/OverviewTab.vue:291)
### Javasolt javítás
Össze kell kötni a szervizkönyv modullal. A következő szerviz esedékességét az `asset_events` táblában rögzített szerviz előzményekből kellene kiszámolni (pl. utolsó olajcsere + 15.000 km vagy + 1 év).
---
## 📊 Adatfolyam Diagram (Jelenlegi Hibás Állapot)
```mermaid
flowchart TD
DB[(PostgreSQL\nvehicle.assets)]
DB -->|registration_certificate_validity\n2026-08-08| API
API[GET /api/v1/vehicles/{id}]
API -->|AssetResponse\nregistration_certificate_validity| FE_Store[Pinia Store\nvehicle.ts]
FE_Store -->|vehicle.registration_certificate_validity| OverviewTab
FE_Store -->|vehicle.individual_equipment.dates.mot_expiry| OverviewTab
subgraph OverviewTab [OverviewTab.vue - HIBA]
MOT[MOT label\nolvas: individual_equipment.dates.mot_expiry]
Service[Next Service\nComing Soon placeholder]
end
subgraph VehicleFormModal [VehicleFormModal.vue - VESZÉLYES]
Form[Adminisztráció fül]
Form --> insurance[insurance_expiry_date]
Form --> purchase[purchase_date]
Form --> regCert[registration_certificate_validity]
insurance -->|ELVESZ| PUT[PUT /vehicles/{id}\nexclude_unset=True]
purchase -->|ELVESZ| PUT
regCert -->|ELTÁROLVA| PUT
end
PUT -->|AssetUpdate.model_dump\ncsak definiált mezők| DB
```
---
## ✅ Javítási Terv (Jóváhagyásra Vár)
| # | Prioritás | Feladat | Érintett fájlok |
|---|-----------|---------|-----------------|
| 1 | **KRITIKUS** | `OverviewTab.vue` MOT olvasás javítása `registration_certificate_validity`-ből | [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue:273) |
| 2 | **KRITIKUS** | `insurance_expiry_date` és `purchase_date` hozzáadása az `Asset` modellhez | [`asset.py`](backend/app/models/vehicle/asset.py:69) |
| 3 | **KRITIKUS** | `insurance_expiry_date` és `purchase_date` hozzáadása az `AssetUpdate`/`AssetCreate` sémákhoz | [`asset.py schema`](backend/app/schemas/asset.py:269) |
| 4 | **MAGAS** | Szinkronizációs logika: `registration_certificate_validity` másolása `individual_equipment.dates.mot_expiry`-be | [`assets.py endpoint`](backend/app/api/v1/endpoints/assets.py:660) |
| 5 | **KÖZEPES** | Dedikált `insurance_policies` tábla létrehozása | Új SQLAlchemy modell |
| 6 | **KÖZEPES** | Következő szerviz összekötése a szervizkönyv adatokkal | [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue:291) |
---
## 📝 Technikai Megjegyzések
- A **Sync Engine** használata kötelező az új mezők adatbázisba vezetéséhez: `docker exec sf_api python3 -m app.scripts.sync_engine`
- A JSONB `individual_equipment.dates` opcionális, nem minden járműnél van kitöltve
- A frontend `Vehicle` interfész frissítése is szükséges a [`vehicle.ts`](frontend/src/stores/vehicle.ts:9) store-ban
- A `populateForm` függvényben a hiányzó mezők miatt TypeScript hiba léphet fel, ha szigorú típusellenőrzés van
---
*Jelentés készült: 2026-06-20 | Auditor: Rendszer-Architect*

158
docs/cost_pipeline_audit.md Normal file
View File

@@ -0,0 +1,158 @@
# P0 Cost Pipeline Visibility Audit Report
**Date:** 2026-06-23
**Author:** Fast Coder (Core Developer)
**Status:** Complete
**Gitea Card:** #282
---
## Executive Summary
The `/dashboard/costs` page is **empty** for User 28 (tester_pro@profibot.hu) despite Org 1 having **5 confirmed records** in `fleet_finance.asset_costs` (for vehicles BMW123 and QWE432). The root cause is a **backend organization resolution bug** in the `GET /expenses/` endpoint.
---
## 1. Frontend Audit
### CostsView.vue
- **File:** [`frontend/src/views/costs/CostsView.vue`](frontend/src/views/costs/CostsView.vue:299)
- Calls `api.get('/expenses', { params })` at line 299
- **Does NOT pass any `organization_id` parameter** — only sends `page`, `page_size`, and optionally `asset_id`
- The frontend relies entirely on the backend to resolve the correct organization
### Pinia Stores
- **`cost.ts`** ([`frontend/src/stores/cost.ts`](frontend/src/stores/cost.ts)) — Only handles POST (create expense), not fetching
- **`expense.ts`** ([`frontend/src/stores/expense.ts`](frontend/src/stores/expense.ts)) — Fetches by asset ID (`/expenses/{asset_id}`), but is NOT used by CostsView.vue
### Data Mapping
The frontend template reads these keys from the API response:
- `cost.created_at` or `cost.date` (line 127)
- `cost.category_code` or `cost.cost_type` (line 134)
- `cost.category_name` or `cost.cost_type` (line 136)
- `cost.amount_gross` (line 143)
- `cost.currency` (line 143)
- `cost.vehicle_name` or `cost.license_plate` (line 149)
- `cost.description` (line 155)
The backend API returns all these keys correctly (see lines 193-218 of expenses.py). **No mapping mismatch found.**
---
## 2. Backend API Audit
### Endpoint: `GET /expenses/` (list_all_expenses)
- **File:** [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py:115-239)
### 🔴 ROOT CAUSE: Organization Resolution Bug
At lines 130-139, the endpoint resolves the user's organization:
```python
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
membership = org_result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=403, detail="No active organization membership found.")
org_id = membership.organization_id
```
**Problem:** This query uses `.limit(1)` without an `ORDER BY`, which means PostgreSQL returns the **first matching row by physical storage order**. For User 28, this resolves to **organization_id = 63** (membership ID 26, ADMIN role), which has **ZERO asset_costs**.
### User 28's Memberships (in DB order):
| Membership ID | Organization ID | Role | Status | Has Costs? |
|---------------|----------------|-------|--------|------------|
| 26 | 63 | ADMIN | active | **NO** |
| 27 | 21 | OWNER | active | **NO** |
| 49 | 1 | OWNER | active | **YES (5 records)** |
### The Fix Should Be:
The endpoint should use the user's **`active_organization_id`** (from `identity.users` table) instead of blindly picking the first membership. The `active_organization_id` is already tracked on the user profile (see [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:70) — `active_organization_id: number | null`).
---
## 3. Database Verification
### Org 1 Asset Costs (Confirmed)
| Asset ID | License Plate | Amount | Category | Status |
|----------|--------------|--------|----------|--------|
| bcfed768... | BMW123 | 69,850 | 4 (INSURANCE) | APPROVED |
| bcfed768... | BMW123 | 69,850 | 4 (INSURANCE) | APPROVED |
| c6afc130... | QWE432 | 10,000 | 1 (FUEL) | APPROVED |
| c6afc130... | QWE432 | 10,000 | 1 (FUEL) | APPROVED |
| c6afc130... | QWE432 | 5,000 | 1 (FUEL) | APPROVED |
**Total: 5 records, all APPROVED, all in Org 1.**
### Backend Query Test
When the exact same SQLAlchemy query is run with `organization_id = 1` explicitly, it returns **all 5 rows** with correct vehicle info (license_plate, brand, model). The data pipeline from DB → ORM → API response is **fully functional**.
---
## 4. Data Flow Summary
```
CostsView.vue
└─ GET /expenses (no org_id param)
└─ Backend resolves org via first active membership
└─ Finds org_id = 63 (User 28's first membership)
└─ Query: AssetCost.organization_id == 63
└─ Returns 0 rows → Empty table
```
**Expected flow:**
```
CostsView.vue
└─ GET /expenses (no org_id param)
└─ Backend resolves org via active_organization_id
└─ Finds org_id = 1 (User 28's active org)
└─ Query: AssetCost.organization_id == 1
└─ Returns 5 rows → Table populated
```
---
## 5. Recommended Fix
### Immediate Fix (Backend)
Modify [`list_all_expenses()`](backend/app/api/v1/endpoints/expenses.py:130-139) to use the user's `active_organization_id` instead of the first active membership:
```python
# Use active_organization_id from user profile
org_id = current_user.active_organization_id
if not org_id:
# Fallback: try to find any active membership
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
membership = org_result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=403, detail="No active organization membership found.")
org_id = membership.organization_id
```
### Alternative Fix (Frontend)
Pass `organization_id` explicitly in the API call from CostsView.vue, reading it from the auth store's `active_organization_id`. However, this is less robust as it requires every API consumer to know about org switching.
### Recommended Approach
**Fix the backend** — it's the single source of truth for organization resolution and will fix the issue for ALL frontend components that call `GET /expenses/`.
---
## 6. Files Touched During Audit
| File | Purpose |
|------|---------|
| [`frontend/src/views/costs/CostsView.vue`](frontend/src/views/costs/CostsView.vue) | Frontend costs page — calls GET /expenses |
| [`frontend/src/stores/cost.ts`](frontend/src/stores/cost.ts) | Pinia store for creating expenses |
| [`frontend/src/stores/expense.ts`](frontend/src/stores/expense.ts) | Pinia store for per-asset expense fetching |
| [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts) | Auth store — has `active_organization_id` |
| [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py) | **Backend endpoint with the bug** (lines 130-139) |
| `fleet_finance.asset_costs` | Database table — 5 records confirmed for Org 1 |
| `fleet.organization_members` | User 28 has 3 memberships (org 63, 21, 1) |
| `identity.users` | User 28 has `scope_id = None`, `scope_level = 'individual'` |

177
docs/cost_ui_audit.md Normal file
View File

@@ -0,0 +1,177 @@
# Cost UI Audit Report — Dashboard Cost Actions & Existing Cost Forms
**Date:** 2026-06-21
**Author:** Fast Coder (Core Developer)
**Scope:** P0 READ & PLAN — Dual-Entry Cost System UX Audit
**Status:** Audit Complete — No code modified
---
## 1. Dashboard Cost Actions Card (`CostsActionsCard.vue`)
**File:** [`frontend/src/components/dashboard/CostsActionsCard.vue`](frontend/src/components/dashboard/CostsActionsCard.vue)
### Current Template Structure
The card is a 350px-tall bento-grid tile with:
| Element | Description |
|---------|-------------|
| **Header** | Dark slate bar with `💰` icon + `t('dashboard.costsTitle')` label |
| **Vehicle Selector** | `<select>` dropdown listing all `vehicleStore.sortedVehicles` by license plate + brand/model |
| **No Vehicle Warning** | Amber alert box shown when no vehicle is selected |
| **⛽ Record Fueling** | Big gradient button (sf-accent→emerald) — calls `openFuelModal()` |
| **🅿️ Parking / Toll** | Medium secondary button — calls `openFeeModal()` |
| ** Additional Costs** | Medium secondary button — calls `openComplexModal()` |
### Events Emitted
- **`cost-saved`** — Emitted with `vehicleId: string` after any modal saves successfully. Parent (`DashboardView`) forwards this to `VehicleDetailModal` to trigger cost refresh.
### Modals Opened
| Modal | Trigger | Mode |
|-------|---------|------|
| `SimpleFuelModal` | `openFuelModal()` | Fuel entry (liters, odometer) |
| `ComplexExpenseModal` (Fee mode) | `openFeeModal()` | `isFeeMode=true` → Parking/Toll |
| `ComplexExpenseModal` (Complex mode) | `openComplexModal()` | `isFeeMode=false` → Full category cascade |
### Key Observations
-**Quick Actions exist** for Fuel, Parking/Toll, and "Other" costs.
-**No "Invoice / Számla" button** — there is no deep-entry wizard for supplier invoices with line items.
-**No supplier field, no invoice number, no VAT breakdown** in any of the current modals.
- ✅ The card is well-structured and uses a clean vehicle selector + action button pattern.
---
## 2. Existing Cost Form Audit
### 2.1 `SimpleFuelModal.vue`
**File:** [`frontend/src/components/dashboard/SimpleFuelModal.vue`](frontend/src/components/dashboard/SimpleFuelModal.vue)
| Field | Type | Required |
|-------|------|----------|
| Vehicle (read-only) | Display | — |
| Date | `date` input | ✅ |
| Amount (HUF) | `number` | ✅ |
| Quantity (Liters/kWh) | `number` | ✅ |
| Odometer | `number` | ❌ |
**Payload:** `asset_id`, `category_id` (FUEL resolved by code), `amount_gross`, `currency`, `date`, `mileage_at_cost`, `description`, `data.quantity`, `data.fuel_type`.
### 2.2 `ComplexExpenseModal.vue`
**File:** [`frontend/src/components/dashboard/ComplexExpenseModal.vue`](frontend/src/components/dashboard/ComplexExpenseModal.vue)
| Field | Type | Required |
|-------|------|----------|
| Vehicle (read-only) | Display | — |
| Date | `date` input | ✅ |
| Amount (HUF) | `number` | ✅ |
| Main Category | `<select>` cascade | ✅ (if not fee mode) |
| Sub Category | `<select>` cascade | ✅ (if available) |
| Description / Notes | `<textarea>` | ❌ |
| Recurring Toggle | Checkbox | ❌ (hidden in fee mode) |
**Dual Mode:**
- `isFeeMode=true` → Pre-fills category as FEES (code lookup), hides category cascade.
- `isFeeMode=false` → Full category cascade from `/dictionaries/cost-categories`.
### 2.3 `CostManagerModal.vue`
**File:** [`frontend/src/components/cost/CostManagerModal.vue`](frontend/src/components/cost/CostManagerModal.vue)
This is a **read-only cost list viewer**, not an entry form. It displays paginated costs from `/expenses` with date, type badge, amount, vehicle, and description columns. No create/edit functionality.
### 2.4 `FinancialsTab.vue` (Vehicle Detail)
**File:** [`frontend/src/components/vehicles/tabs/FinancialsTab.vue`](frontend/src/components/vehicles/tabs/FinancialsTab.vue)
Shows **purchasing/leasing financials** (purchase price, down payment, monthly installment, contract number, etc.). This is about vehicle acquisition financing, not operational cost entry.
### 2.5 `FinancialCard.vue` (Dashboard)
**File:** [`frontend/src/components/dashboard/FinancialCard.vue`](frontend/src/components/dashboard/FinancialCard.vue)
Shows wallet/credit balance (purchased credits, service coins, earned, voucher). No cost entry functionality.
---
## 3. Gap Analysis: What's Missing for Dual-Entry Cost System
### Current State (Quick Actions Only)
| Feature | Status |
|---------|--------|
| Fuel entry (liters + odometer) | ✅ `SimpleFuelModal` |
| Parking / Toll quick entry | ✅ `ComplexExpenseModal` (fee mode) |
| Other cost with category cascade | ✅ `ComplexExpenseModal` (complex mode) |
| Recurring cost toggle | ✅ In complex mode |
| **Supplier / Vendor field** | ❌ **Missing** |
| **Invoice number** | ❌ **Missing** |
| **Invoice date (separate from cost date)** | ❌ **Missing** |
| **VAT amount / VAT rate** | ❌ **Missing** |
| **Net amount (split from gross)** | ❌ **Missing** |
| **Line items (multi-item invoice)** | ❌ **Missing** |
| **Document upload (PDF invoice)** | ❌ **Missing** |
| **Payment due date** | ❌ **Missing** |
| **Cost center / department allocation** | ❌ **Missing** |
### Verdict: Build from Scratch or Extend?
**Recommendation: BUILD FROM SCRATCH** for the Deep Wizard (`CostEntryWizard.vue`).
**Rationale:**
1. The existing `ComplexExpenseModal` is tightly coupled to the "quick entry" paradigm — single amount, single category, no supplier.
2. Adding supplier + invoice number + VAT + line items would require a complete form rewrite anyway.
3. The Quick Actions (Fuel, Parking, Other) are **perfect as-is** for the "Dual-Entry" left side. They should remain untouched.
4. The Deep Wizard should be a **new, separate component** (`CostEntryWizard.vue`) with multi-step or multi-section layout:
- **Step 1:** Vehicle + Supplier + Invoice metadata
- **Step 2:** Line items (category, net, VAT, gross)
- **Step 3:** Document upload + Review
---
## 4. Proposed Architecture for Dual-Entry Cost System
```
┌─────────────────────────────────────────────────────┐
│ CostsActionsCard.vue (EXISTING) │
│ │
│ ┌──────────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ ⛽ Fuel │ │ 🅿️ Park │ │ Other │ │
│ │ (Quick) │ │ (Quick) │ │ (Quick) │ │
│ └──────────────┘ └──────────┘ └───────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 📄 Invoice / Számla (Deep Wizard) [NEW] │ │
│ │ → Opens CostEntryWizard.vue │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
### New Component: `CostEntryWizard.vue`
- **Location:** `frontend/src/components/cost/CostEntryWizard.vue`
- **Trigger:** New "📄 Invoice" button on `CostsActionsCard`
- **Features:**
- Supplier autocomplete (from `/providers` API)
- Invoice number + invoice date
- Net amount + VAT amount + Gross amount (auto-calc)
- Multi-line item support (dynamic rows)
- Category cascade (reuse from `ComplexExpenseModal`)
- Document upload (PDF/image)
- Payment due date
- Notes field
---
## 5. Summary
| Aspect | Finding |
|--------|---------|
| **Quick Actions** | ✅ Fully functional for Fuel, Parking/Toll, Other costs |
| **Deep Invoice Entry** | ❌ Does not exist — must be built as `CostEntryWizard.vue` |
| **Reusable Form Parts** | ⚠️ Category cascade logic can be extracted from `ComplexExpenseModal` |
| **Supplier Data** | ✅ Provider API exists (`/providers`) — can be reused |
| **Backend Readiness** | ⚠️ Need to verify if `asset_costs` table supports `supplier_id`, `invoice_number`, `vat_amount`, `net_amount` fields |

271
docs/dashboard_ui_audit.md Normal file
View File

@@ -0,0 +1,271 @@
# P0 Audit: Private Dashboard (`/dashboard`) UI & State Architecture
**Date:** 2026-06-21
**Scope:** Read-only audit of routing, components, store, and navigation paths
**Auditor:** Fast Coder (Core Developer)
---
## 1. Routing Architecture
**Router file:** [`frontend/src/router/index.ts`](frontend/src/router/index.ts)
The `/dashboard` route is a **child route** under the [`PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue) wrapper:
| Path | Name | Component | Auth |
|------|------|-----------|------|
| `/dashboard` | `dashboard` | [`DashboardView.vue`](frontend/src/views/DashboardView.vue) | `requiresAuth` |
| `/dashboard/vehicles` | `FleetView` | [`FleetView.vue`](frontend/src/views/vehicles/FleetView.vue) | `requiresAuth` |
| `/dashboard/vehicles/:id` | `VehicleDetails` | [`VehicleDetailsView.vue`](frontend/src/views/vehicles/VehicleDetailsView.vue) | `requiresAuth` |
| `/dashboard/service-finder` | `service-finder` | [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue) | `requiresAuth` |
| `/dashboard/subscription` | `subscription` | [`SubscriptionPlansView.vue`](frontend/src/views/SubscriptionPlansView.vue) | `requiresAuth` |
**Corporate mirror routes** exist under `/organization/:id/` with identical child structure.
---
## 2. DashboardView.vue — Main Entry Point
**File:** [`frontend/src/views/DashboardView.vue`](frontend/src/views/DashboardView.vue)
### Data Fetching (Lifecycle)
```typescript
onMounted(() => {
vehicleStore.fetchVehicles() // GET /assets/vehicles
authStore.fetchMyOrganizations() // GET /organizations/my
})
```
- **`fetchVehicles()`** is called unconditionally on mount.
- **`fetchMyOrganizations()`** loads the user's org list for corporate mode detection.
- No cost/financial data is fetched at the dashboard level — costs are lazy-loaded per-vehicle in the detail modal.
### 5-Card Grid Layout
The dashboard renders 5 cards in a `grid-cols-1 md:grid-cols-3 lg:grid-cols-5` layout:
| # | Card Component | File | Events |
|---|---------------|------|--------|
| 1 | `MyVehiclesCard` | [`frontend/src/components/dashboard/MyVehiclesCard.vue`](frontend/src/components/dashboard/MyVehiclesCard.vue) | `@open-card` |
| 2 | `CostsActionsCard` | [`frontend/src/components/dashboard/CostsActionsCard.vue`](frontend/src/components/dashboard/CostsActionsCard.vue) | `@cost-saved` |
| 3 | `ServiceFinderCard` | [`frontend/src/components/dashboard/ServiceFinderCard.vue`](frontend/src/components/dashboard/ServiceFinderCard.vue) | — |
| 4 | `GamificationCard` | [`frontend/src/components/dashboard/GamificationCard.vue`](frontend/src/components/dashboard/GamificationCard.vue) | `@open-card` |
| 5 | `ProfileTrustCard` | [`frontend/src/components/dashboard/ProfileTrustCard.vue`](frontend/src/components/dashboard/ProfileTrustCard.vue) | `@open-card` |
### Modal System
The DashboardView manages **3 modal layers**:
1. **Flip Modal** (`activeCard`): A 3D-flip overlay that shows `PrivateVehicleManager` when `activeCard === 'vehicles'`.
2. **VehicleDetailModal**: Deep-link modal with 4 tabs (Basics, Finance, Alerts, ServiceBook).
3. **VehicleFormModal**: Add/Edit vehicle form (standalone, decoupled from the manager).
---
## 3. MyVehiclesCard — The Garage Tile
**File:** [`frontend/src/components/dashboard/MyVehiclesCard.vue`](frontend/src/components/dashboard/MyVehiclesCard.vue)
### Data Source
```typescript
const displayVehicles = computed(() => vehicleStore.sortedVehicles)
```
Uses the Pinia store's `sortedVehicles` getter directly. Shows max **3 vehicles** (`.slice(0, 3)`).
### Vehicle Rendering
Each vehicle is rendered via [`VehicleCardCompact`](frontend/src/components/vehicle/VehicleCardCompact.vue) — a minimal row component showing:
- `license_plate` (via `VehiclePlateBadge`)
- `brand` / `model` / `nickname`
### Click Actions
- **Card click** → `navigateToFleet()``/dashboard/vehicles` (or `/organization/:id/vehicles`)
- **Plate click** → `openVehicleDetail(vehicle)``/dashboard/vehicles/:id` (or `/organization/:id/vehicles/:id`)
---
## 4. VehicleCardStandard — Full Tile Component
**File:** [`frontend/src/components/vehicle/VehicleCardStandard.vue`](frontend/src/components/vehicle/VehicleCardStandard.vue)
### Props Used (template bindings)
| Template Expression | Backend `AssetResponse` Field | Status |
|---|---|---|
| `vehicle.image` | ❌ **NOT in AssetResponse** | ⚠️ **BROKEN**`AssetResponse` has no `image` field |
| `vehicle.brand` | ✅ `brand` | OK |
| `vehicle.nickname` | ❌ **NOT in AssetResponse** | ⚠️ **BROKEN** — No `nickname` field in schema |
| `vehicle.license_plate` | ✅ `license_plate` | OK |
| `vehicle.countryCode` / `vehicle.country_code` | ❌ **NOT in AssetResponse** | ⚠️ **BROKEN** — No country code field |
| `vehicle.model` | ✅ `model` | OK |
| `vehicle.year_of_manufacture` / `vehicle.year` | ✅ `year_of_manufacture` | OK |
| `vehicle.current_mileage` / `vehicle.mileage` | ✅ `current_mileage` | OK |
| `vehicle.engine` / `vehicle.fuel_type` | ❌ `engine` **NOT in AssetResponse** | ⚠️ **BROKEN**`engine` is a display-only field; `fuel_type` is OK |
| `vehicle.individual_equipment?.color` / `vehicle.color` | ❌ `color` **NOT in AssetResponse** | ⚠️ **BROKEN**`color` is stored in `individual_equipment` JSONB, but the root `color` field doesn't exist |
| `vehicle.nextService` | ❌ **NOT in AssetResponse** | ⚠️ **BROKEN** — No `nextService` field |
### Critical Broken Bindings Summary
| Field | Used In | Backend Reality |
|-------|---------|-----------------|
| `vehicle.image` | CardStandard (image area) | Not in `AssetResponse` — always shows placeholder |
| `vehicle.nickname` | CardStandard, CardCompact | Not in `AssetResponse` — always null |
| `vehicle.countryCode` / `country_code` | CardStandard, CardCompact, DetailModal | Not in `AssetResponse` — plate badge shows no country |
| `vehicle.engine` | CardStandard (engine row) | Not in `AssetResponse` — falls back to `fuel_type` |
| `vehicle.color` | CardStandard (color row) | Not in `AssetResponse` — stored in `individual_equipment.color` |
| `vehicle.nextService` | CardStandard (service row) | Not in `AssetResponse` — always shows "—" |
| `vehicle.mileage` (deprecated) | CardStandard, DetailModal | Falls back to `current_mileage` — OK but deprecated |
---
## 5. Pinia Store Audit
**File:** [`frontend/src/stores/vehicle.ts`](frontend/src/stores/vehicle.ts)
### API Endpoint
```typescript
const res = await api.get('/assets/vehicles')
```
**Correct endpoint** — matches backend `GET /api/v1/assets/vehicles`.
### Vehicle Interface vs Backend Schema
The frontend `Vehicle` interface (line 10-109) is **mostly aligned** with the backend [`AssetResponse`](backend/app/schemas/asset.py:32). However:
| Frontend `Vehicle` Field | Backend `AssetResponse` | Match |
|---|---|---|
| `id: string` | `id: UUID` | ✅ |
| `vin` | `vin` | ✅ |
| `license_plate` | `license_plate` | ✅ |
| `name` | `name` | ✅ |
| `catalog_id` | `catalog_id` | ✅ |
| `vehicle_class` | `vehicle_class` | ✅ |
| `brand` | `brand` | ✅ |
| `model` | `model` | ✅ |
| `trim_level` | `trim_level` | ✅ |
| `fuel_type` | `fuel_type` | ✅ |
| `engine_capacity` | `engine_capacity` | ✅ |
| `power_kw` | `power_kw` | ✅ |
| `torque_nm` | `torque_nm` | ✅ |
| `cylinder_layout` | `cylinder_layout` | ✅ |
| `transmission_type` | `transmission_type` | ✅ |
| `drive_type` | `drive_type` | ✅ |
| `euro_classification` | `euro_classification` | ✅ |
| `curb_weight` | `curb_weight` | ✅ |
| `max_weight` | `max_weight` | ✅ |
| `door_count` | `door_count` | ✅ |
| `seat_count` | `seat_count` | ✅ |
| `current_mileage` | `current_mileage` | ✅ |
| `condition_score` | `condition_score` | ✅ |
| `status` | `status` | ✅ |
| `is_verified` | `is_verified` | ✅ |
| `year_of_manufacture` | `year_of_manufacture` | ✅ |
| `created_at` | `created_at` | ✅ |
| `updated_at` | `updated_at` | ✅ |
| `is_under_warranty` | `is_under_warranty` | ✅ |
| `warranty_expiry_date` | `warranty_expiry_date` | ✅ |
| `registration_certificate_number` | `registration_certificate_number` | ✅ |
| `registration_certificate_validity` | `registration_certificate_validity` | ✅ |
| `vehicle_registration_document_number` | `vehicle_registration_document_number` | ✅ |
| `registration_country` | `registration_country` | ✅ |
| `first_domestic_registration_date` | `first_domestic_registration_date` | ✅ |
| `import_country` | `import_country` | ✅ |
| `title_document_number` | `title_document_number` | ✅ |
| `engine_number` | `engine_number` | ✅ |
| `number_of_previous_owners` | `number_of_previous_owners` | ✅ |
| `is_for_sale` | `is_for_sale` | ✅ |
| `price` | `price` | ✅ |
| `currency` | `currency` | ✅ |
| `is_primary` | `is_primary` | ✅ |
| `current_organization_id` | `current_organization_id` | ✅ |
| `owner_organization_id` | `owner_organization_id` | ✅ |
| `individual_equipment` | `individual_equipment` | ✅ |
**The store interface is well-aligned with the backend.** The broken bindings are in the **display components** (`VehicleCardStandard`, `VehicleCardCompact`) which reference fields that exist in the `VehicleData` type (a superset with mock-data fallbacks) but not in the actual backend response.
---
## 6. Navigation / Drill-Down Paths
### Path 1: Dashboard → Fleet View
```
/dashboard → click card → /dashboard/vehicles (FleetView.vue)
```
FleetView renders all vehicles in a `BaseCard` grid. Clicking a card navigates to:
```
/dashboard/vehicles/:id (VehicleDetailsView.vue)
```
### Path 2: Dashboard → Vehicle Detail Modal (in-page)
```
/dashboard → plate-click → VehicleDetailModal (Teleport)
```
This is an **in-page modal**, not a route change. The modal has 4 tabs:
- **Alapadatok** (Basics) — reads from `AssetResponse` directly
- **Pénzügyek (TCO)** — lazy-fetches `GET /assets/{id}/costs`
- **Figyelmeztetések** — reads from `individual_equipment` JSONB
- **Szervizkönyv** — lazy-fetches `GET /assets/{id}/events`
### Path 3: Dashboard → Flip Modal → PrivateVehicleManager
```
/dashboard → "My Vehicles" card click → Flip Modal → PrivateVehicleManager
```
The PrivateVehicleManager shows `VehicleCardStandard` tiles in a carousel. Clicking a tile opens the same `VehicleDetailModal`.
### Path 4: Corporate Mode
```
/organization/:id → /organization/:id/vehicles/:vehicleId
```
All navigation functions in `MyVehiclesCard`, `FleetView`, and `PrivateVehicleManager` check `authStore.isCorporateMode` and route accordingly.
---
## 7. Broken Bindings & Immediate Fixes Required
### 🔴 CRITICAL (Data never renders)
| # | Component | Field | Impact |
|---|-----------|-------|--------|
| 1 | `VehicleCardStandard` | `vehicle.image` | Image area always shows placeholder SVG |
| 2 | `VehicleCardStandard` | `vehicle.nickname` | Nickname section always empty |
| 3 | `VehicleCardStandard` | `vehicle.countryCode` / `country_code` | Plate badge has no country flag |
| 4 | `VehicleCardStandard` | `vehicle.engine` | Engine row falls back to `fuel_type` (acceptable but not ideal) |
| 5 | `VehicleCardStandard` | `vehicle.color` | Color row always shows "—" |
| 6 | `VehicleCardStandard` | `vehicle.nextService` | Service row always shows "—" |
### 🟡 MODERATE (Fallback works but data is incomplete)
| # | Component | Field | Impact |
|---|-----------|-------|--------|
| 7 | `VehicleCardStandard` | `vehicle.mileage` (deprecated) | Falls back to `current_mileage` — OK |
| 8 | `VehicleDetailModal` | `vehicle.body_type` | Falls back to `individual_equipment.car_specs.body_type` — OK |
### 🟢 GREEN (Fully functional)
- All fields in the Pinia store `Vehicle` interface match the backend `AssetResponse`
- The `fetchVehicles()` endpoint (`GET /assets/vehicles`) is correct
- The `fetchAssetFinancials()` endpoint (`GET /assets/vehicles/{id}/financials`) is correct
- Cost fetching (`GET /assets/{id}/costs`) is correct
- Event fetching (`GET /assets/{id}/events`) is correct
- Navigation paths correctly distinguish private vs corporate mode
---
## 8. Recommendations
1. **Add missing fields to `AssetResponse`** (or use `individual_equipment` JSONB):
- `nickname` → add to `AssetResponse` as an optional string
- `country_code` → already in `individual_equipment`, expose at root level
- `next_service_mileage` → computed field or stored in `individual_equipment`
2. **Fix `VehicleCardStandard` to use `individual_equipment.color`** instead of `vehicle.color`:
```typescript
vehicle.individual_equipment?.color || vehicle.color || '—'
```
This already works for the color field (line 103) but `vehicle.color` is never set by the API.
3. **Remove `vehicle.image` from `VehicleCardStandard`** or add an image URL field to the backend schema if vehicle images are planned.
4. **Add `country_code` to `AssetResponse`** at the root level so the plate badge can display country flags without digging into JSONB.
5. **Consider adding a `next_service` computed field** to the backend `AssetResponse` or the store getter, derived from `current_mileage` + service interval.

View File

@@ -0,0 +1,227 @@
# P0 DEEP AUDIT - Database Consistency & Zombie API Hunt Report
**Date:** 2026-06-21
**Author:** Fast Coder (Core Developer)
**Gitea Issue:** #274
**Scope:** Database schemas `vehicle`, `finance`, `fleet_finance` + Backend API endpoints
---
## 1. Database Cleanliness
### 1.1 Schema: `vehicle` (20 tables)
| Table | Purpose | Status |
|-------|---------|--------|
| `assets` | Core vehicle assets (thick model) | ✅ Clean |
| `asset_events` | Service/maintenance events | ✅ Clean |
| `asset_inspections` | Inspection records | ✅ Clean |
| `asset_reviews` | User reviews | ✅ Clean |
| `asset_telemetry` | Telemetry data | ✅ Clean |
| `auto_data_crawler_queue` | Crawler queue | ✅ Clean |
| `catalog_discovery` | Catalog discovery queue | ✅ Clean |
| `dict_body_types` | Body type dictionary | ✅ Clean |
| `external_reference_library` | External reference data | ✅ Clean |
| `feature_definitions` | Feature definitions | ✅ Clean |
| `gb_catalog_discovery` | GB catalog discovery | ✅ Clean |
| `model_feature_maps` | Model-feature mappings | ✅ Clean |
| `motorcycle_specs` | Motorcycle specs | ✅ Clean |
| `odometer_readings` | Odometer readings | ✅ Clean |
| `reference_lookup` | Reference lookup | ✅ Clean |
| `vehicle_catalog` | Vehicle catalog | ✅ Clean |
| `vehicle_logbook` | Trip logbook | ✅ Clean |
| `vehicle_model_definitions` | Master model definitions | ✅ Clean |
| `vehicle_ownership_history` | Ownership history | ✅ Clean |
| `vehicle_transfer_requests` | Transfer requests | ✅ Clean |
| `vehicle_types` | Vehicle type dictionary | ✅ Clean |
| `vehicle_user_ratings` | User ratings | ✅ Clean |
### 1.2 Schema: `fleet_finance` (6 tables)
| Table | Purpose | Status |
|-------|---------|--------|
| `asset_costs` | Cost ledger (moved from `vehicle`) | ✅ Clean |
| `asset_financials` | Purchase/financing data (moved from `vehicle`) | ✅ Clean |
| `cost_categories` | Cost category hierarchy (moved from `vehicle`) | ✅ Clean |
| `insurance_providers` | Insurance provider catalog | ✅ Clean |
| `vehicle_insurance_policies` | Vehicle insurance policies | ✅ Clean |
| `vehicle_tax_obligations` | Vehicle tax obligations | ✅ Clean |
### 1.3 Schema: `finance` (7 tables)
| Table | Purpose | Status |
|-------|---------|--------|
| `credit_logs` | Credit transactions | ✅ Clean |
| `exchange_rates` | Currency exchange rates | ✅ Clean |
| `issuers` | Billing entities | ✅ Clean |
| `org_subscriptions` | Organization subscriptions | ✅ Clean |
| `payment_intents` | Payment intents | ✅ Clean |
| `user_subscriptions` | User subscriptions | ✅ Clean |
| `withdrawal_requests` | Withdrawal requests | ✅ Clean |
### 1.4 Critical Checks
| Check | Result |
|-------|--------|
| `vehicle.vehicle_expenses` exists? | ❌ **NOT FOUND** - Correctly removed |
| `vehicle.costs` (VehicleCost) exists? | ❌ **NOT FOUND** - Correctly removed |
| `data` schema has leftover tables? | ❌ **NOT FOUND** - Schema is empty |
| Duplicate table names between `vehicle` and `fleet_finance`? | ❌ **NONE** - No overlap |
| Legacy `expense`/`cost` tables anywhere? | ❌ **NONE** - Clean |
**Verdict: ✅ DATABASE IS CLEAN.** No duplicated tables, no legacy tables left behind. The migration of `AssetCost`, `CostCategory`, `AssetFinancials` from `vehicle``fleet_finance` schema is complete.
---
## 2. Zombie API Endpoints
### 2.1 🔴 CRITICAL: [`reports.py`](backend/app/api/v1/endpoints/reports.py) - References `vehicle.vehicle_expenses`
**File:** [`backend/app/api/v1/endpoints/reports.py`](backend/app/api/v1/endpoints/reports.py:18)
Two endpoints use raw SQL queries referencing the **non-existent** `vehicle.vehicle_expenses` table:
1. **`GET /reports/summary/{vehicle_id}`** (line 8-32) - Queries `FROM vehicle.vehicle_expenses`
2. **`GET /reports/trends/{vehicle_id}`** (line 34-50) - Queries `FROM vehicle.vehicle_expenses`
**Impact:** These endpoints will **ALWAYS return 500 errors** because the table `vehicle.vehicle_expenses` does not exist in the database.
**Router registration:** [`api.py`](backend/app/api/v1/api.py:33) - Registered as `prefix="/reports"`
### 2.2 🟡 WARNING: [`expenses.py`](backend/app/api/v1/endpoints/expenses.py) - Uses `AssetCost` from `fleet_finance` (correct)
**File:** [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py:10)
Imports `AssetCost` and `CostCategory` from `app.models` which correctly resolves to `fleet_finance` schema models. **This is NOT a zombie** - it's correctly using the moved models.
### 2.3 🟡 WARNING: [`assets.py`](backend/app/api/v1/endpoints/assets.py) - Uses `AssetCost` from `fleet_finance` (correct)
**File:** [`backend/app/api/v1/endpoints/assets.py`](backend/app/api/v1/endpoints/assets.py:14-15)
Imports `AssetCost`, `CostCategory` from `app.models` and `AssetFinancials`, `VehicleInsurancePolicy`, `VehicleTaxObligation` from `app.models.fleet_finance`. **Correctly references the new schema.**
### 2.4 🟢 OK: [`dictionaries.py`](backend/app/api/v1/endpoints/dictionaries.py) - Uses `CostCategory` from `fleet_finance`
**File:** [`backend/app/api/v1/endpoints/dictionaries.py`](backend/app/api/v1/endpoints/dictionaries.py:13)
Imports `CostCategory` from `app.models.fleet_finance`. **Correct.**
### 2.5 Zombie API Summary
| Endpoint File | Route Prefix | Status | Issue |
|---------------|-------------|--------|-------|
| `reports.py` | `/reports/summary/{id}` | 🔴 **ZOMBIE** | References `vehicle.vehicle_expenses` (DNE) |
| `reports.py` | `/reports/trends/{id}` | 🔴 **ZOMBIE** | References `vehicle.vehicle_expenses` (DNE) |
| `reports.py` | `/reports/summary/latest` | 🟢 OK | Returns mock data only |
| `expenses.py` | `/expenses/{asset_id}` | 🟢 OK | Uses `AssetCost` from `fleet_finance` |
| `assets.py` | `/assets/vehicles/{id}/costs` | 🟢 OK | Uses `AssetCost` from `fleet_finance` |
| `assets.py` | `/assets/vehicles/{id}/insurance` | 🟢 OK | Uses `VehicleInsurancePolicy` |
| `assets.py` | `/assets/vehicles/{id}/tax` | 🟢 OK | Uses `VehicleTaxObligation` |
| `dictionaries.py` | `/dictionaries/cost-categories` | 🟢 OK | Uses `CostCategory` from `fleet_finance` |
---
## 3. InsuranceProvider & Missing CRUD Analysis
### 3.1 InsuranceProvider Model
The [`InsuranceProvider`](backend/app/models/fleet_finance/models.py:180) model exists in `fleet_finance.insurance_providers` with fields:
- `id` (Integer, PK)
- `name` (String, unique)
- `claim_phone` (String, nullable)
- `claim_url` (String, nullable)
- `services_offered` (JSONB)
- `is_active` (Boolean)
### 3.2 Existing Insurance/Tax Endpoints in [`assets.py`](backend/app/api/v1/endpoints/assets.py)
| Method | Route | Exists? |
|--------|-------|---------|
| `GET` | `/assets/vehicles/{asset_id}/insurance` | ✅ Yes (list) |
| `POST` | `/assets/vehicles/{asset_id}/insurance` | ✅ Yes (create) |
| `PUT` | `/assets/vehicles/{asset_id}/insurance/{policy_id}` | ❌ **MISSING** |
| `DELETE` | `/assets/vehicles/{asset_id}/insurance/{policy_id}` | ❌ **MISSING** |
| `GET` | `/assets/vehicles/{asset_id}/tax` | ✅ Yes (list) |
| `POST` | `/assets/vehicles/{asset_id}/tax` | ✅ Yes (create) |
| `PUT` | `/assets/vehicles/{asset_id}/tax/{tax_id}` | ❌ **MISSING** |
| `DELETE` | `/assets/vehicles/{asset_id}/tax/{tax_id}` | ❌ **MISSING** |
### 3.3 InsuranceProvider CRUD (Standalone)
| Method | Route | Exists? |
|--------|-------|---------|
| `GET` | `/insurance-providers` | ❌ **MISSING** |
| `GET` | `/insurance-providers/{id}` | ❌ **MISSING** |
| `POST` | `/insurance-providers` | ❌ **MISSING** |
| `PUT` | `/insurance-providers/{id}` | ❌ **MISSING** |
| `DELETE` | `/insurance-providers/{id}` | ❌ **MISSING** |
**No schema exists for InsuranceProvider** - searched `backend/app/schemas/` for `InsuranceProvider` patterns - **0 results**.
---
## 4. Action Plan
### 4.1 🔴 P0 - Fix Zombie Endpoints in `reports.py`
**Problem:** Two endpoints in [`reports.py`](backend/app/api/v1/endpoints/reports.py) reference the non-existent `vehicle.vehicle_expenses` table.
**Solution:** Rewrite the raw SQL queries to use the new `fleet_finance.asset_costs` table with proper joins to `cost_categories`:
```python
# Replace raw SQL with SQLAlchemy ORM queries using AssetCost + CostCategory
from app.models import AssetCost, CostCategory
from sqlalchemy import select, func
# GET /reports/summary/{vehicle_id}
stmt = select(
CostCategory.code.label("category"),
func.sum(AssetCost.amount_gross).label("total_amount"),
func.count(AssetCost.id).label("transaction_count")
).outerjoin(CostCategory, AssetCost.category_id == CostCategory.id
).where(AssetCost.asset_id == vehicle_id
).group_by(CostCategory.code)
```
**Estimated effort:** 30 minutes
### 4.2 🟡 P1 - Add UPDATE/DELETE for Insurance Policies and Tax Obligations
**Missing endpoints in [`assets.py`](backend/app/api/v1/endpoints/assets.py):**
1. `PUT /assets/vehicles/{asset_id}/insurance/{policy_id}` - Update insurance policy
2. `DELETE /assets/vehicles/{asset_id}/insurance/{policy_id}` - Delete insurance policy
3. `PUT /assets/vehicles/{asset_id}/tax/{tax_id}` - Update tax obligation
4. `DELETE /assets/vehicles/{asset_id}/tax/{tax_id}` - Delete tax obligation
**Estimated effort:** 1 hour
### 4.3 🟡 P1 - Create InsuranceProvider CRUD Endpoints
**Missing:** Complete CRUD for standalone `InsuranceProvider` catalog:
1. Create schema: `InsuranceProviderResponse`, `InsuranceProviderCreate`, `InsuranceProviderUpdate`
2. Create new endpoint file or add to existing router
3. Endpoints: `GET /insurance-providers`, `GET /insurance-providers/{id}`, `POST /insurance-providers`, `PUT /insurance-providers/{id}`, `DELETE /insurance-providers/{id}`
**Estimated effort:** 1.5 hours
### 4.4 🟢 P2 - Add InsuranceProvider to Admin UI
Once the API endpoints exist, add the InsuranceProvider management to the admin panel.
**Estimated effort:** 1 hour
---
## 5. Summary
| Category | Status |
|----------|--------|
| **Database Cleanliness** | ✅ **PASS** - No duplicates, no legacy tables |
| **Zombie API Endpoints** | 🔴 **2 CRITICAL** - `reports.py` references `vehicle.vehicle_expenses` |
| **InsuranceProvider CRUD** | ❌ **FULLY MISSING** - No standalone endpoints |
| **Insurance Policy UPDATE/DELETE** | ❌ **MISSING** - Only GET and POST exist |
| **Tax Obligation UPDATE/DELETE** | ❌ **MISSING** - Only GET and POST exist |
**Total estimated fix effort:** ~4 hours

774
docs/db_schema_audit.md Normal file
View File

@@ -0,0 +1,774 @@
# 🏗️ P0 DEEP ARCHITECTURE AUDIT — Teljes Adatbázis Séma Audit
**Dátum:** 2026-06-20
**Hatáskör:** Teljes SQLAlchemy modellréteg (`backend/app/models/`)
**Elemzett fájlok:** 26 modellfájl
**Feltárt kapcsolatok:** 95 ForeignKey, 65 relationship() deklaráció
**JSONB mezők:** 56 darab
---
## Tartalomjegyzék
1. [Séma Áttekintés](#1-séma-áttekintés)
2. [Teljes Táblalista Sémánként](#2-teljes-táblalista-sémánként)
3. [Kapcsolati Térkép (Entity-Relationship)](#3-kapcsolati-térkép-entity-relationship)
4. [Központi Entitás: Asset (Digital Twin)](#4-központi-entitás-asset-digital-twin)
5. [JSONB Mezők Teljes Feltárása](#5-jsonb-mezők-teljes-feltárása)
6. [Redundancia Analízis](#6-redundancia-analízis)
7. [Hiányosság Elemzés — Admin, Biztosítás, Adó](#7-hiányosság-elemzés)
8. [Architekturális Javaslat — Admin & Pénzügyi Adatok Tárolása](#8-architekturális-javaslat)
9. [Függelék: Teljes FK-relationship Mátrix](#9-függelék-teljes-fk-relationship-mátrix)
---
## 1. Séma Áttekintés
Az adatbázis **9 PostgreSQL sémára** van bontva, Domain-Driven Design (DDD) elvek szerint:
| Séma | Táblák száma | Elsődleges Domain |
|------|-------------|-------------------|
| `vehicle` | ~28 | Járművek, katalógus, költségek, események |
| `identity` | 10 | Személyek, felhasználók, tárcák, eszközök |
| `fleet` | 7 | Szervezetek, tagok, telephelyek, asset-hozzárendelés |
| `system` | 14 | Rendszerparaméterek, dokumentumok, értesítések, címek |
| `marketplace` | 10 | Szolgáltatók, szakértői címkék, felfedezés |
| `finance` | 7 | Kibocsátók, fizetési szándékok, kivételi kérelmek |
| `audit` | 4 | Biztonsági napló, műveleti napló, főkönyv |
| `gamification` | 9 | Pontok, szintek, jelvények, szezonok |
| `marketing` | 7 | Kampányok, kreatívok, elhelyezések, hirdetések |
| **ÖSSZESEN** | **~96 tábla** | |
---
## 2. Teljes Táblalista Sémánként
### 2.1 `vehicle` séma (~28 tábla)
| Tábla | Modell | Fájl | Leírás |
|-------|--------|------|--------|
| `vehicle.vehicle_types` | `VehicleType` | `vehicle_definitions.py:17` | Jármű kategóriák JSONB `units`-szal |
| `vehicle.feature_definitions` | `FeatureDefinition` | `vehicle_definitions.py:33` | Felszereltség jegyzék |
| `vehicle.vehicle_model_definitions` | `VehicleModelDefinition` | `vehicle_definitions.py:48` | **MDM Master** — 60+ mező, 4 JSONB, UniqueConstraint 7 mezőn |
| `vehicle.model_feature_maps` | `ModelFeatureMap` | `vehicle_definitions.py:148` | M:N modell↔felszereltség |
| `vehicle.dict_body_types` | `BodyTypeDictionary` | `vehicle_definitions.py:162` | Karosszéria típusok i18n-nel |
| `vehicle.vehicle_catalog` | `AssetCatalog` | `asset.py:14` | Katalógus bejegyzés, JSONB `factory_data` |
| `vehicle.assets` | `Asset` | `asset.py:69` | **Digital Twin** — 84 oszlop, központi entitás |
| `vehicle.asset_financials` | `AssetFinancials` | `asset.py:246` | Beszerzési/amortizációs adatok |
| `vehicle.asset_costs` | `AssetCost` | `asset.py:271` | TCO költségek |
| `vehicle.vehicle_logbook` | `VehicleLogbook` | `asset.py:312` | NAV menetlevél GPS-szel |
| `vehicle.asset_inspections` | `AssetInspection` | `asset.py:341` | Napi ellenőrző lista |
| `vehicle.asset_reviews` | `AssetReview` | `asset.py:357` | Jármű értékelések |
| `vehicle.vehicle_ownership_history` | `VehicleOwnership` | `asset.py:373` | Tulajdonosváltozások |
| `vehicle.asset_telemetry` | `AssetTelemetry` | `asset.py:389` | Telemetria (1:1) |
| `vehicle.odometer_readings` | `OdometerReading` | `asset.py:398` | Kilométeróra állások időrendben |
| `vehicle.asset_assignments` | `AssetAssignment` | `asset.py:415` | Asset↔Organization kapcsolat |
| `vehicle.asset_events` | `AssetEvent` | `asset.py:447` | **Digitális Szervizkönyv** |
| `vehicle.exchange_rates` | `ExchangeRate` | `asset.py:492` | Árfolyamok |
| `vehicle.catalog_discovery` | `CatalogDiscovery` | `asset.py:499` | Robot felfedező munkaterület |
| `vehicle.vehicle_expenses` | `VehicleExpenses` | `asset.py:528` | Költség reporting |
| `vehicle.vehicle_transfer_requests` | `VehicleTransferRequest` | `asset.py:546` | Asset átadás-átvétel workflow |
| `vehicle.cost_categories` | `CostCategory` | `vehicle.py:19` | Hierarchikus költségkategóriák |
| `vehicle.costs` | `VehicleCost` | `vehicle.py:64` | **⚠️ VMD-hez kötve, NEM Asset-hez!** |
| `vehicle.vehicle_user_ratings` | `VehicleUserRating` | `vehicle.py:112` | 4-dimenziós felhasználói értékelés |
| `vehicle.gb_catalog_discovery` | `GbCatalogDiscovery` | `vehicle.py:168` | GB piaci felfedező robot |
| `vehicle.motorcycle_specs` | `MotorcycleSpecs` | `motorcycle_specs.py:6` | Motor technikai adatok JSONB-ben |
| `vehicle.external_reference_library` | `ExternalReferenceLibrary` | `external_reference.py:6` | Külső referencia könyvtár |
| `vehicle.external_reference_queue` | `ExternalReferenceQueue` | `external_reference_queue.py:6` | Külső referencia várólista |
| `vehicle.reference_lookup` | `ReferenceLookup` | `reference_data.py` | Referencia kereső JSONB-vel |
### 2.2 `identity` séma (10 tábla)
| Tábla | Modell | Fájl | Leírás |
|-------|--------|------|--------|
| `identity.persons` | `Person` | `identity.py:37` | **DNS** — valós személy, soha nem törölhető |
| `identity.users` | `User` | `identity.py:126` | Login entitás, GDPR törölhető |
| `identity.wallets` | `Wallet` | `identity.py:223` | Triple Wallet |
| `identity.verification_tokens` | `VerificationToken` | `identity.py:244` | Jóváhagyó tokenek |
| `identity.social_accounts` | `SocialAccount` | `identity.py:258` | Közösségi fiókok |
| `identity.active_vouchers` | `ActiveVoucher` | `identity.py:276` | Aktív utalványok |
| `identity.user_trust_profiles` | `UserTrustProfile` | `identity.py:290` | Gondos Gazda Index |
| `identity.one_time_passwords` | `OneTimePassword` | `identity.py:321` | Egyszeri jelszavak |
| `identity.devices` | `Device` | `identity.py:340` | Eszköz fingerprint |
| `identity.user_device_links` | `UserDeviceLink` | `identity.py:355` | User↔Device kapcsolat |
### 2.3 `fleet` séma (7 tábla)
| Tábla | Modell | Fájl | Leírás |
|-------|--------|------|--------|
| `fleet.organizations` | `Organization` | `organization.py:70` | **Központi szervezet** — lifecycle |
| `fleet.org_roles` | `OrgRole` | `organization.py:39` | Dinamikus RBAC JSONB-vel |
| `fleet.organization_financials` | `OrganizationFinancials` | `organization.py:215` | Éves pénzügyi adatok |
| `fleet.organization_members` | `OrganizationMember` | `organization.py:230` | Tagok (user/person/invited_email) |
| `fleet.organization_sales_assignments` | `OrganizationSalesAssignment` | `organization.py:268` | Értékesítési hozzárendelés |
| `fleet.branches` | `Branch` | `organization.py:279` | Telephelyek PostGIS-szel |
| `fleet.asset_assignments` | `AssetAssignment` | `asset.py:415` | Asset-szervezet kapcsolat |
### 2.4 `system` séma (14 tábla)
| Tábla | Modell | Fájl | Leírás |
|-------|--------|------|--------|
| `system.system_parameters` | `SystemParameter` | `system.py:19` | Hierarchikus paraméterek |
| `system.internal_notifications` | `InternalNotification` | `system.py:40` | Értesítések |
| `system.system_data_completion_weights` | `SystemDataCompletionWeight` | `system.py:62` | Kitöltöttségi súlyok |
| `system.system_service_staging` | `SystemServiceStaging` | `system.py:85` | Szolgáltatás staging |
| `system.documents` | `Document` | `document.py:11` | NAS-alapú doksi metatároló |
| `system.legal_documents` | `LegalDocument` | `legal.py:9` | Jogi dokumentumok |
| `system.legal_acceptances` | `LegalAcceptance` | `legal.py:24` | Jogi elfogadások |
| `system.translations` | `Translation` | `translation.py:8` | Fordítások |
| `system.geo_postal_codes` | `GeoPostalCode` | `address.py:12` | Irányítószám kereső |
| `system.geo_streets` | `GeoStreet` | `address.py:22` | Utca regiszter |
| `system.geo_street_types` | `GeoStreetType` | `address.py:31` | Utca típusok |
| `system.addresses` | `Address` | `address.py:39` | Univerzális cím GPS-szel |
| `system.pending_actions` | `PendingAction` | `security.py:22` | **Sentinel** — 4 szem elv |
| `system.subscription_tiers` | `SubscriptionTier` | `core_logic.py:12` | Előfizetési csomagok |
### 2.5 `marketplace` séma (10 tábla)
| Tábla | Modell | Fájl | Leírás |
|-------|--------|------|--------|
| `marketplace.service_profiles` | `ServiceProfile` | `service.py:21` | Szolgáltatói profilok PostGIS-szel |
| `marketplace.expertise_tags` | `ExpertiseTag` | `service.py:79` | **4-szintű hierarchia** |
| `marketplace.service_expertises` | `ServiceExpertise` | `service.py:142` | M:N profil↔tag |
| `marketplace.service_staging` | `ServiceStaging` | `service.py:158` | Szolgáltatás staging |
| `marketplace.discovery_parameters` | `DiscoveryParameter` | `service.py:184` | Felfedezési paraméterek |
| `marketplace.costs` | `Cost` | `service.py:196` | Költségek |
| `marketplace.service_specialties` | `ServiceSpecialty` | `core_logic.py:88` | Önhivatkozó hierarchia |
| `marketplace.service_providers` | `ServiceProvider` | `social.py:22` | Közösségi szolgáltatók |
| `marketplace.ratings` | `Rating` | `address.py:77` | Univerzális értékelés |
| `marketplace.service_reviews` | `ServiceReview` | `social.py:86` | Ellenőrzött értékelések |
| `marketplace.votes` | `Vote` | `social.py:46` | Közösségi szavazatok |
### 2.6 `finance` séma (7 tábla)
| Tábla | Modell | Fájl | Leírás |
|-------|--------|------|--------|
| `finance.issuers` | `Issuer` | `finance.py:27` | Számlakibocsátók |
| `finance.payment_intents` | `PaymentIntent` | `payment.py:30` | **Double Lock** |
| `finance.withdrawal_requests` | `WithdrawalRequest` | `payment.py:147` | Kivételi kérelmek |
| `finance.org_subscriptions` | `OrganizationSubscription` | `core_logic.py:25` | Szervezeti előfizetés |
| `finance.user_subscriptions` | `UserSubscription` | `core_logic.py:49` | Felhasználói előfizetés |
| `finance.credit_logs` | `CreditTransaction` | `core_logic.py:72` | Kredit napló |
| `finance.exchange_rates` | `ExchangeRate` | `asset.py:492` | Árfolyamok |
### 2.7 `audit` séma (4 tábla)
| Tábla | Modell | Fájl | Leírás |
|-------|--------|------|--------|
| `audit.security_audit_logs` | `SecurityAuditLog` | `audit.py:12` | Biztonsági események |
| `audit.operational_logs` | `OperationalLog` | `audit.py:29` | Napi műveletek |
| `audit.process_logs` | `ProcessLog` | `audit.py:43` | Robot naplók |
| `audit.financial_ledger` | `FinancialLedger` | `audit.py:78` | **Központi főkönyv** |
### 2.8 `gamification` séma (9 tábla)
| Tábla | Modell | Fájl | Leírás |
|-------|--------|------|--------|
| `gamification.point_rules` | `PointRule` | `gamification.py:13` | Akció→pont szabályok |
| `gamification.level_configs` | `LevelConfig` | `gamification.py:23` | Szintküszöbök |
| `gamification.points_ledger` | `PointsLedger` | `gamification.py:33` | XP/büntetés tranzakciók |
| `gamification.user_stats` | `UserStats` | `gamification.py:49` | Aggregált statok |
| `gamification.badges` | `Badge` | `gamification.py:71` | Jelvények |
| `gamification.user_badges` | `UserBadge` | `gamification.py:80` | M:N user↔jelvény |
| `gamification.user_contributions` | `UserContribution` | `gamification.py:95` | Hozzájárulások |
| `gamification.seasons` | `Season` | `gamification.py:136` | Szezonok |
| `gamification.seasonal_competitions` | `SeasonalCompetitions` | `gamification.py:149` | Versenyek |
### 2.9 `marketing` séma (7 tábla)
| Tábla | Modell | Fájl | Leírás |
|-------|--------|------|--------|
| `marketing.campaigns` | `Campaign` | `marketing.py:65` | Kampányok JSONB célzással |
| `marketing.creatives` | `Creative` | `marketing.py:126` | Kreatív anyagok |
| `marketing.placements` | `Placement` | `marketing.py:174` | Hirdetési felületek |
| `marketing.campaign_creatives` | `CampaignCreative` | `marketing.py:204` | M:N kampány↔kreatív |
| `marketing.campaign_placements` | `CampaignPlacement` | `marketing.py:226` | M:N kampány↔elhelyezés |
| `marketing.ad_impressions` | `AdImpression` | `marketing.py:247` | Hirdetési megjelenések |
| `marketing.ad_clicks` | `AdClick` | `marketing.py:276` | Hirdetési kattintások |
---
## 3. Kapcsolati Térkép (Entity-Relationship)
### 3.1 Vizuális Összefoglaló
```mermaid
erDiagram
ASSET ||--o{ ASSET_COST : van
ASSET ||--o{ ASSET_EVENT : van
ASSET ||--o{ VEHICLE_LOGBOOK : van
ASSET ||--o{ ASSET_INSPECTION : van
ASSET ||--o{ ASSET_REVIEW : van
ASSET ||--o{ ODOMETER_READING : van
ASSET ||--o{ VEHICLE_OWNERSHIP : van
ASSET ||--o{ ASSET_ASSIGNMENT : van
ASSET ||--o{ SERVICE_REQUEST : van
ASSET ||--o{ VEHICLE_TRANSFER : van
ASSET ||--|| ASSET_FINANCIALS : 1:1
ASSET ||--|| ASSET_TELEMETRY : 1:1
ASSET ||--o| ASSET_CATALOG : katalogus
PERSON ||--o{ USER : fiók
PERSON ||--o{ ORGANIZATION : tulajdonol
USER ||--|| WALLET : 1:1
USER ||--|| USER_TRUST_PROFILE : 1:1
USER ||--|| USER_STATS : 1:1
ORGANIZATION ||--o{ MEMBER : tagok
ORGANIZATION ||--o{ BRANCH : telephely
ORGANIZATION ||--o{ ASSET_ASSIGNMENT : jarmuvek
ORGANIZATION ||--o| SERVICE_PROFILE : szolgaltato
VMD ||--o{ ASSET_CATALOG : varians
VMD ||--o{ VEHICLE_COST : koltseg
VMD ||--o{ VEHICLE_RATING : ertekeles
```
### 3.2 Kritikus Kapcsolatok
#### ✳️ FIGYELEM: `VehicleCost` eltérő referenciája
A [`VehicleCost`](backend/app/models/vehicle/vehicle.py:64) `vehicle_id` mezeje a [`VehicleModelDefinition`](backend/app/models/vehicle/vehicle_definitions.py:48)-re mutat, **NEM** az [`Asset`](backend/app/models/vehicle/asset.py:69)-re! Ez architekturális anomália: a költségek a modelldefinícióhoz vannak kötve, nem a konkrét járműpéldányhoz.
#### ✳️ Többféle cost modell
- [`VehicleCost`](backend/app/models/vehicle/vehicle.py:64) — VMD-hez kötött
- [`AssetCost`](backend/app/models/vehicle/asset.py:271) — Asset-hez kötött (TCO)
- [`VehicleExpenses`](backend/app/models/vehicle/asset.py:528) — Asset-hez kötött (expense)
- [`Cost`](backend/app/models/marketplace/service.py:196) — MarketPlace költség
#### ✳️ Person-User Dual Entity
- [`Person`](backend/app/models/identity/identity.py:37) — DNS, halhatatlan, `merged_into_id` self-ref
- [`User`](backend/app/models/identity/identity.py:126) — Login entitás, törölhető
- `OrganizationMember` mindkettőhöz kötődhet
#### ✳️ Önhivatkozó hierarchiák (7 darab)
1. [`CostCategory`](backend/app/models/vehicle/vehicle.py:19) — `parent_id` self
2. [`ExpertiseTag`](backend/app/models/marketplace/service.py:79) — `parent_id` + `path` (4 szint)
3. [`ServiceSpecialty`](backend/app/models/core_logic.py:88) — `parent_id` self
4. [`User.referred_by_id`](backend/app/models/identity/identity.py:149) — self
5. [`User.current_sales_agent_id`](backend/app/models/identity/identity.py:150) — self
6. [`Person.merged_into_id`](backend/app/models/identity/identity.py:76) — self
7. [`ServiceProfile.parent_id`](backend/app/models/marketplace/service.py:31) — self
---
## 4. Központi Entitás: Asset (Digital Twin)
Az [`Asset`](backend/app/models/vehicle/asset.py:69) a rendszer központi entitása, **84 oszloppal** és **12+ kapcsolattal**.
### 4.1 Asset mezőstruktúra
```yaml
# === AZONOSÍTÓ ÉS KATALÓGUS ===
id: UUID (PK) | catalog_id: FK vezerel.vehicle_catalog.id | name: String
# === JÁRMŰ SPECIFIKUS (DENORMALIZÁLT) ===
vin: String(17) | license_plate: String(20) | make: String(100)
model: String(255) | year: Integer | variant: String(100)
fuel_type: String(50) | engine_size: Integer | color: String(50)
seats: Integer | body_type: String(50) | transmission_type: String(30)
drivetrain: String(30) | mileage_unit: String(10)
# === MŰSZAKI ADATOK ===
power_hp: Integer | power_kw: Integer | torque_nm: Integer
co2_emission: Integer | euro_class: String(10) | emission_class: String(10)
# === FELSZERELTSÉG ===
audio_system_type: String(100)
individual_equipment: JSONB # ← CRITICAL: is_primary itt tárolva!
# === REGISZTRÁCIÓS DOKUMENTUMOK (RÉSZLEGES) ===
registration_certificate_number: String(50)
registration_certificate_validity: DateTime
vehicle_registration_document_number: String(50)
# === SZERVEZET ÉS LOKÁCIÓ ===
current_organization_id: FK | branch_id: FK | relocation_performed: Boolean
# === TULAJDONOS ÉS ÜZEMELTETŐ ===
owner_person_id: FK | owner_org_id: FK
operator_person_id: FK | operator_org_id: FK
# === ÁLLAPOT ===
status: String(30) | data_status: String(20) | current_mileage: Integer
condition_score: Integer(0-100)
# === IDŐBÉLYEGEK ===
created_at | updated_at | first_registration_date | purchase_date
warranty_expiry | last_technical_inspection | next_inspection_date
```
### 4.2 Asset kapcsolati kép
```
Asset
├──1:1──► AssetFinancials (pénzügy)
├──1:N──► AssetCost (TCO költségek)
├──1:N──► AssetEvent (Digitális Szervizkönyv)
├──1:N──► VehicleLogbook (NAV menetlevél)
├──1:N──► AssetInspection (napi ellenőrzés)
├──1:N──► AssetReview (értékelések)
├──1:1──► AssetTelemetry
├──1:N──► OdometerReading (óraállás)
├──1:N──► VehicleOwnership (tulajdonosváltozás)
├──1:N──► AssetAssignment (→Organization)
├──1:N──► ServiceRequest
├──1:N──► VehicleTransferRequest
├──N:1──► AssetCatalog
├──N:1──► Organization (current)
├──N:1──► Branch
├──N:1──► Person (owner/operator)
└──N:1──► Organization (owner/operator)
```
---
## 5. JSONB Mezők Teljes Feltárása
Összesen **56 JSONB mező** található. Alább a kritikusak.
### 5.1 `Asset.individual_equipment` — KRITIKUS
**Deklaráció:** [`asset.py:114`](backend/app/models/vehicle/asset.py:114)
```python
individual_equipment: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
```
**Jelenlegi tartalom:**
```json
{
"is_primary": true|false,
// + extra felszerelések
}
```
**Probléma:** Az `is_primary` logikai mező JSONB-ben van elrejtve, lekérdezhetetlen index nélkül. ORM szinten computed property-ként van kezelve (lásd [`asset.py:183-198`](backend/app/models/vehicle/asset.py:183)).
### 5.2 Többi Kritikus JSONB Mező
| Modell | Mező | Cél |
|--------|------|-----|
| `AssetCatalog` | `factory_data` | Gyári adatok |
| `AssetFinancials` | `accounting_details` | Számviteli részletek |
| `AssetCost` | `data` | Költség metaadatok |
| `VehicleModelDefinition` | `marketing_name_aliases` | Alternatív nevek |
| `VehicleModelDefinition` | `raw_api_data` | API nyers válasz |
| `VehicleModelDefinition` | `research_metadata` | Kutatási adatok |
| `VehicleModelDefinition` | `specifications` | Specifikációk |
| `Person` | `identity_docs` | Személyi docs |
| `Person` | `ice_contact` | Vészhelyzeti kapcsolat |
| `User` | `visual_settings` | UI beállítások |
| `User` | `custom_permissions` | Egyedi jogok |
| `User` | `alternative_emails` | Alternatív emailek |
| `User` | `email_history` | Email változások |
| `Organization` | `visual_settings` | Cég UI beállítások |
| `Organization` | `settings` | Cég beállítások |
| `Organization` | `aliases` | Alias nevek |
| `Organization` | `tags` | Címkék |
| `Organization` | `notification_settings` | Értesítési beállítások |
| `Organization` | `external_integration_config` | Külső integráció |
| `ServiceProfile` | `vibe_analysis` | AI hangulatelemzés |
| `ServiceProfile` | `social_links` | Közösségi linkek |
| `ServiceProfile` | `specialization_tags` | Specializációk |
| `ServiceProfile` | `verification_log` | Ellenőrzési napló |
| `ServiceProfile` | `opening_hours` | Nyitvatartás |
| `ExpertiseTag` | `name_translations` | Többnyelvű név |
| `ExpertiseTag` | `search_keywords` | Kulcsszavak |
| `OrgRole` | `permissions` | Capability flag-ek |
| `Branch` | `opening_hours` | Nyitvatartás |
| `Rating` | `images` | Képek array |
| `Campaign` | `targeting` | Célzás |
| `AssetInspection` | `checklist_results` | Ellenőrző lista |
| `PendingAction` | `payload` | Műveleti adatok |
| `SubscriptionTier` | `rules` | Csomag szabályok |
| `OrganizationSubscription` | `extra_allowances` | **Booster architektúra** |
| `SystemParameter` | `value` | Paraméter érték |
| `InternalNotification` | `data` | Értesítési adatok |
| `SystemServiceStaging` | `raw_data` | Nyers staging adat |
| `FinancialLedger` | `details` | Tranzakció részletek |
---
## 6. Redundancia Analízis
### 6.1 MAGAS KOCKÁZATÚ
#### 1. Dupla Költség Rendszer
| Tábla | Referencia | Cél |
|-------|-----------|-----|
| `VehicleCost` | vehicle_id → VMD.id | ❌ Anomália |
| `AssetCost` | asset_id → Asset.id | ✅ Helyes |
| `VehicleExpenses` | vehicle_id → Asset.id | ✅ Helyes |
**Kockázat:** `VehicleCost` a modell-definícióhoz köt, nem a konkrét járműhöz.
#### 2. Háromszoros kilométeróra tárolás
- `Asset.current_mileage` — denormalizált utolsó érték
- `OdometerReading.reading` — időbeli adatsor
- `AssetTelemetry.current_mileage` — harmadik helyen!
#### 3. Asset mezők duplikációja
`make`, `model`, `year`, `variant`, `fuel_type` szerepel:
- `Asset`-ben (denormalizált)
- `VehicleModelDefinition`-ben (eredeti)
- `AssetCatalog`-ban (katalógus)
#### 4. Cím adatok duplikációja
`Branch` denormalizált címmezőket tartalmaz `Address` mellett.
### 6.2 KÖZEPES KOCKÁZATÚ
#### 5. Kettős tulajdonos mezők
- `Organization.owner_id` → users (technikai, törölhető)
- `Organization.legal_owner_id` → persons (jogi, halhatatlan)
Szándékos (Dual Entity), de potenciális konfúzió.
#### 6. Subscription mezők duplikációja
`Organization` denormalizáltan tárolja a `subscription_plan`, `base_asset_limit`, `purchased_extra_slots` mezőket, melyek az `OrganizationSubscription`-ban is szerepelnek.
### 6.3 ALACSONY KOCKÁZAT (szándékos)
- `owner_person_id` + `owner_org_id` — B2B/B2C miatt
- Triple Wallet mezők — szándékos
---
## 7. Hiányosság Elemzés
### 7.1 Jelenlegi Állapot
| Domain | Státusz | Megjegyzés |
|--------|---------|------------|
| **Regisztrációs doksik** | ⚠️ Részleges | 3 mező az Asset-en |
| **Műszaki vizsga** | ⚠️ Részleges | 2 mező (last/next) |
| **Első forgalomba helyezés** | ✅ Van | `first_registration_date` |
| **KGFB biztosítás** | ❌ HIÁNYZIK | Nincs adatmodell |
| **Casco biztosítás** | ❌ HIÁNYZIK | Nincs adatmodell |
| **Súlyadó** | ❌ HIÁNYZIK | Nincs adatmodell |
| **Cégautó adó** | ❌ HIÁNYZIK | Nincs adatmodell |
| **Forgalmi engedély** | ⚠️ Részleges | Csak szám + érvényesség |
| **Törzskönyv** | ⚠️ Részleges | Csak szám |
| **Import megkülönböztetés** | ❌ HIÁNYZIK | Nincs |
| **Expected/Recurring költségek** | ❌ HIÁNYZIK | Nincs |
### 7.2 Specifikus Hiányzó Mezők
#### Biztosítás (teljesen hiányzik)
```
insurance_type: KGFB/CASCO/BOTH
insurer_name, policy_number, contract_date
start_date, expiry_date
premium_amount, premium_currency
bonus_malus_level (0-20)
deductible_amount, is_active, status, notes
```
#### Adók (teljesen hiányzik)
```
tax_type: WEIGHT_TAX/COMPANY_CAR_TAX/PERFORMANCE_TAX
tax_year, tax_amount, tax_currency
payment_date, payment_status
tax_authority, tax_period_start/end, notes
```
#### Admin (részleges)
```
MEGLÉVŐ: registration_certificate_number, registration_certificate_validity, vehicle_registration_document_number
HIÁNYZÓ: registration_type (FIRST_HU/FIRST_FOREIGN/IMPORT_USED)
first_hungarian_registration_date, first_foreign_registration_date
title_deed_number, title_deed_issue_date
engine_number, number_of_previous_owners
original_plate_number, import_country
```
#### Ütemezett költségek (teljesen hiányzik)
```
expected_cost_type: INSURANCE/TAX/SERVICE/INSPECTION/TIRE_CHANGE/OTHER
expected_amount, currency, frequency (MONTHLY/QUARTERLY/YEARLY/ONE_TIME)
next_due_date, last_paid_date, is_recurring
category_id FK, description, reminder_days_before
```
---
## 8. Architekturális Javaslat
### 8.1 Ajánlott: Dedikált táblák (előnyben részesített)
```sql
-- Biztosítási kötvények
vehicle.vehicle_insurance_policies
id: UUID PK
asset_id: UUID FK -> vehicle.assets.id
insurance_type: ENUM(KGFB, CASCO, BOTH)
insurer_name, policy_number, contract_date, start_date, expiry_date
premium_amount, premium_currency, bonus_malus_level
deductible_amount, status, data: JSONB
-- Adókötelezettségek
vehicle.vehicle_tax_obligations
id: UUID PK
asset_id: UUID FK -> vehicle.assets.id
tax_type: ENUM(WEIGHT_TAX, COMPANY_CAR_TAX, PERFORMANCE_TAX)
tax_year, tax_amount, tax_currency
payment_date, payment_status, tax_period_start/end
data: JSONB
-- Ütemezett költségek
vehicle.vehicle_expected_costs
id: UUID PK
asset_id: UUID FK -> vehicle.assets.id
cost_type: ENUM(INSURANCE, TAX, SERVICE, INSPECTION, TIRE_CHANGE, OTHER)
expected_amount, currency, frequency
next_due_date, last_paid_date, is_recurring
category_id FK -> cost_categories.id
description, reminder_days_before, status, data: JSONB
-- Regisztrációs dokumentumok (kibővítve)
vehicle.vehicle_registration_documents
id: UUID PK
asset_id: UUID FK -> vehicle.assets.id UNIQUE
registration_type: ENUM(FIRST_HU, FIRST_FOREIGN, IMPORT_USED)
first_hungarian_registration_date, first_foreign_registration_date
title_deed_number, title_deed_issue_date
engine_number, number_of_previous_owners
original_plate_number, import_country, data: JSONB
```
### 8.2 Alternatíva: Meglévő modellek bővítése (gyorsabb)
#### `Asset` bővítése:
```python
first_hungarian_registration_date: Mapped[Optional[date]]
first_foreign_registration_date: Mapped[Optional[date]]
import_country: Mapped[Optional[str]]
title_deed_number: Mapped[Optional[str]]
engine_number: Mapped[Optional[str]]
number_of_previous_owners: Mapped[Optional[int]]
```
#### `AssetFinancials` bővítése:
```python
insurance_kgfb_policy_number: Mapped[Optional[str]]
insurance_kgfb_expiry: Mapped[Optional[date]]
insurance_kgfb_premium: Mapped[Optional[float]]
insurance_casco_policy_number: Mapped[Optional[str]]
insurance_casco_expiry: Mapped[Optional[date]]
insurance_casco_premium: Mapped[Optional[float]]
bonus_malus_level: Mapped[Optional[int]]
```
### 8.3 Ajánlott Megvalósítási Sorrend
1. **P1**`VehicleInsurancePolicy` új modell (dedikált tábla)
2. **P1**`VehicleTaxObligation` új modell (dedikált tábla)
3. **P2**`VehicleExpectedCost` új modell (ütemezett költségek)
4. **P2**`VehicleRegistrationDocument` új modell (admin adatok elkülönítése)
5. **P3**`individual_equipment.is_primary` kivezetése dedikált boolean oszlopba
---
## 9. Függelék: Teljes FK-relationship Mátrix
### 9.1 Minden ForeignKey
```
=== VEHICLE SÉMA ===
AssetCatalog.master_definition_id -> vehicle.vehicle_model_definitions.id
Asset.catalog_id -> vehicle.vehicle_catalog.id
Asset.current_organization_id -> fleet.organizations.id
Asset.branch_id -> fleet.branches.id
Asset.owner_person_id -> identity.persons.id
Asset.owner_org_id -> fleet.organizations.id
Asset.operator_person_id -> identity.persons.id
Asset.operator_org_id -> fleet.organizations.id
AssetFinancials.asset_id -> vehicle.assets.id (UNIQUE)
AssetCost.asset_id -> vehicle.assets.id
AssetCost.organization_id -> fleet.organizations.id
AssetCost.category_id -> vehicle.cost_categories.id
AssetCost.document_id -> system.documents.id
AssetCost.linked_asset_event_id -> vehicle.asset_events.id
VehicleLogbook.asset_id -> vehicle.assets.id
VehicleLogbook.driver_id -> identity.users.id
AssetInspection.asset_id -> vehicle.assets.id
AssetInspection.inspector_id -> identity.users.id
AssetReview.asset_id -> vehicle.assets.id
AssetReview.user_id -> identity.users.id
VehicleOwnership.asset_id -> vehicle.assets.id
VehicleOwnership.user_id -> identity.users.id
AssetTelemetry.asset_id -> vehicle.assets.id (UNIQUE)
OdometerReading.asset_id -> vehicle.assets.id
OdometerReading.cost_id -> vehicle.asset_costs.id
AssetAssignment.asset_id -> vehicle.assets.id
AssetAssignment.organization_id -> fleet.organizations.id
AssetEvent.asset_id -> vehicle.assets.id
AssetEvent.user_id -> identity.users.id
AssetEvent.organization_id -> fleet.organizations.id
AssetEvent.cost_id -> vehicle.asset_costs.id
AssetEvent.linked_expense_id -> vehicle.asset_costs.id
VehicleExpenses.vehicle_id -> vehicle.assets.id
VehicleTransferRequest.asset_id -> vehicle.assets.id
VehicleTransferRequest.requester_id -> identity.users.id
VehicleTransferRequest.current_owner_id -> identity.persons.id
VehicleTransferRequest.proof_document_id -> system.documents.id
VehicleCost.vehicle_id -> vehicle.vehicle_model_definitions.id
VehicleCost.organization_id -> fleet.organizations.id
VehicleCost.category_id -> vehicle.cost_categories.id
VehicleUserRating.vehicle_id -> vehicle.vehicle_model_definitions.id
VehicleUserRating.user_id -> identity.users.id
CostCategory.parent_id -> vehicle.cost_categories.id (self-ref)
FeatureDefinition.vehicle_type_id -> vehicle.vehicle_types.id
VehicleModelDefinition.vehicle_type_id -> vehicle.vehicle_types.id
ModelFeatureMap.model_definition_id -> vehicle.vehicle_model_definitions.id
ModelFeatureMap.feature_id -> vehicle.feature_definitions.id
MotorcycleSpecs.(FK) -> vehicle.auto_data_crawler_queue.id
ExternalReferenceLibrary.matched_vmd_id -> vehicle.vehicle_model_definitions.id
=== IDENTITY SÉMA ===
Person.address_id -> system.addresses.id
Person.merged_into_id -> identity.persons.id (self-ref)
Person.user_id (active_user) -> identity.users.id
User.person_id -> identity.persons.id
User.referred_by_id -> identity.users.id (self-ref)
User.current_sales_agent_id -> identity.users.id (self-ref)
Wallet.user_id -> identity.users.id (UNIQUE)
Wallet.organization_id -> fleet.organizations.id
VerificationToken.user_id -> identity.users.id
SocialAccount.user_id -> identity.users.id
ActiveVoucher.wallet_id -> identity.wallets.id
UserTrustProfile.user_id -> identity.users.id (PK)
UserDeviceLink.user_id -> identity.users.id
UserDeviceLink.device_hash -> identity.devices.fingerprint_hash
=== FLEET SÉMA ===
Organization.legal_owner_id -> identity.persons.id
Organization.address_id -> system.addresses.id
Organization.subscription_tier_id -> system.subscription_tiers.id
Organization.owner_id -> identity.users.id
OrganizationFinancials.organization_id -> fleet.organizations.id
OrganizationMember.organization_id -> fleet.organizations.id
OrganizationMember.user_id -> identity.users.id
OrganizationMember.person_id -> identity.persons.id
OrganizationSalesAssignment.organization_id -> fleet.organizations.id
OrganizationSalesAssignment.agent_user_id -> identity.users.id
Branch.organization_id -> fleet.organizations.id
Branch.address_id -> system.addresses.id
Rating.author_id -> identity.users.id
Rating.target_organization_id -> fleet.organizations.id
Rating.target_user_id -> identity.users.id
Rating.target_branch_id -> fleet.branches.id
=== SYSTEM SÉMA ===
Address.postal_code_id -> system.geo_postal_codes.id
GeoStreet.postal_code_id -> system.geo_postal_codes.id
InternalNotification.user_id -> identity.users.id
Document.uploaded_by -> identity.users.id
LegalAcceptance.user_id -> identity.users.id
LegalAcceptance.document_id -> system.legal_documents.id
PendingAction.requester_id -> identity.users.id
PendingAction.approver_id -> identity.users.id
=== MARKETPLACE SÉMA ===
ServiceProfile.organization_id -> fleet.organizations.id (UNIQUE)
ServiceProfile.parent_id -> marketplace.service_profiles.id (self-ref)
ExpertiseTag.parent_id -> marketplace.expertise_tags.id (self-ref)
ExpertiseTag.suggested_by_id -> identity.persons.id
ServiceExpertise.service_id -> marketplace.service_profiles.id
ServiceExpertise.expertise_id -> marketplace.expertise_tags.id
ServiceRequest.user_id -> identity.users.id
ServiceRequest.asset_id -> vehicle.assets.id
ServiceRequest.branch_id -> fleet.branches.id
ServiceProvider.added_by_user_id -> identity.users.id
Vote.user_id -> identity.users.id
Vote.provider_id -> marketplace.service_providers.id
UserScore.user_id -> identity.users.id
UserScore.competition_id -> gamification.competitions.id
ServiceReview.service_id -> marketplace.service_profiles.id
ServiceReview.user_id -> identity.users.id
ServiceReview.transaction_id -> audit.financial_ledger.transaction_id
ServiceSpecialty.parent_id -> marketplace.service_specialties.id (self-ref)
OrganizationSubscription.org_id -> fleet.organizations.id
OrganizationSubscription.tier_id -> system.subscription_tiers.id
UserSubscription.user_id -> identity.users.id
UserSubscription.tier_id -> system.subscription_tiers.id
CreditTransaction.org_id -> fleet.organizations.id
=== FINANCE SÉMA ===
PaymentIntent.payer_id -> identity.users.id
PaymentIntent.beneficiary_id -> identity.users.id
WithdrawalRequest.user_id -> identity.users.id
WithdrawalRequest.approved_by_id -> identity.users.id
=== AUDIT SÉMA ===
SecurityAuditLog.actor_id -> identity.users.id
SecurityAuditLog.target_id -> identity.users.id
SecurityAuditLog.confirmed_by_id -> identity.users.id
OperationalLog.user_id -> identity.users.id
FinancialLedger.user_id -> identity.users.id
FinancialLedger.person_id -> identity.persons.id
FinancialLedger.related_agent_id -> identity.users.id
FinancialLedger.issuer_id -> finance.issuers.id
=== GAMIFICATION SÉMA ===
PointsLedger.user_id -> identity.users.id
UserStats.user_id -> identity.users.id (PK)
UserBadge.user_id -> identity.users.id
UserBadge.badge_id -> gamification.badges.id
UserContribution.user_id -> identity.users.id
UserContribution.season_id -> gamification.seasons.id
UserContribution.reviewed_by -> identity.users.id
SeasonalCompetitions.season_id -> gamification.seasons.id
=== MARKETING SÉMA ===
CampaignCreative.campaign_id -> marketing.campaigns.id
CampaignCreative.creative_id -> marketing.creatives.id
CampaignPlacement.campaign_id -> marketing.campaigns.id
CampaignPlacement.placement_id -> marketing.placements.id
AdImpression.campaign_id -> marketing.campaigns.id
AdImpression.creative_id -> marketing.creatives.id
AdImpression.placement_id -> marketing.placements.id
AdClick.impression_id -> marketing.ad_impressions.id
AdClick.campaign_id -> marketing.campaigns.id
AdClick.creative_id -> marketing.creatives.id
```
### 9.2 Leggyakrabban Hivatkozott Táblák
| Tábla | Hivatkozások | Honnan |
|-------|-------------|--------|
| `identity.users` | **30+** | Minden domainből |
| `vehicle.assets` | **12** | cost, event, logbook, inspection, stb. |
| `fleet.organizations` | **10** | asset, cost, member, branch, stb. |
| `identity.persons` | **6** | user, organization, financial_ledger |
| `vehicle.vehicle_model_definitions` | **4** | catalog, cost, rating |
| `vehicle.cost_categories` | **3** | asset_cost, vehicle_cost |
---
## Összefoglalás
### Főbb Megállapítások
1. **Központi entitás:** [`Asset`](backend/app/models/vehicle/asset.py:69) — Digital Twin, 12+ kapcsolattal
2. **Legkritikusabb redundancia:** `VehicleCost` a VMD-hez köt (NEM az Asset-hez)
3. **Dual Entity:** `Person` (DNS) + `User` (login) sikeresen implementálva
4. **7 önhivatkozó hierarchia** — CostCategory, ExpertiseTag (4 szint), ServiceSpecialty, User (referral, sales), Person (merge), ServiceProfile
5. **56 JSONB mező** — Rugalmasság, de kereshetőséget ront
6. **`individual_equipment.is_primary`** — Logikai mező JSONB-ben elrejtve
### Kritikus Hiányosságok
1.**Biztosítási adatok teljes hiánya** (KGFB, Casco)
2.**Adó adatok teljes hiánya** (súlyadó, cégautó adó)
3.**Első forgalomba helyezés típusának hiánya**
4.**Ütemezett/expected költségek hiánya**
5. ⚠️ **Regisztrációs dokumentumok részlegesek** (3 mező)
6. ⚠️ **Három cost modell párhuzamosan** eltérő referenciákkal
### Ajánlott Következő Lépések
1. **P1:** Dedikált `VehicleInsurancePolicy` és `VehicleTaxObligation` modellek
2. **P1:** `Asset` bővítése hiányzó admin mezőkkel
3. **P2:** `VehicleExpectedCost` modell
4. **P2:** `VehicleRegistrationDocument` modell
5. **P3:** `individual_equipment.is_primary` dedikált boolean oszlopba
6. **P3:** Cost modellek konszolidációja

View File

@@ -0,0 +1,278 @@
# P0 Finance API Gap Analysis Report
**Date:** 2026-06-21
**Author:** Fast Coder (Core Developer)
**Scope:** `fleet_finance` and `finance` schema models vs FastAPI endpoints
**Status:** ✅ Part 1 API Tests Passed (6/6)
---
## 1. Executive Summary
This report audits all SQLAlchemy models in the `fleet_finance` and `finance` schemas against their corresponding FastAPI REST endpoints. The goal is to identify which models have full CRUD coverage, which have partial coverage, and which are completely missing API access.
**Key Finding:** Out of **13 finance-related models** across both schemas, **7 have dedicated API endpoints**, **2 have indirect coverage** (via other endpoints), and **4 have NO API endpoints at all**.
---
## 2. Part 1: API Test Results ✅
All 6 Phase 1 finance API endpoints were tested and passed:
| # | Endpoint | Method | Status | Response |
|---|----------|--------|--------|----------|
| 1 | `/api/v1/assets/vehicles/{asset_id}/financials` | GET | ✅ 200 | Full financial data returned |
| 2 | `/api/v1/assets/vehicles/{asset_id}/financials` | PATCH | ✅ 200 | Partial update applied (monthly_installment: 50000→75000) |
| 3 | `/api/v1/assets/vehicles/{asset_id}/insurance` | GET | ✅ 200 | Empty list (no policies yet) |
| 4 | `/api/v1/assets/vehicles/{asset_id}/insurance` | POST | ✅ 201 | KGFB policy created |
| 5 | `/api/v1/assets/vehicles/{asset_id}/tax` | GET | ✅ 200 | Empty list (no obligations yet) |
| 6 | `/api/v1/assets/vehicles/{asset_id}/tax` | POST | ✅ 201 | WEIGHT_TAX obligation created |
**Test Asset Used:** `7ebedcdf-9316-42a4-a6e0-7ce656668cf9` (TEST-API-999, org 1)
**Auth:** `admin@profibot.hu` (member of org 1 with ADMIN role)
---
## 3. Schema: `fleet_finance` — Model vs API Matrix
| # | Model | Schema | Table | GET | POST | PATCH/PUT | DELETE | CRUD Status |
|---|-------|--------|-------|-----|------|-----------|--------|-------------|
| 1 | **CostCategory** | `fleet_finance` | `cost_categories` | ❌ | ❌ | ❌ | ❌ | **❌ MISSING** |
| 2 | **AssetCost** | `fleet_finance` | `asset_costs` | ✅ | ✅ | ❌ | ❌ | ⚠️ Partial (R+C) |
| 3 | **AssetFinancials** | `fleet_finance` | `asset_financials` | ✅ | ❌ | ✅ | ❌ | ⚠️ Partial (R+U) |
| 4 | **InsuranceProvider** | `fleet_finance` | `insurance_providers` | ❌ | ❌ | ❌ | ❌ | **❌ MISSING** |
| 5 | **VehicleInsurancePolicy** | `fleet_finance` | `vehicle_insurance_policies` | ✅ | ✅ | ❌ | ❌ | ⚠️ Partial (R+C) |
| 6 | **VehicleTaxObligation** | `fleet_finance` | `vehicle_tax_obligations` | ✅ | ✅ | ❌ | ❌ | ⚠️ Partial (R+C) |
### 3.1 Detailed Findings — `fleet_finance`
#### ✅ CostCategory — ❌ NO API Endpoints
- **File:** [`backend/app/models/fleet_finance/models.py`](../backend/app/models/fleet_finance/models.py:35)
- **Purpose:** Hierarchical cost categories (FUEL, MAINTENANCE, REPAIR, etc.)
- **Used indirectly** by `AssetCost` via `category_id` FK
- **No dedicated router** exists for CRUD operations on categories
- **Impact:** Categories must be seeded via DB scripts (see `seed_cost_category_tiers.py`, `seed_cost_category_visibility.py`)
- **Recommendation:** Create a `/api/v1/cost-categories` CRUD endpoint for admin management
#### ✅ AssetCost — ⚠️ Partial (Read + Create only)
- **File:** [`backend/app/models/fleet_finance/models.py`](../backend/app/models/fleet_finance/models.py:96)
- **Endpoints:**
- `GET /api/v1/assets/{asset_id}/costs` — list costs for an asset ✅
- `POST /api/v1/expenses/` — create expense (with Smart Linking to AssetEvent) ✅
- `GET /api/v1/expenses/{asset_id}` — list expenses with category enrichment ✅
- **Missing:** PATCH (update) and DELETE endpoints
- **Recommendation:** Add PATCH/DELETE for expense management
#### ✅ AssetFinancials — ⚠️ Partial (Read + Update only)
- **File:** [`backend/app/models/fleet_finance/models.py`](../backend/app/models/fleet_finance/models.py:141)
- **Endpoints:**
- `GET /api/v1/assets/vehicles/{asset_id}/financials`
- `PATCH /api/v1/assets/vehicles/{asset_id}/financials`
- **Missing:** POST (create) endpoint — PATCH requires pre-existing record
- **Workaround:** Must be created via direct DB insert or admin script
- **Recommendation:** Add a POST endpoint to create AssetFinancials records
#### ✅ InsuranceProvider — ❌ NO API Endpoints
- **File:** [`backend/app/models/fleet_finance/models.py`](../backend/app/models/fleet_finance/models.py:180)
- **Purpose:** Insurance company catalog (name, claim_phone, services_offered)
- **No API endpoints** exist for listing, creating, or managing providers
- **Impact:** Cannot create insurance policies via API without pre-seeding providers
- **Recommendation:** Create a `/api/v1/insurance-providers` CRUD endpoint
#### ✅ VehicleInsurancePolicy — ⚠️ Partial (Read + Create only)
- **File:** [`backend/app/models/fleet_finance/models.py`](../backend/app/models/fleet_finance/models.py:208)
- **Endpoints:**
- `GET /api/v1/assets/vehicles/{asset_id}/insurance`
- `POST /api/v1/assets/vehicles/{asset_id}/insurance`
- **Missing:** PATCH (update policy details), DELETE (cancel policy)
- **Recommendation:** Add PATCH for policy updates (e.g., renewal, premium change)
#### ✅ VehicleTaxObligation — ⚠️ Partial (Read + Create only)
- **File:** [`backend/app/models/fleet_finance/models.py`](../backend/app/models/fleet_finance/models.py:252)
- **Endpoints:**
- `GET /api/v1/assets/vehicles/{asset_id}/tax`
- `POST /api/v1/assets/vehicles/{asset_id}/tax`
- **Missing:** PATCH (update payment status), DELETE
- **Recommendation:** Add PATCH for payment status updates
---
## 4. Schema: `finance` — Model vs API Matrix
| # | Model | Schema | Table | GET | POST | PATCH/PUT | DELETE | CRUD Status |
|---|-------|--------|-------|-----|------|-----------|--------|-------------|
| 1 | **Issuer** | `finance` | `issuers` | ✅ | ❌ | ✅ | ❌ | ⚠️ Partial (R+U) |
| 2 | **PaymentIntent** | `finance` | `payment_intents` | ✅ | ✅ | ❌ | ❌ | ⚠️ Partial (R+C) |
| 3 | **WithdrawalRequest** | `finance` | `withdrawal_requests` | ❌ | ❌ | ❌ | ❌ | **❌ MISSING** |
| 4 | **OrganizationSubscription** | `finance` | `org_subscriptions` | ❌ | ❌ | ❌ | ❌ | **❌ MISSING** |
| 5 | **UserSubscription** | `finance` | `user_subscriptions` | ❌ | ❌ | ❌ | ❌ | **❌ MISSING** |
| 6 | **CreditTransaction** | `finance` | `credit_transactions` | ❌ | ❌ | ❌ | ❌ | **❌ MISSING** |
| 7 | **FinancialLedger** | `audit` | `financial_ledger` | ✅ | ❌ | ❌ | ❌ | ⚠️ Partial (R) |
### 4.1 Detailed Findings — `finance`
#### ✅ Issuer — ⚠️ Partial (Read + Update only, Admin-only)
- **File:** [`backend/app/models/marketplace/finance.py`](../backend/app/models/marketplace/finance.py:27)
- **Endpoints:**
- `GET /api/v1/finance-admin/` — list issuers (admin only) ✅
- `PATCH /api/v1/finance-admin/{issuer_id}` — update issuer (admin only) ✅
- **Missing:** POST (create issuer), public listing
- **Note:** Admin-only access via `check_finance_admin_access` dependency
#### ✅ PaymentIntent — ⚠️ Partial (Read + Create only)
- **File:** [`backend/app/models/marketplace/payment.py`](../backend/app/models/marketplace/payment.py:30)
- **Endpoints:**
- `POST /api/v1/billing/payment-intent/create`
- `POST /api/v1/billing/payment-intent/{id}/stripe-checkout`
- `POST /api/v1/billing/payment-intent/{id}/process-internal`
- `GET /api/v1/billing/payment-intent/{id}/status`
- `POST /api/v1/billing/stripe-webhook`
- **Note:** Well-covered for payment flow; no direct PATCH/DELETE needed
#### ❌ WithdrawalRequest — ❌ NO API Endpoints
- **File:** [`backend/app/models/marketplace/payment.py`](../backend/app/models/marketplace/payment.py:147)
- **Purpose:** Withdrawal requests from Earned wallet (user_id, amount, payout_method, status, approval)
- **Has domain methods:** `approve()`, `reject()`, `cancel()`, `is_expired()`
- **No API endpoints** exist for creating or managing withdrawal requests
- **Impact:** Users cannot request payouts from their Earned wallet
- **Recommendation:** Create `/api/v1/billing/withdrawals` CRUD endpoint
#### ❌ OrganizationSubscription — ❌ NO API Endpoints
- **File:** [`backend/app/models/core_logic.py`](../backend/app/models/core_logic.py:25)
- **Purpose:** Organization subscription plans with extra_allowances JSONB
- **No dedicated API endpoints** — used indirectly via billing upgrade flow
- **Note:** The `POST /api/v1/billing/upgrade` endpoint handles subscription changes but doesn't expose the model directly
#### ❌ UserSubscription — ❌ NO API Endpoints
- **File:** [`backend/app/models/core_logic.py`](../backend/app/models/core_logic.py:49)
- **Purpose:** User-level subscriptions
- **No API endpoints** at all
#### ❌ CreditTransaction — ❌ NO API Endpoints
- **File:** [`backend/app/models/core_logic.py`](../backend/app/models/core_logic.py:72)
- **Purpose:** Credit transaction logs (org_id, amount, description)
- **No API endpoints** at all
- **Note:** Credits are managed internally by the billing engine
#### ✅ FinancialLedger — ⚠️ Partial (Read only)
- **File:** [`backend/app/models/system/audit.py`](../backend/app/models/system/audit.py:78)
- **Endpoints:**
- `GET /api/v1/billing/wallet/transactions` — list transactions with pagination ✅
- **Missing:** No write endpoints (ledger is append-only by design)
- **Note:** Read-only access is correct for an audit log
---
## 5. Gap Summary
### 5.1 Critical Gaps (No API Access)
| Model | Schema | Impact | Priority |
|-------|--------|--------|----------|
| **InsuranceProvider** | `fleet_finance` | Cannot create insurance policies without pre-seeded providers | **HIGH** |
| **CostCategory** | `fleet_finance` | Categories must be managed via DB scripts | **MEDIUM** |
| **WithdrawalRequest** | `finance` | Users cannot request Earned wallet payouts | **HIGH** |
| **OrganizationSubscription** | `finance` | No direct subscription management API | **MEDIUM** |
| **UserSubscription** | `finance` | No direct user subscription API | **LOW** |
| **CreditTransaction** | `finance` | Credits managed internally; read-only may suffice | **LOW** |
### 5.2 Partial Coverage Gaps
| Model | Missing Operations | Impact | Priority |
|-------|-------------------|--------|----------|
| **AssetFinancials** | POST (create) | Must pre-seed via DB before PATCH works | **MEDIUM** |
| **AssetCost** | PATCH, DELETE | Cannot update or remove expenses | **MEDIUM** |
| **VehicleInsurancePolicy** | PATCH, DELETE | Cannot update or cancel policies | **LOW** |
| **VehicleTaxObligation** | PATCH, DELETE | Cannot update payment status | **LOW** |
| **Issuer** | POST (create) | Must be created via DB or admin panel | **LOW** |
---
## 6. Recommendations
### Phase 2 — High Priority
1. **Create `/api/v1/insurance-providers` CRUD endpoint** — enables dynamic provider management
2. **Create `/api/v1/billing/withdrawals` CRUD endpoint** — enables Earned wallet payout requests
3. **Add POST `/api/v1/assets/vehicles/{asset_id}/financials`** — enables creating financial records via API
### Phase 3 — Medium Priority
4. **Create `/api/v1/cost-categories` CRUD endpoint** — enables category management via API
5. **Add PATCH/DELETE to `/api/v1/expenses/{expense_id}`** — enables expense editing
6. **Add subscription management endpoints** — enables direct plan changes
### Phase 4 — Low Priority
7. **Add PATCH to insurance/tax endpoints** — enables status updates
8. **Add Issuer POST endpoint** — enables provider creation via admin panel
---
## 7. Endpoint Inventory (All Finance-Related)
### Router: `assets.py` (prefix: `/api/v1/assets`)
| Method | Path | Model | Status |
|--------|------|-------|--------|
| GET | `/vehicles/{asset_id}/financials` | AssetFinancials | ✅ Tested |
| PATCH | `/vehicles/{asset_id}/financials` | AssetFinancials | ✅ Tested |
| GET | `/vehicles/{asset_id}/insurance` | VehicleInsurancePolicy | ✅ Tested |
| POST | `/vehicles/{asset_id}/insurance` | VehicleInsurancePolicy | ✅ Tested |
| GET | `/vehicles/{asset_id}/tax` | VehicleTaxObligation | ✅ Tested |
| POST | `/vehicles/{asset_id}/tax` | VehicleTaxObligation | ✅ Tested |
| GET | `/{asset_id}/financial-summary` | Asset (aggregated) | Not tested |
| GET | `/{asset_id}/costs` | AssetCost | Not tested |
### Router: `expenses.py` (prefix: `/api/v1/expenses`)
| Method | Path | Model | Status |
|--------|------|-------|--------|
| GET | `/{asset_id}` | AssetCost | Not tested |
| POST | `/` | AssetCost | Not tested |
### Router: `finance_admin.py` (prefix: `/api/v1/finance-admin`)
| Method | Path | Model | Status |
|--------|------|-------|--------|
| GET | `/` | Issuer | Not tested |
| PATCH | `/{issuer_id}` | Issuer | Not tested |
### Router: `billing.py` (prefix: `/api/v1/billing`)
| Method | Path | Model | Status |
|--------|------|-------|--------|
| POST | `/upgrade` | OrganizationSubscription | Not tested |
| POST | `/payment-intent/create` | PaymentIntent | Not tested |
| POST | `/payment-intent/{id}/stripe-checkout` | PaymentIntent | Not tested |
| POST | `/payment-intent/{id}/process-internal` | PaymentIntent | Not tested |
| POST | `/stripe-webhook` | PaymentIntent | Not tested |
| GET | `/payment-intent/{id}/status` | PaymentIntent | Not tested |
| GET | `/wallet/balance` | FinancialLedger (aggregated) | Not tested |
| GET | `/wallet/transactions` | FinancialLedger | Not tested |
---
## 8. Appendix: Schema Details
### `fleet_finance` Schema Tables
| Table | Primary Key | Key Columns |
|-------|-------------|-------------|
| `cost_categories` | `id` (int) | `parent_id`, `code`, `name`, `is_system`, `visibility`, `min_tier` |
| `asset_costs` | `id` (int) | `asset_id`, `organization_id`, `category_id`, `amount_net/gross`, `vat_rate`, `status`, `linked_asset_event_id` |
| `asset_financials` | `id` (int) | `asset_id`, `purchase_price_net/gross`, `financing_type`, `accounting_details` (JSONB), `monthly_installment`, `down_payment` |
| `insurance_providers` | `id` (int) | `name`, `claim_phone`, `claim_url`, `services_offered` (JSONB), `is_active` |
| `vehicle_insurance_policies` | `id` (uuid) | `asset_id`, `provider_id`, `insurance_type`, `policy_number`, `start/expiry_date`, `premium_amount` |
| `vehicle_tax_obligations` | `id` (uuid) | `asset_id`, `tax_type`, `tax_year`, `amount`, `due_date`, `payment_status` |
### `finance` Schema Tables
| Table | Primary Key | Key Columns |
|-------|-------------|-------------|
| `issuers` | `id` (int) | `name`, `tax_id`, `type` (KFT/EV/BT/ZRT/OTHER), `revenue_limit`, `current_revenue`, `api_config` (JSONB) |
| `payment_intents` | `id` (uuid) | `intent_token`, `payer_id`, `beneficiary_id`, `target_wallet_type`, `net/handling/gross` amounts, `status`, `stripe_*` fields |
| `withdrawal_requests` | `id` (uuid) | `user_id`, `amount`, `payout_method`, `status`, `approved_by`, `approved_at`, `rejection_reason` |
| `org_subscriptions` | `id` (int) | `organization_id`, `tier_id`, `start/end_date`, `status`, `extra_allowances` (JSONB) |
| `user_subscriptions` | `id` (int) | `user_id`, `tier_id`, `start/end_date`, `status` |
| `credit_transactions` | `id` (int) | `organization_id`, `amount`, `description`, `created_at` |
### `audit` Schema Tables
| Table | Primary Key | Key Columns |
|-------|-------------|-------------|
| `financial_ledger` | `id` (int) | `user_id`, `amount`, `entry_type` (DEBIT/CREDIT), `wallet_type`, `transaction_id`, `status`, `issuer_id`, `invoice_*` fields |
---
*Report generated by Fast Coder (Core Developer) — 2026-06-21*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
# 🏗️ P0 Architect Report: Backend API & Validation Audit — Vehicle Management & Finance
**Dátum:** 2026-06-21
**Auditor:** Rendszer-Architect
**Státusz:** Read-Only Audit (Kódmódosítás nélkül)
**Scope:** Vehicle CRUD, Pydantic Schemas, Fleet Finance Endpoints
---
## Executive Summary
Két kritikus gapet azonosítottam a Backend API és a Database Modellek között:
### 🔴 Critical Gap #1 — 6 Hiányzó Internationalizációs Mező a Schemákból és Service Logikából
Az `Asset` modell (backend/app/models/vehicle/asset.py:147) tartalmaz 6 új mezőt (`registration_country`, `first_domestic_registration_date`, `import_country`, `title_document_number`, `engine_number`, `number_of_previous_owners`), amelyek:
- **Hiányoznak** az `AssetCreate` (backend/app/schemas/asset.py:122) és `AssetUpdate` (backend/app/schemas/asset.py:269) Pydantic sémákból
- **Hiányoznak** a `create_or_claim_vehicle()` service metódus `asset_fields` dict-jéből (backend/app/services/asset_service.py:172-224)
- **Hiányoznak** az update logikából (backend/app/services/asset_service.py)
### 🔴 Critical Gap #2 — ZERO API Endpoint a Fleet Finance Modellekhez
A `fleet_finance` séma 4 modellje (`AssetFinancials`, `VehicleInsurancePolicy`, `VehicleTaxObligation`, `InsuranceProvider`) rendelkezik adatbázis táblákkal, de:
- **0 Pydantic schema** létezik hozzájuk (a schemas/finance.py csak `Issuer` sémákat tartalmaz)
- **0 API endpoint** létezik hozzájuk (az endpoints/finance_admin.py csak Issuer managementet tartalmaz)
- `AssetFinancials` csak a `create_or_claim_vehicle()` által kerül inicializálásra (nullákkal és `financing_type="unknown"`), de SOHA nem frissíthető az API-n keresztül
---
## 1. Section: Vehicle Schemas and Endpoints Status
### 1.1 Valódi Végpont Lokáció
⚠️ **Critical finding:** A `endpoints/vehicles.py` fájl NEM a jármű CRUD-ot tartalmazza, hanem kizárólag szociális értékeléseket (`VehicleUserRating`).
A tényleges jármű CRUD a `endpoints/assets.py` fájlban található:
- `POST /api/v1/assets/vehicles``create_or_claim_vehicle()` (backend/app/api/v1/endpoints/assets.py:502)
- `PUT /api/v1/assets/vehicles/{asset_id}``update_vehicle()` (backend/app/api/v1/endpoints/assets.py:606)
- `GET /api/v1/assets/vehicles``get_user_vehicles()` (backend/app/api/v1/endpoints/assets.py:223)
- `GET /api/v1/assets/vehicles/{vehicle_id}``get_vehicle()` (backend/app/api/v1/endpoints/assets.py:439)
### 1.2 Hiányzó Mezők Mátrixa
| Mező | Asset Modell | AssetCreate Schema | AssetUpdate Schema | Service Logika |
|------|-------------|-------------------|-------------------|---------------|
| `registration_country` (ISO 3166-1 alpha-2) | ✅ (line 147) | ❌ | ❌ | ❌ |
| `first_domestic_registration_date` | ✅ (line 151) | ❌ | ❌ | ❌ |
| `import_country` (ISO 3166-1 alpha-2) | ✅ (line 155) | ❌ | ❌ | ❌ |
| `title_document_number` | ✅ (line 159) | ❌ | ❌ | ❌ |
| `engine_number` | ✅ (line 163) | ❌ | ❌ | ❌ |
| `number_of_previous_owners` | ✅ (line 167) | ❌ | ❌ | ❌ |
### 1.3 Meglévő Validátorok Állapota
Az `AssetCreate` séma jelenleg az alábbi validátorokat tartalmazza:
- `empty_str_to_none` — minden string mezőre (backend/app/schemas/asset.py:134)
- `normalize_brand` / `normalize_model` — brand/model normalizálás (backend/app/schemas/asset.py:143)
- `normalize_individual_equipment` — equipment lista normalizálás (backend/app/schemas/asset.py:159)
- `validate_vin_or_plate` — VIN vagy rendszám kötelezőség (backend/app/schemas/asset.py:183)
- `determine_status` — alapértelmezett DRAFT státusz (backend/app/schemas/asset.py:249)
**Hiányzó validátorok az új mezőkhöz:**
- `registration_country`: ISO 3166-1 alpha-2 formátum validátor (2 karakter, nagybetű)
- `import_country`: ISO 3166-1 alpha-2 formátum validátor
- `first_domestic_registration_date`: nem lehet jövőbeli dátum
- `number_of_previous_owners`: >= 0 integer validáció
---
## 2. Section: Financial Endpoints Status
### 2.1 Fleet Finance Modellek — Orphan Analysis
| Modell | DB Tábla Létezik? | Pydantic Schema? | API Endpoint? | API-n elérhető? |
|--------|------------------|-----------------|---------------|-----------------|
| `AssetFinancials` (backend/app/models/fleet_finance/models.py:141) | ✅ | ❌ | ❌ | ❌ (csak create-nél inicializálva) |
| `InsuranceProvider` (backend/app/models/fleet_finance/models.py:180) | ✅ | ❌ | ❌ | ❌ |
| `VehicleInsurancePolicy` (backend/app/models/fleet_finance/models.py:208) | ✅ | ❌ | ❌ | ❌ |
| `VehicleTaxObligation` (backend/app/models/fleet_finance/models.py:252) | ✅ | ❌ | ❌ | ❌ |
### 2.2 AssetFinancials Inicializálás
A `create_or_claim_vehicle()` (backend/app/services/asset_service.py:273) az alábbi módon hozza létre az `AssetFinancials` rekordot:
```
financials = AssetFinancials(
asset_id=asset.id,
purchase_price_net=Decimal("0"),
purchase_price_gross=Decimal("0"),
down_payment=Decimal("0"),
monthly_installment=Decimal("0"),
residual_value=Decimal("0"),
financing_type="unknown"
)
```
Ez a rekord SOHA nem frissíthető az API-n keresztül, mert:
- Nincs `PATCH /assets/{asset_id}/financials` végpont
- Nincs `PUT /assets/{asset_id}/financials` végpont
### 2.3 Meglévő Finance Végpontok
A `endpoints/expenses.py` tartalmazza az `AssetCost` CRUD-ot (költség típusú adatok), de a pénzügyi/finanszírozási adatok teljesen hiányoznak.
A `endpoints/finance_admin.py` csak `Issuer` menedzsmentet tartalmaz (kibocsátók kezelése), semmi köze a fleet finance modelljeihez.
### 2.4 Meglévő Pydantic Schémák a Finance Domainben
- `schemas/finance.py` — csak `IssuerResponse` és `IssuerUpdate`
- `schemas/fleet.py` — csak `EventCreate` és `TCOStats` (minimális)
---
## 3. Section: Frontend Form Data Structure Recommendations
A jelenlegi frontend `VehicleFormModal.vue` formája nem tartalmazza a 6 hiányzó internationalizációs mezőt, és nem kommunikál a fleet finance modellekkel.
### Ajánlott Payload Csoportosítás
Az új frontend form structure az alábbi logikai csoportokra bontható:
**A csoport — Alapadatok (Basic Info)**
- `brand`, `model`, `generation_name`, `trim_level`, `year`
- `license_plate`, `vin`
- `vehicle_class`, `body_type`
**B csoport — Regisztrációs adatok (Registration)**
- `registration_country` (ISO 3166-1 alpha-2 dropdown)
- `first_domestic_registration_date` (datepicker)
- `registration_certificate_number`
- `registration_certificate_validity`
- `vehicle_registration_document_number`
- `title_document_number`
**C csoport — Import adatok (Import)**
- `import_country` (ISO 3166-1 alpha-2 dropdown)
- `engine_number`
**D csoport — Tulajdonlás (Ownership)**
- `number_of_previous_owners`
- `color`, `mileage`, `fuel_type`, `transmission_type`
- `individual_equipment`
**E csoport — Pénzügyi adatok (Financials) — ÚJ endpoint**
- `purchase_price_net` / `purchase_price_gross`
- `down_payment`, `monthly_installment`, `residual_value`
- `financing_type`, `financing_provider`, `contract_number`
- `interest_rate`, `lease_start_date`, `lease_end_date`
**F csoport — Biztosítás és Adó (Insurance & Tax) — ÚJ endpoint**
- `insurance_type`, `policy_number`, `start_date`, `expiry_date`, `premium_amount`, `provider_id`
- `tax_type`, `tax_year`, `amount`, `due_date`, `payment_status`
### API Végpont Javaslatok
A frontend számára az alábbi új REST végpontokra lenne szükség:
1. `PATCH /api/v1/assets/vehicles/{asset_id}/financials` — AssetFinancials frissítése
2. `GET /api/v1/assets/vehicles/{asset_id}/financials` — AssetFinancials lekérése
3. `POST /api/v1/assets/vehicles/{asset_id}/insurance` — Biztosítási kötvény hozzáadása
4. `GET /api/v1/assets/vehicles/{asset_id}/insurance` — Biztosítási kötvények listázása
5. `POST /api/v1/assets/vehicles/{asset_id}/tax` — Adókötelezettség hozzáadása
6. `GET /api/v1/assets/vehicles/{asset_id}/tax` — Adókötelezettségek listázása
---
## 4. Section: Recommended Action Plan
### Phase 1 — Backend (Backend API javítások)
| # | Feladat | Érintett fájl(ok) | Priority |
|---|---------|-------------------|----------|
| 1 | Add 6 hiányzó mezőt az `AssetCreate` és `AssetUpdate` sémákhoz | schemas/asset.py | P0 |
| 2 | Add ISO 3166-1 alpha-2 validátor a `registration_country` és `import_country` mezőkhöz | schemas/asset.py | P0 |
| 3 | Add `first_domestic_registration_date` jövőbeli dátum tiltás | schemas/asset.py | P0 |
| 4 | Egészítsd ki a `create_or_claim_vehicle()` logikát a 6 új mezővel | services/asset_service.py | P0 |
| 5 | Hozd létre az `AssetFinancialsUpdate` Pydantic sémát | schemas/finance.py (új) | P1 |
| 6 | Hozd létre a `VehicleInsurancePolicyCreate/Response` sémákat | schemas/finance.py (új) | P1 |
| 7 | Hozd létre a `VehicleTaxObligationCreate/Response` sémákat | schemas/finance.py (új) | P1 |
| 8 | Implementáld a `PATCH /assets/{asset_id}/financials` végpontot | endpoints/assets.py (új) | P1 |
| 9 | Implementáld a `POST/GET /assets/{asset_id}/insurance` végpontokat | endpoints/assets.py (új) | P1 |
| 10 | Implementáld a `POST/GET /assets/{asset_id}/tax` végpontokat | endpoints/assets.py (új) | P1 |
### Phase 2 — Frontend (UI komponensek bővítése)
| # | Feladat | Érintett komponens | Priority |
|---|---------|-------------------|----------|
| 1 | Bővítsd a `VehicleFormModal.vue` űrlapot a B, C, D csoport mezőivel | VehicleFormModal.vue | P1 |
| 2 | Hozz létre egy új `FinancialsTab.vue` komponenst (E csoport) | (új fájl) | P1 |
| 3 | Hozz létre egy új `InsuranceTaxTab.vue` komponenst (F csoport) | (új fájl) | P1 |
| 4 | Integráld az új tabeket a `VehicleDetailModal.vue` komponensbe | VehicleDetailModal.vue | P1 |
---
## Appendix: Audited Files
| Fájl | Sorok | Tartalom |
|------|-------|----------|
| backend/app/api/v1/endpoints/vehicles.py | 1-143 | ❌ Csak social ratings, NEM vehicle CRUD |
| backend/app/api/v1/endpoints/assets.py | 1-1231 | ✅ Valódi vehicle CRUD |
| backend/app/schemas/asset.py | 1-453 | ✅ AssetCreate, AssetUpdate, AssetResponse |
| backend/app/schemas/vehicle.py | 1-56 | ❌ Csak social rating sémák |
| backend/app/models/vehicle/asset.py | 1-515 | ✅ Asset modell a 6 új mezővel |
| backend/app/services/asset_service.py | 1-802 | ✅ create_or_claim_vehicle (hiányzó 6 mező) |
| backend/app/api/v1/endpoints/finance_admin.py | 1-77 | ❌ Csak Issuer management |
| backend/app/api/v1/endpoints/expenses.py | 1-418 | ✅ AssetCost CRUD (de nem fleet finance) |
| backend/app/schemas/finance.py | 1-43 | ❌ Csak Issuer sémák |
| backend/app/schemas/fleet.py | 1-20 | ❌ Minimális (EventCreate, TCOStats) |
| backend/app/models/fleet_finance/models.py | 1-286 | ✅ 4 fleet finance modell (API nélkül) |
---
*Ez a report egy read-only audit eredménye. Kódmódosítás nem történt. A frontend redesign csak az Architect jóváhagyása után kezdődhet.*

View File

@@ -0,0 +1,189 @@
# 🏛️ P0 AUDIT: Financial Data Modeling - Purchasing, Leasing, Insurance & Taxes
**Dátum:** 2026-06-20
**Szerző:** Rendszer-Architect
**Státusz:** AUDIT COMPLETE (no code changes)
**Hatáskör:** `backend/app/models/vehicle/asset.py`, `backend/app/models/vehicle/vehicle.py`
---
## 1. AssetFinancials Audit
### 1.1 Jelenlegi állapot
A `AssetFinancials` modell a `vehicle.asset_financials` táblában egy 1:1 kapcsolatban áll az `Asset` modelllel (`uselist=False`).
**Meglévő oszlopok:**
| Oszlop | Típus | Leírás |
|--------|-------|--------|
| `id` | `Integer PK` | Elsődleges kulcs |
| `asset_id` | `UUID FK → vehicle.assets.id` | **UNIQUE** - 1:1 kapcsolat |
| `purchase_price_net` | `Numeric(18,2)` | Nettó vételár |
| `purchase_price_gross` | `Numeric(18,2)` | Bruttó vételár |
| `vat_rate` | `Numeric(5,2)` | ÁFA kulcs (alapértelmezett: 27.00) |
| `activation_date` | `DateTime` | Aktiválás dátuma |
| `verified_purchase_date` | `DateTime` | Ellenőrzött vásárlás dátuma |
| `financing_type` | `String(50)` | Finanszírozás típusa (pl. cash, loan, lease, unknown) |
| `accounting_details` | `JSONB` | Könyvelési részletek (catch-all) |
### 1.2 Hiányzó mezők (lízing/hitel finanszírozáshoz)
A következő mezők **hiányoznak** a jelenlegi modellből:
| Hiányzó mező | Típus | Indoklás |
|-------------|-------|----------|
| `down_payment` | `Numeric(18,2)` | Előleg - lízing és hitel esetén kritikus |
| `monthly_installment` | `Numeric(18,2)` | Havi törlesztő részlet |
| `residual_value` | `Numeric(18,2)` | Maradványérték (lízing végén) |
| `contract_number` | `String(100)` | Szerződésszám (hitel/lízing szerződés) |
| `financing_provider` | `String(200)` | Finanszírozó neve (bank, lízingcég) |
| `interest_rate` | `Numeric(5,2)` | Kamatláb (%) |
| `total_contract_value` | `Numeric(18,2)` | Teljes szerződéses érték |
| `lease_start_date` | `DateTime` | Lízing kezdő dátuma |
| `lease_end_date` | `DateTime` | Lízing vége dátuma |
| `payment_frequency` | `String(20)` | Fizetési gyakoriság (monthly/quarterly/yearly) |
| `payment_day` | `Integer` | Fizetési nap a hónapban (1-28) |
### 1.3 Javasolt bővítés
A meglévő `accounting_details` JSONB mező alkalmas lehet könyvelési metaadatok tárolására, de **dedikált oszlopok** szükségesek a lekérdezhetőség és adatintegritás biztosításához.
Javasolt új mezők az AssetFinancials modellhez:
- `down_payment` - Előleg
- `monthly_installment` - Havi törlesztő
- `residual_value` - Maradványérték
- `contract_number` - Szerződésszám
- `financing_provider` - Finanszírozó neve
- `interest_rate` - Kamatláb
- `total_contract_value` - Teljes szerződéses érték
- `lease_start_date` - Lízing kezdete
- `lease_end_date` - Lízing vége
- `payment_frequency` - Fizetési gyakoriság
- `payment_day` - Fizetési nap
---
## 2. Cost & Expense Modellek Összehasonlítása
### 2.1 `VehicleExpenses` - Legacy modell
| Tulajdonság | Érték |
|-------------|-------|
| **Tábla** | `vehicle.vehicle_expenses` |
| **Kategória típusa** | `String(50)` - egyszerű string, nincs FK |
| **ÁFA kezelés** | Nincs |
| **Jóváhagyási workflow** | Nincs |
| **Számla/dokumentum** | Nincs |
| **Kapcsolat AssetEvent-tel** | Nincs |
| **Használat** | Legacy, egyszerű reporting |
### 2.2 `AssetCost` - Elsődleges költségmodell
| Tulajdonság | Érték |
|-------------|-------|
| **Tábla** | `vehicle.asset_costs` |
| **Kategória** | `category_id``vehicle.cost_categories` (FK) |
| **ÁFA kezelés** | `amount_net`, `amount_gross`, `vat_rate` (Gross-First) |
| **Jóváhagyási workflow** | `DRAFT``PENDING_APPROVAL``APPROVED` |
| **Számla/dokumentum** | `invoice_number`, `document_id` |
| **Smart Sync (AssetEvent)** | Bidirectional `linked_asset_event_id` |
| **JSONB adatok** | Extra mezők: `mileage_at_cost`, `description` |
| **Szervezeti kötés** | `organization_id` |
### 2.3 Következtetés
A `VehicleExpenses` egy **legacy, leegyszerűsített modell**, amelyet a jövőben fokozatosan ki kell vezetni. A `AssetCost` a teljes értékű, jóváhagyási workflow-val és Smart Sync-kel rendelkező költségmodell.
---
## 3. Adatbázis Stratégia - Javasolt Rétegek
### 3.1 Rétegdiagram
```
1. réteg: Beszerzés & Finanszírozás (AssetFinancials)
├─ purchase_price_net / purchase_price_gross / vat_rate
├─ down_payment / monthly_installment / residual_value
├─ contract_number / financing_provider / interest_rate
├─ lease_start_date / lease_end_date
└─ payment_frequency / payment_day
2. réteg: Működési költségek (AssetCost)
├─ category_id → CostCategory
├─ amount_net / amount_gross / vat_rate
├─ invoice_number / status
└─ linked_asset_event_id / data JSONB
3. réteg: Biztosítási kötvények (VehicleInsurancePolicy) - ÚJ
├─ policy_number / insurer_name / coverage_type
├─ coverage_limit / deductible
├─ start_date / end_date / renewal_status
└─ premium_amount
4. réteg: Adókötelezettségek (VehicleTaxObligation) - ÚJ
├─ tax_type / authority / assessment_period
├─ tax_amount / due_date
└─ payment_status / exemption_status
```
### 3.2 Stratégia összefoglalása
**1. réteg - `AssetFinancials` bővítése:**
- Egyszeri beszerzési adatok: vételár, áfa, aktiválás dátuma
- Finanszírozási adatok: előleg, havi törlesztő, maradványérték, futamidő, kamatláb, szerződésszám
- 1:1 kapcsolatban az Asset-tel
**2. réteg - `AssetCost` (már létezik, változtatás nélkül):**
- Minden operatív költség itt landol: szerviz, üzemanyag, biztosítási díj befizetések, adó befizetések
- Jóváhagyási workflow (DRAFT → APPROVED)
**3. réteg - `VehicleInsurancePolicy` (ÚJ modell javasolt):**
- A biztosítási kötvény metaadatai
- A díj befizetések az AssetCost-ban rögzítésre kerülnek
**4. réteg - `VehicleTaxObligation` (ÚJ modell javasolt):**
- Az adókötelezettség metaadatai
- A díj befizetések az AssetCost-ban rögzítésre kerülnek
### 3.3 Döntési fa
```
Pénzügyi rekord érkezik
├─ Egyszeri beszerzési/vásárlási adat?
│ → AssetFinancials
├─ Finanszírozási szerződés (lízing/hitel)?
│ → AssetFinancials
├─ Rendszeres díj befizetés (biztosítás, adó)?
│ → AssetCost
├─ Biztosítási kötvény adatai?
│ → VehicleInsurancePolicy
└─ Adókötelezettség adatai?
→ VehicleTaxObligation
```
---
## 4. Módosítandó Fájlok (Tervezés - NEM kódolva)
| Fájl | Művelet |
|------|---------|
| `backend/app/models/vehicle/asset.py` | `AssetFinancials` bővítése lízing/hitel mezőkkel + új `VehicleInsurancePolicy` + `VehicleTaxObligation` modellek |
| `backend/app/schemas/asset.py` | `AssetFinancials` Pydantic schema bővítése |
| `backend/app/services/asset_service.py` | `AssetService.create_or_claim_vehicle()` frissítése (jelenleg hardcode 0 érték) |
| `backend/app/api/v1/endpoints/financials.py` | Potenciálisan új endpoint a biztosítás/adó CRUD-hoz |
---
## 5. Konklúzió
1. **`AssetFinancials`** jelenleg is alkalmas a beszerzési adatok tárolására, de **hiányoznak belőle a lízing-specifikus mezők**.
2. **`AssetCost`** a helyes modell az operatív költségekhez (beleértve a biztosítási díjakat és adó befizetéseket is). A `VehicleExpenses` legacy modell kivezetendő.
3. **Dedikált modellek** (`VehicleInsurancePolicy`, `VehicleTaxObligation`) szükségesek a biztosítási kötvények és adókötelezettségek metaadatainak tárolásához, míg a tényleges befizetések az `AssetCost`-ban landolnak.

View File

@@ -0,0 +1,199 @@
# 🚨 P0 UI/UX AUDIT — Vehicle Details Page Rendering
**Dátum:** 2026-06-19
**Auditor:** Rendszer-Architect
**Hatáskör:** `VehicleDetailsView.vue` + all 5 tab components
**Státusz:** 🔴 KRITIKUS — beavatkozás szükséges
---
## 1. AUDIT — Fejléc (Header) Olvashatóság
### Vizsgált fájl
`frontend/src/views/vehicles/VehicleDetailsView.vue`
### Háttér rétegek (layer stacking)
| Réteg | CSS | Leírás |
|-------|-----|--------|
| `z-0` | `fixed inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed` (sor 4) | Teljes képernyős garázs háttérkép |
| `z-10` | `relative mx-auto max-w-7xl px-4 py-8` (sor 7) | Tartalom réteg |
| Body | `bg-[#04151F]` (main.css sor 10) | Sötétkék body háttér |
### Fejléc elemek és színeik
| Elem | Sor | CSS class | Szín |
|------|-----|-----------|------|
| Vissza gomb | 11 | `text-white/80` + `bg-white/10` | 80% fehér szöveg, 10% fehér háttér |
| Rendszám (H1) | 40 | `text-white` | `#ffffff` (tiszta fehér) |
| Márka/Modell/Év | 43 | `text-white/60` | 60% fehér (`rgba(255,255,255,0.6)`) |
| Tab navigáció | 52-58 | `text-white` / `text-white/50` | Tiszta fehér / 50% fehér |
### 🔴 PROBLÉMA: Nincs sötétítő overlay
A `VehicleDetailsView.vue:4` **egyáltalán nem tartalmaz sötétítő réteget** a `garage-bg.png` fölött. A `z-0` rétegen lévő kép közvetlenül a tartalom alatt van, semmilyen `bg-black/40`, sem `backdrop-brightness-50`, sem `overlay` nincs alkalmazva.
**Következmény:** Ha a `garage-bg.png` világos színtartományokat tartalmaz (ami egy garázs/workshop képnél tipikus), a `text-white` és `text-white/60` szövegek teljesen olvashatatlanná válnak.
> ⚠️ **Megjegyzés:** Ugyanez a probléma áll fenn a `FleetView.vue:3-6` esetében is, ahol a komment is jelzi: `"Garage Background Image (no dark overlay)"`.
---
## 2. AUDIT — Tab Konzisztencia
### CSS osztályok összehasonlítása
#### `OverviewTab.vue` — 🟦 **Light Theme (White Cards)**
| Elem | CSS class (sor) |
|------|-----------------|
| Photo div | `bg-white border-slate-200 shadow-sm rounded-2xl` (sor 6) |
| Basic Info card | `bg-white border-slate-200 shadow-sm rounded-2xl p-6` (sor 15) |
| MOT card | `bg-white border-slate-200 shadow-sm rounded-2xl p-5` (sor 55) |
| Mileage card | `bg-white border-slate-200 shadow-sm rounded-2xl p-5` (sor 72) |
| Next Service card | `bg-white border-slate-200 shadow-sm rounded-2xl p-5` (sor 89) |
| **Szövegszínek** | `text-slate-900`, `text-slate-700`, `text-slate-500` |
#### `TechDataTab.vue`, `ServiceBookTab.vue`, `FinancialsTab.vue`, `DocumentsTab.vue` — 🟩 **Dark Glassmorphism Theme (mind a 4)**
| Elem | CSS class (sor) |
|------|-----------------|
| Container | `bg-white/5 border-white/10 backdrop-blur-sm rounded-2xl p-6` (sor 2) |
| Szövegszínek | `text-white`, `text-white/50` |
### 🔴 PROBLÉMA: Teljes stilisztikai szakadás
| Aspektus | OverviewTab | 4 másik tab |
|----------|-------------|-------------|
| **Háttér** | `bg-white` (tiszta fehér) | `bg-white/5` (áttetsző, 5% fehér) |
| **Border** | `border-slate-200` (sötétszürke) | `border-white/10` (halvány fehér) |
| **Szöveg** | `text-slate-900` (sötétszürke) | `text-white` (fehér) |
| **Hatás** | `shadow-sm` | `backdrop-blur-sm` (üvegesség) |
| **Vizuális hangulat** | Hagyományos light card | Modern dark glassmorphism |
Az `OverviewTab.vue` **teljesen más tervezési rendszert** használ, mint a másik 4 tab. Ez vizuális sokkot okoz a tab váltáskor: a többi tab szépen beleolvad a sötét háttérbe, az OverviewTab viszont hirtelen "kiugró" fehér kártyákat mutat.
---
## 3. AUDIT — Hiányzó Járműfotó
### Vizsgált fájl
`frontend/src/components/vehicles/tabs/OverviewTab.vue`
### Jelenlegi állapot (sorok 6-12)
```html
<div class="flex aspect-[16/9] items-center justify-center overflow-hidden
rounded-2xl border border-slate-200 bg-white shadow-sm lg:aspect-square">
<img
src="https://images.unsplash.com/photo-1503376712341-a67b5e40e2d1?auto=format&fit=crop&w=800&q=80"
alt="Vehicle photo"
class="h-full w-full object-cover rounded-2xl"
/>
</div>
```
### 🔴 PROBLÉMA: 3 rétegű hiba
1. **Hardcode-olt stock fotó, nem a valós jármű** — A kép URL egy fix Unsplash link, minden járműnél ugyanaz a piros sportautó jelenik meg.
2. **Nincs hibakezelés betöltési hibára** — Ha az Unsplash CDN:
- Letiltja a hotlinkinget (CORS/403)
- Időtúllépés (timeout)
- Hálózati hiba
- **Akkor a felhasználó egy üres fehér dobozt lát**, mert nincs `@error` eseménykezelés és nincs fallback placeholder.
3. **Fehér háttér a sötét téma közepén** — A `bg-white border-slate-200` miatt, ha a kép nem tölt be, egy nagy fehér téglalap látszik a sötét háttér előtt, ami vizuálisan "kiég".
---
## 4. Architect Javaslatok
### 4.1 Fejléc olvashatóság javítása
**Ajánlott megoldás:** Sötétített overlay réteg bevezetése a header mögött.
**Lehetőség A — Header gradient sáv (ajánlott):**
Helyezzünk egy sötét gradient sávot közvetlenül a fejléc szövegek mögé (relative pozícionálással), ami blokkolja a `garage-bg.png` világos részeit:
```css
/* Egy sötét gradient overlay a header div-en belül */
.header-overlay {
background: linear-gradient(to bottom, rgba(4,21,31,0.85) 0%, rgba(4,21,31,0.4) 100%);
border-radius: 1rem;
padding: 1.5rem;
}
```
**Lehetőség B — Teljes oldalas overlay (gyors megoldás):**
A `garage-bg.png` div és a tartalom közé egy további `z-5` réteg:
```html
<div class="fixed inset-0 z-[5] bg-black/40"></div>
```
### 4.2 Tab egységesítés
**Ajánlott irány:** Az összes tabot a Dark Glassmorphism (`bg-white/5 backdrop-blur-sm border-white/10`) stílusra kell átállítani, hogy az egész oldal egységes, modern, sötét témájú maradjon.
| Módosítandó elem | Jelenlegi | Javasolt |
|------------------|-----------|----------|
| OverviewTab photo div | `bg-white border-slate-200 shadow-sm` | `bg-white/5 border-white/10 backdrop-blur-sm` |
| OverviewTab info card | `bg-white border-slate-200 shadow-sm p-6` | `bg-white/5 border-white/10 backdrop-blur-sm p-6` |
| OverviewTab vital cards (x3) | `bg-white border-slate-200 shadow-sm p-5` | `bg-white/5 border-white/10 backdrop-blur-sm p-5` |
| OverviewTab szövegek | `text-slate-900/700/500` | `text-white/90/70/50` |
| Icon bg-k (MOT, mileage, service) | `bg-amber-100`, `bg-blue-100`, `bg-emerald-100` | `bg-amber-500/20`, `bg-blue-500/20`, `bg-emerald-500/20` |
| Icon színek | `text-amber-600`, `text-blue-600`, `text-emerald-600` | `text-amber-300`, `text-blue-300`, `text-emerald-300` |
### 4.3 Járműfotó javítása
**Kötelező változtatások:**
1. **Dinamikus képforrás** — Az `src` attribute-nak a backendről kell jönnie (pl. `vehicle.photo_url`), és ha nincs fotó, egy placeholder SVG-t kell mutatni.
2. **Fallback és hibakezelés** — Az `<img>` tagon `@error` eseménykezelő, ami placeholder-re vált:
```html
<img
:src="vehicle.photo_url || placeholderUrl"
alt="Vehicle photo"
class="h-full w-full object-cover rounded-2xl"
@error="onImageError"
/>
```
```ts
const imageError = ref(false)
const onImageError = () => { imageError.value = true }
```
3. **Placeholder komponens** — Ha a kép nem tölt be, egy jármű SVG ikon jelenjen meg:
```html
<div v-if="imageError || !vehicle.photo_url"
class="flex h-full w-full items-center justify-center bg-white/5">
<svg class="h-16 w-16 text-white/30" ...>
<!-- Vehicle silhouette icon -->
</svg>
<p class="text-sm text-white/30">{{ t('vehicleDetail.noPhoto') }}</p>
</div>
```
### 4.4 Hatásvizsgálat: Érintett fájlok
| Fájl | Módosítás típusa |
|------|------------------|
| `VehicleDetailsView.vue` (`frontend/src/views/vehicles/VehicleDetailsView.vue`) | Overlay réteg hozzáadása a header köré |
| `OverviewTab.vue` (`frontend/src/components/vehicles/tabs/OverviewTab.vue`) | Teljes CSS átállítás light → dark glassmorphism + kép placeholder logika |
| `FleetView.vue` (`frontend/src/views/vehicles/FleetView.vue`) | (Ajánlott) Ugyanez az overlay javítás, mivel itt is ugyanaz a probléma |
---
## Összefoglaló
| # | Probléma | Súlyosság | Érintett fájl(ok) |
|---|----------|-----------|-------------------|
| 1 | Fehér szöveg világos háttérképen — nincs overlay | 🔴 **KRITIKUS** | `VehicleDetailsView.vue:4,40-43` |
| 2 | Tab stílusok teljes inkonzisztenciája (light vs dark) | 🔴 **KRITIKUS** | `OverviewTab.vue` vs összes többi tab |
| 3 | Hiányzó járműfotó / nincs fallback | 🟡 **MAGAS** | `OverviewTab.vue:6-12` |
**Ajánlott beavatkozási sorrend:** ① Tab egységesítés (glassmorphism) → ② Header overlay → ③ Photo placeholder + error handling

View File

@@ -0,0 +1,180 @@
# 🚗 Jármű Adatfolyam Audit 2026-06-21
**Kártya:** #275 - Audit: Jármű adatfolyam (DB → API → Frontend) teljes körű elemzése
**Auditor:** Rendszer-Architect
**Státusz:** Kész
---
## 📋 Vizsgált Kérdések
1. **Dashboard jármű kártyák hova mutatnak?**
2. **Járművek oldalon milyen adatok látszanak?**
3. **Áttekintés (Overview) fül melyik adatbázis táblákból kapja az adatokat?**
4. **API kapcsolatok lefedik-e az összes adatbázis adatot?**
---
## 1. Dashboard Jármű Kártyák (VehicleCardStandard)
**Fájl:** `frontend/src/components/vehicle/VehicleCardStandard.vue`
**Linkelés:**
- `@click="$emit('click', vehicle')` → A parent komponens kezeli (pl. DashboardView, CompanyGarageView), ami megnyitja a `VehicleDetailModal`-t
**Kártyán megjelenő adatok és forrásaik:**
| Mező | Forrás (DB) | API |
|------|-------------|-----|
| `license_plate` | `vehicle.asset.license_plate` | `GET /assets/vehicles` |
| `brand` | `vehicle.asset.brand` | `GET /assets/vehicles` |
| `model` | `vehicle.asset.model` | `GET /assets/vehicles` |
| `year_of_manufacture` | `vehicle.asset.year_of_manufacture` | `GET /assets/vehicles` |
| `current_mileage` | `vehicle.asset.current_mileage` | `GET /assets/vehicles` |
| `engine` (kW / cm³) | `vehicle.asset.power_kw` / `engine_capacity` | `GET /assets/vehicles` |
| `fuel_type` | `vehicle.asset.fuel_type` | `GET /assets/vehicles` |
| `color` | `individual_equipment.color` (JSONB) | `GET /assets/vehicles` |
| `nextService` | `individual_equipment.dates.next_service` (JSONB) | `GET /assets/vehicles` |
---
## 2. Jármű Részletek (VehicleDetailModal)
**Fájl:** `frontend/src/components/vehicle/VehicleDetailModal.vue`
A modál **4 tab**-ot tartalmaz:
### Tab 1: Alapadatok (Basic Data)
- **Forrás:** `vehicle.asset` mezői + `individual_equipment` JSONB
- **Megjelenített:** Engine (kW/LE), body_type, VIN, mileage, transmission, drive_type, fuel_type, trim_level, EV specs, features, dates, documents, trust score
### Tab 2: Pénzügyek (Finance)
- **API:** `GET /assets/{id}/costs``finance.asset_costs`
- **Tartalom:** Éves TCO, fix/running költségek, prémium chartok, átlag fogyasztás/km költség
- **HIÁNYZIK:** AssetFinancials (beszerzés/lízing), VehicleInsurancePolicy (biztosítások), VehicleTaxObligation (adók)
### Tab 3: Figyelmeztetések (Alerts)
- **Forrás:** `individual_equipment.dates.*` (JSONB)
- **Tartalom:** insurance_expiry, mot_expiry, next_service, tire_change
### Tab 4: Szervizkönyv (Servicebook)
- **API:** `GET /assets/{id}/events``vehicle.asset_events`
- **Művelet:** Listázás + POST új esemény
---
## 3. Áttekintés (OverviewTab) Adatforrásai
**Fájl:** `frontend/src/components/vehicles/tabs/OverviewTab.vue`
| Adat | Forrás | DB Tábla/Mező |
|------|--------|---------------|
| `license_plate` | `inject('vehicle')` | `vehicle.asset.license_plate` |
| `brand`, `model` | `inject('vehicle')` | `vehicle.asset.brand/model` |
| `year_of_manufacture` | `inject('vehicle')` | `vehicle.asset.year_of_manufacture` |
| `fuel_type` | `inject('vehicle')` (i18n) | `vehicle.asset.fuel_type` |
| `current_mileage` | `inject('vehicle')` | `vehicle.asset.current_mileage` |
| Havi költség | `expenseStore.monthlyCost` | `finance.asset_costs` (aggregált) |
| Havi km | `individual_equipment.monthly_mileage` (JSONB) | `vehicle.asset` |
| Legutóbbi 3 költség | `expenseStore.recentExpenses` | `finance.asset_costs` |
| MOT lejárat | `individual_equipment.dates.mot_expiry` (JSONB) | `vehicle.asset` |
| Következő szerviz | `individual_equipment.dates.next_service` (JSONB) | `vehicle.asset` |
---
## 4. API Lefedettség Ellenőrzés
### AssetResponse lefedettség (50+ mező)
- **Minden** `vehicle.asset` mező szerepel az `AssetResponse`-ban
- A frontend `Vehicle` interface is lefedi az összes mezőt
- **Kivétel:** `cargo_volume_x` és `cargo_volume_y` közül csak `cargo_volume_y` van a frontend típusban
### Fleet_finance lefedettség
- `asset_financials``GET /assets/vehicles/{id}/financials`
- `vehicle_insurance_policies``GET /assets/vehicles/{id}/insurance`
- `vehicle_tax_obligations``GET /assets/vehicles/{id}/taxes`
- Mindhárom külön API endpointon, teljes CRUD támogatással
### Hiányzó API endpointok
- `VehicleUserRating` → Nincs API (trust score rendszer hiányos)
- `VehicleTransferRequest` → Nincs API (tulajdonosváltás kézzel)
- `VehicleLogbook` → Nincs API (naplózás hiányos)
---
## 5. Feltárt Problémák
### PROBLÉMA 1: AssetFinancials hiánya a VehicleDetailModal-ból
**Súlyosság:** Magas
**Leírás:** A VehicleDetailModal Pénzügyek tabja csak az AssetCost adatokat jeleníti meg. Az AssetFinancials (beszerzés, lízing), VehicleInsurancePolicy (biztosítások) és VehicleTaxObligation (adók) adatok a külön FinancialsTab komponensben érhetők el, ami nem része a modálnak.
**Javaslat:** Integrálni a FinancialsTab-ot a VehicleDetailModal-ba, vagy legalább linket tenni a teljes pénzügyi nézetre.
### PROBLÉMA 2: is_primary vizuális jelzés hiánya
**Súlyosság:** Közepes
**Leírás:** Az `is_primary` mező létezik (JSONB-ből számolva), de a VehicleCardStandard nem jeleníti meg vizuálisan.
**Javaslat:** Csillag/jelvény hozzáadása a kártyához, ha a jármű elsődleges.
### PROBLÉMA 3: Frontend Vehicle típushiba
**Súlyosság:** Alacsony
**Leírás:** A frontend Vehicle interface tartalmaz `asset_financials`, `insurance_policies`, `tax_obligations` mezőket, de ezek nem részei a backend AssetResponse sémának - külön API hívásokkal töltődnek be.
**Javaslat:** Tisztázni a frontend típusokat, opcionális mezőként jelezni, hogy ezek külön betöltendők.
---
## 6. Adatfolyam Diagram
```
┌──────────────────┐ GET /assets/vehicles ┌──────────────────┐
│ PostgreSQL DB │ ←─────────────────────────→ │ FastAPI Backend │
│ │ │ │
│ vehicle.asset │ AssetResponse (50+ mező) │ /vehicles GET │
│ vehicle.asset_ │ AssetCatalogResponse │ /vehicles/{id} │
│ catalog │ AssetEventResponse │ /{id}/events │
│ finance.asset_ │ AssetCostResponse │ /{id}/costs │
│ costs │ AssetFinancialsResponse │ /{id}/financials│
│ fleet_finance.* │ VehicleInsurancePolicyResp │ /{id}/insurance │
│ vehicle.asset_ │ VehicleTaxObligationResp │ /{id}/taxes │
│ events │ │ │
└──────────────────┘ └──────────────────┘
┌──────────────────┐
│ Vue 3 Frontend │
│ │
│ VehicleCard │
│ → click │
│ → DetailModal │
│ │
│ VehicleDetail │
│ Modal (4 tab) │
│ ├─ Alapadatok │
│ ├─ Pénzügyek │
│ │ (csak costs) │
│ ├─ Figyelmezt. │
│ └─ Szervizkönyv │
│ │
│ OverviewTab │
│ (inject vehicle │
│ + expenseStore)│
│ │
│ FinancialsTab │
│ (külön oldal) │
└──────────────────┘
```
---
## 7. Következtetés
**Összességében az adatfolyam megfelelően van felépítve.** Az API lefedi az adatbázis összes fontos mezőjét, a frontend komponensek a megfelelő API végpontokról olvassák az adatokat.
**Főbb erősségek:**
- Thick Digital Twin koncepció jól implementálva
- AssetResponse minden mezőt tartalmaz
- Fleet_finance modellek külön, tiszta API endpointokon
- JSONB mezők (individual_equipment) rugalmasan használva
**Főbb hiányosságok:**
1. VehicleDetailModal Pénzügyek tabja nem tartalmazza a lízing/biztosítás/adó adatokat
2. is_primary vizuális jelzés hiányzik a kártyán
3. VehicleUserRating, VehicleTransferRequest, VehicleLogbook API-hiányos

View File

@@ -0,0 +1,142 @@
# 🚗 Jármű Regisztrációs Dokumentumok Audit Jelentés
**Dátum:** 2026-06-19
**Auditor:** Rendszer-Architect
**Hatáskör:** Teljes adatfolyam (adatbázis → API → Frontend)
**Státusz:** Hiányzó funkciók azonosítva — beavatkozás szükséges
---
## 📋 Összefoglaló
A jármű rögzítési felületek auditja során **3 adatmező hiányát** azonosítottuk, amelyek a forgalmi engedély és törzskönyv adatainak rögzítéséhez szükségesek. Ezek az adatok jelenleg **sehol sem** tárolhatók el az adatbázisban, és a felhasználói felületen sem érhetők el.
---
## 🔴 Hiányzó Mezők (3 db)
| # | Mező neve | Magyar leírás | Típus | Hol hiányzik |
|---|-----------|---------------|-------|--------------|
| 1 | `registration_certificate_number` | Forgalmi engedély száma | `VARCHAR(50)` | Minden rétegben |
| 2 | `registration_certificate_validity` | Forgalmi engedély érvényessége | `DATE` | Minden rétegben |
| 3 | `vehicle_registration_document_number` | Törzskönyv száma | `VARCHAR(50)` | Minden rétegben |
---
## 🔍 Részletes Hiánytérkép (Rétegenként)
### 1. 🗄️ Adatbázis — Asset Model
**Fájl:** `backend/app/models/vehicle/asset.py` (69. sor)
Az `Asset` modell jelenleg a következő dokumentációs/admin mezőket tartalmazza:
- `first_registration_date` — Első forgalomba helyezés dátuma
- `next_mot_date` — Műszaki vizsga lejárata
- `insurance_expiry_date` — Biztosítás lejárata
- `purchase_date` — Vásárlás dátuma
- `is_under_warranty` / `warranty_expiry_date` — Jótállás
- `condition` — Állapot
- `color` — Szín
- `notes` — Megjegyzések
**Hiányzik:**
- `registration_certificate_number`
- `registration_certificate_validity`
- `vehicle_registration_document_number`
### 2. 📐 Pydantic Sémák
**Fájl:** `backend/app/schemas/asset.py`
| Séma | Sor | Státusz |
|------|-----|---------|
| `AssetResponse` | 32 | ❌ Mind a 3 mező hiányzik |
| `AssetCreate` | 117 | ❌ Mind a 3 mező hiányzik |
| `AssetUpdate` | 259 | ❌ Mind a 3 mező hiányzik |
### 3. 🌐 API Végpontok
**Fájl:** `backend/app/api/v1/endpoints/assets.py`
| Végpont | Művelet | Státusz |
|---------|---------|---------|
| `POST /vehicles` | Létrehozás | ❌ AssetCreate nem tartalmazza a mezőket |
| `PUT /vehicles/{asset_id}` | Frissítés | ❌ AssetUpdate nem tartalmazza a mezőket |
| `GET /vehicles` | Listázás | ❌ AssetResponse nem tartalmazza a mezőket |
| `GET /vehicles/{id}` | Részletek | ❌ AssetResponse nem tartalmazza a mezőket |
### 4. ⚙️ Asset Service
**Fájl:** `backend/app/services/asset_service.py` (34. sor)
A `create_or_claim_vehicle()` metódus (172-222. sor) `asset_fields` szótára **nem tartalmazza** a dokumentum mezőket.
### 5. 🖥️ Frontend — Vehicle Store
**Fájl:** `frontend/src/stores/vehicle.ts` (9. sor)
A `Vehicle` interfész (9-90. sor) nem tartalmazza a dokumentum mezőket.
### 6. 🖥️ Frontend — VehicleFormModal
**Fájl:** `frontend/src/components/dashboard/VehicleFormModal.vue`
| Alrendszer | Sor | Státusz |
|------------|-----|---------|
| Form reactive state | 1742-1806 | ❌ Hiányzik mind a 3 mező |
| Admin Tab UI (Tab 2) | 961-1018 | ❌ Csak állapot, szín, jótállás, jegyzetek |
| handleSave() payload | 2122-2199 | ❌ Hiányzik mind a 3 mező |
| populateForm() | 2029-2113 | ❌ Hiányzik mind a 3 mező |
| resetForm() | 1987-2026 | ❌ Hiányzik mind a 3 mező |
### 7. 🌍 i18n Fordítások
**Frontend:** `frontend/src/i18n/hu.ts` és `frontend/src/i18n/en.ts`
**Backend:** `backend/static/locales/hu.json` és `backend/static/locales/en.json`
Hiányzó fordítási kulcsok:
- `vehicle.label_registration_certificate_number`
- `vehicle.label_registration_certificate_validity`
- `vehicle.label_vehicle_registration_document_number`
---
## 📊 Működő Mezők Ellenőrzőlistája
### Alapadatok Tab (Tab 0) ✅
Rendszám, VIN, Márka, Modell, Gyártási év, Üzemanyag típus, Felszereltségi szint, Név, Km óra állás
### Műszaki Tab (Tab 1) ✅
Hengerűrtartalom, Teljesítmény, Nyomaték, Hajtás, Váltó, Kárószéria típus, Klíma típus, Henger-elrendezés, Ajtók/Ülések/Hengerek száma, Súlyadatok, EV adatok, Dátumok
### Adminisztráció Tab (Tab 2) ⚠️
- ✅ Állapot, Szín, Jótállás, Megjegyzések
- ❌ Forgalmi engedély száma
- ❌ Forgalmi engedély érvényessége
- ❌ Törzskönyv száma
### Felszereltség Tab (Tab 3) ✅
Személyautó, Motorkerékpár, LCV, HGV kategóriák szerinti felszereltség
---
## 📝 Terv a Hiányzó Funkciók Pótlására
### Végrehajtandó lépések (sorrendben):
1. **Adatbázis** — 3 új oszlop hozzáadása az `Asset` modellhez (`backend/app/models/vehicle/asset.py`)
2. **Sync**`docker exec -it sf_api python -m app.scripts.sync_engine`
3. **Pydantic sémák**`AssetResponse`, `AssetCreate`, `AssetUpdate` bővítése (`backend/app/schemas/asset.py`)
4. **Asset Service**`asset_fields` dict bővítése (`backend/app/services/asset_service.py`)
5. **Vehicle Store**`Vehicle` interfész bővítése (`frontend/src/stores/vehicle.ts`)
6. **VehicleFormModal** — form state, Admin tab UI, handleSave, populateForm, resetForm bővítése
7. **Frontend i18n** — magyar és angol fordítási kulcsok hozzáadása
8. **Backend i18n** — (opcionális) magyar és angol fordítási kulcsok hozzáadása
---
## ⚠️ Megjegyzések
1. **Nincs szükség új migrációs fájlra** — A projekt a `sync_engine`-t használja a séma szinkronizálására.
2. **Külön oszlopok** — Az adatok nem a JSONB `individual_equipment` mezőbe kerülnek, mivel strukturált admin adatok.
3. **Soft-delete kompatibilitás** — Az új mezők `nullable=True` beállítással jönnek létre.

View File

@@ -0,0 +1,7 @@
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8502
CMD ["npm", "run", "dev"]

6
frontend/admin/app.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<div style="font-family: sans-serif; padding: 2rem; background-color: #1a1a1a; color: #fff; min-height: 100vh;">
<h1>ServiceFinder Admin</h1>
<p>A biztonságos adminisztrációs és statisztikai felület konténere sikeresen felállt.</p>
</div>
</template>

View File

@@ -0,0 +1,3 @@
export default defineNuxtConfig({
devtools: { enabled: true }
})

View File

@@ -0,0 +1,16 @@
{
"name": "sf-admin-frontend",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview"
},
"devDependencies": {
"nuxt": "^3.11.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
}
}

View File

@@ -420,7 +420,7 @@
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" 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')" @click="$emit('close')"
> >
{{ $t('admin.packages.cancel') }} {{ $t('common.cancel') }}
</button> </button>
<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" 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"
@@ -432,7 +432,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> <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" /> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg> </svg>
{{ $t('admin.packages.saving') }} {{ $t('common.saving') }}
</span> </span>
<span v-else> <span v-else>
{{ isEditing ? $t('admin.packages.save_changes') : $t('admin.packages.create') }} {{ isEditing ? $t('admin.packages.save_changes') : $t('admin.packages.create') }}

View File

@@ -0,0 +1,834 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-white w-full max-w-2xl max-h-[90vh] rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-fade-in-up">
<!-- Header -->
<div class="flex items-center justify-between bg-slate-700 px-6 py-4 shrink-0">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<span>🧾</span>
{{ t('costWizard.title') }}
</h3>
<button
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-colors"
@click="$emit('close')"
>
</button>
</div>
<!-- Stepper Progress -->
<div class="flex items-center justify-center gap-1 px-6 py-4 bg-slate-50 border-b border-slate-200 shrink-0">
<div
v-for="(step, idx) in steps"
:key="idx"
class="flex items-center gap-1"
>
<!-- Step circle + label -->
<div
class="flex flex-col items-center cursor-pointer"
@click="goToStep(idx)"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition-all"
:class="stepClass(idx)"
>
<span v-if="idx < currentStep"></span>
<span v-else>{{ idx + 1 }}</span>
</div>
<span
class="text-[10px] mt-1 whitespace-nowrap transition-colors"
:class="idx <= currentStep ? 'text-sf-accent font-semibold' : 'text-slate-400'"
>
{{ t(step.labelKey) }}
</span>
</div>
<!-- Connector line -->
<div
v-if="idx < steps.length - 1"
class="h-0.5 w-8 mx-1 rounded transition-colors"
:class="idx < currentStep ? 'bg-sf-accent' : 'bg-slate-200'"
/>
</div>
</div>
<!-- Scrollable Form Body -->
<div class="flex-1 overflow-y-auto p-6">
<!--
STEP 1: Alapadatok (Vehicle, Category, Date)
-->
<div v-if="currentStep === 0" class="space-y-5">
<!-- Vehicle (read-only display) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectVehicle') }}</label>
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
</div>
</div>
<!-- Main Category -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectCategory') }}</label>
<select
v-model="form.mainCategory"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@change="onMainCategoryChange"
>
<option value="" disabled>{{ t('costWizard.selectCategory') }}...</option>
<option
v-for="cat in mainCategories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
</select>
</div>
<!-- Sub Category -->
<div v-if="subCategories.length > 0">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectSubCategory') }}</label>
<select
v-model="form.subCategory"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option value="" disabled>{{ t('costWizard.selectSubCategory') }}...</option>
<option
v-for="sub in subCategories"
:key="sub.id"
:value="sub.id"
>
{{ sub.name }}
</option>
</select>
</div>
<!-- Cost Date -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.costDate') }}</label>
<input
v-model="form.date"
type="date"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
</div>
<!--
STEP 2: Pénzügyek (Net, VAT, Gross, Currency)
-->
<div v-if="currentStep === 1" class="space-y-5">
<!-- Net Amount -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.netAmount') }}</label>
<input
v-model.number="form.netAmount"
type="number"
min="0"
step="0.01"
required
placeholder="0.00"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@input="onNetAmountChange"
/>
</div>
<!-- VAT Rate -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.vatRate') }}</label>
<select
v-model.number="form.vatRate"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@change="onVatRateChange"
>
<option :value="0">0%</option>
<option :value="5">5%</option>
<option :value="10">10%</option>
<option :value="20">20%</option>
<option :value="27">27%</option>
</select>
</div>
<!-- Gross Amount (GROSS-FIRST: primary field per Masterbook 2.0.1) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.grossAmount') }} <span class="text-xs text-slate-400 font-normal">({{ t('common.optional') }})</span></label>
<input
v-model.number="form.grossAmount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@input="onGrossAmountChange"
/>
</div>
<!-- Currency -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.currency') }}</label>
<select
v-model="form.currency"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option value="HUF">HUF</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
<option value="CHF">CHF</option>
</select>
</div>
</div>
<!--
STEP 3: Admin & Szállító (Vendor, Invoice, Deadline)
-->
<div v-if="currentStep === 2" class="space-y-5">
<!-- Vendor autocomplete with free-text fallback -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.vendor') }}</label>
<div class="relative">
<input
v-model="vendorSearch"
type="text"
:placeholder="t('costWizard.vendorPlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@input="onVendorSearchInput"
@focus="showVendorDropdown = true"
@blur="onVendorBlur"
/>
<!-- Dropdown suggestions -->
<div
v-if="showVendorDropdown && filteredVendors.length > 0"
class="absolute z-20 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
>
<button
v-for="vendor in filteredVendors"
:key="vendor.organization_id"
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50 hover:text-sf-accent transition-colors border-b border-slate-100 last:border-b-0"
@mousedown.prevent="selectVendor(vendor)"
>
<span class="font-medium">{{ vendor.display_name || vendor.name }}</span>
<span v-if="vendor.tax_number" class="ml-2 text-xs text-slate-400">({{ vendor.tax_number }})</span>
</button>
</div>
</div>
<!-- Selected vendor badge -->
<div
v-if="selectedVendor"
class="mt-2 inline-flex items-center gap-2 rounded-full bg-sf-accent/10 border border-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent"
>
<span>{{ selectedVendor.display_name || selectedVendor.name }}</span>
<button
class="ml-1 text-sf-accent/60 hover:text-sf-accent"
@click="clearVendor"
>
</button>
</div>
<p v-else-if="vendorSearch && !selectedVendor" class="mt-1 text-xs text-slate-400">
{{ t('costWizard.vendorPlaceholder') }}
</p>
</div>
<!-- Invoice Number -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.invoiceNumber') }}</label>
<input
v-model="form.invoiceNumber"
type="text"
placeholder="pl. 2026-00123"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Invoice Date -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.invoiceDate') }}</label>
<input
v-model="form.invoiceDate"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
<!-- Payment Deadline -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.paymentDeadline') }}</label>
<input
v-model="form.paymentDeadline"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/>
</div>
</div>
<!--
STEP 4: Fájlok (File attachment)
-->
<div v-if="currentStep === 3" class="space-y-5">
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.attachFiles') }}</label>
<!-- Drop zone -->
<div
class="relative flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-50 px-6 py-10 text-center transition-colors hover:border-sf-accent/50 hover:bg-sf-accent/5 cursor-pointer"
@click="triggerFileInput"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="onFileDrop"
:class="{ 'border-sf-accent bg-sf-accent/5': isDragOver }"
>
<svg class="w-10 h-10 text-slate-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-sm text-slate-500">{{ t('costWizard.dragDrop') }}</p>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="onFileSelect"
/>
</div>
<!-- File list -->
<div v-if="attachedFiles.length > 0" class="mt-3 space-y-2">
<div
v-for="(file, idx) in attachedFiles"
:key="idx"
class="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-100 text-base">📄</span>
<div class="flex-1 min-w-0">
<p class="text-slate-700 truncate font-medium">{{ file.name }}</p>
<p class="text-xs text-slate-400">{{ formatFileSize(file.size) }}</p>
</div>
<button
class="flex h-6 w-6 items-center justify-center rounded-full text-slate-400 hover:text-red-500 hover:bg-red-50 transition-colors"
@click="removeFile(idx)"
>
</button>
</div>
</div>
<p v-else class="text-xs text-slate-400 mt-1">{{ t('costWizard.noFiles') }}</p>
</div>
</div>
<!-- Error Message -->
<div
v-if="errorMessage"
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 mt-4"
>
{{ errorMessage }}
</div>
</div>
<!-- Footer Navigation -->
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4 bg-slate-50 shrink-0">
<button
v-if="currentStep > 0"
type="button"
class="rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-600 transition-all hover:bg-slate-100 hover:shadow-sm"
@click="prevStep"
>
{{ t('common.prev') }}
</button>
<div v-else />
<button
v-if="currentStep < steps.length - 1"
type="button"
class="rounded-xl bg-sf-accent px-6 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
@click="nextStep"
>
{{ t('common.next') }}
</button>
<button
v-else
type="button"
:disabled="isSubmitting"
class="rounded-xl bg-emerald-600 px-6 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-emerald-700 hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center gap-2"
@click="handleSubmit"
>
<svg
v-if="isSubmitting"
class="h-4 w-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{{ isSubmitting ? t('common.saving') : t('common.save') }}</span>
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCostStore } from '../../stores/cost'
import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle'
import { getLocalDateString } from '../../services/dateUtils'
const { t } = useI18n()
const costStore = useCostStore()
// ── Props & Emits ──
const props = defineProps<{
isOpen: boolean
vehicle: Vehicle | null
editCost?: any | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
// ── Stepper State ──
const steps = [
{ labelKey: 'costWizard.step1', descKey: 'costWizard.step1Desc' },
{ labelKey: 'costWizard.step2', descKey: 'costWizard.step2Desc' },
{ labelKey: 'costWizard.step3', descKey: 'costWizard.step3Desc' },
{ labelKey: 'costWizard.step4', descKey: 'costWizard.step4Desc' },
]
const currentStep = ref(0)
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
// ── Form State ──
const form = reactive({
date: getLocalDateString(),
mainCategory: '',
subCategory: '',
netAmount: 0,
vatRate: 20,
grossAmount: 0,
currency: 'HUF',
invoiceNumber: '',
invoiceDate: '',
paymentDeadline: '',
})
// ── Vendor State ──
interface VendorOption {
organization_id: number
name: string
display_name: string | null
tax_number: string | null
}
const vendorSearch = ref('')
const vendors = ref<VendorOption[]>([])
const filteredVendors = ref<VendorOption[]>([])
const selectedVendor = ref<VendorOption | null>(null)
const showVendorDropdown = ref(false)
// ── File State ──
const attachedFiles = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const isDragOver = ref(false)
// ── Category State ──
interface CategoryOption {
id: number
name: string
code?: string
parent_id: number | null
}
const mainCategories = ref<CategoryOption[]>([])
const subCategories = ref<CategoryOption[]>([])
// ── Computed ──
const vehicleLabel = computed(() => {
if (!props.vehicle) return t('dashboard.noVehicleSelected')
const plate = props.vehicle.license_plate || ''
const brand = props.vehicle.brand || ''
const model = props.vehicle.model || ''
return `${plate} (${brand} ${model})`.trim()
})
// ── Stepper Methods ──
function stepClass(idx: number): string {
if (idx < currentStep.value) return 'bg-sf-accent text-white'
if (idx === currentStep.value) return 'bg-sf-accent text-white ring-2 ring-sf-accent/30'
return 'bg-slate-200 text-slate-500'
}
function goToStep(idx: number) {
// Only allow going back to completed steps
if (idx <= currentStep.value) {
currentStep.value = idx
}
}
function validateStep(step: number): boolean {
errorMessage.value = null
if (step === 0) {
if (!form.mainCategory) {
errorMessage.value = 'Kérlek válassz kategóriát.'
return false
}
if (!form.date) {
errorMessage.value = 'Kérlek add meg a dátumot.'
return false
}
}
if (step === 1) {
if (!form.netAmount || form.netAmount <= 0) {
errorMessage.value = 'Kérlek add meg a nettó összeget.'
return false
}
}
return true
}
function nextStep() {
if (!validateStep(currentStep.value)) return
if (currentStep.value < steps.length - 1) {
currentStep.value++
}
}
function prevStep() {
if (currentStep.value > 0) {
currentStep.value--
}
}
// ── Category Methods ──
async function fetchCategories() {
try {
const res = await api.get('/dictionaries/cost-categories')
const allCategories = res.data as CategoryOption[]
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
window.__allCostCategories = allCategories
} catch (err) {
console.error('[CostEntryWizard] Failed to load categories:', err)
mainCategories.value = [
{ 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', 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 },
]
}
}
function onMainCategoryChange() {
form.subCategory = ''
subCategories.value = []
if (!form.mainCategory) return
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
subCategories.value = allCats.filter(
(c) => c.parent_id === Number(form.mainCategory)
)
}
// ── Financial Calculation Methods ──
function onNetAmountChange() {
if (form.netAmount > 0 && form.vatRate > 0) {
form.grossAmount = Math.round(form.netAmount * (1 + form.vatRate / 100) * 100) / 100
}
}
function onVatRateChange() {
if (form.netAmount > 0) {
form.grossAmount = Math.round(form.netAmount * (1 + form.vatRate / 100) * 100) / 100
}
}
function onGrossAmountChange() {
// If user edits gross, recalculate net (reverse VAT)
if (form.grossAmount > 0 && form.vatRate > 0) {
form.netAmount = Math.round(form.grossAmount / (1 + form.vatRate / 100) * 100) / 100
}
}
// ── Vendor Methods ──
async function fetchVendors() {
try {
const res = await api.get('/organizations', {
params: { org_type: 'SERVICE_PROVIDER' },
})
const data = res.data
// Handle both paginated and direct array responses
vendors.value = data.data || data.items || data.results || data || []
} catch (err) {
console.error('[CostEntryWizard] Failed to load vendors:', err)
vendors.value = []
}
}
function onVendorSearchInput() {
showVendorDropdown.value = true
const query = vendorSearch.value.toLowerCase().trim()
if (!query) {
filteredVendors.value = vendors.value
return
}
filteredVendors.value = vendors.value.filter(
(v) =>
(v.display_name || v.name).toLowerCase().includes(query) ||
(v.tax_number && v.tax_number.includes(query))
)
}
function selectVendor(vendor: VendorOption) {
selectedVendor.value = vendor
vendorSearch.value = vendor.display_name || vendor.name
showVendorDropdown.value = false
}
function clearVendor() {
selectedVendor.value = null
vendorSearch.value = ''
filteredVendors.value = vendors.value
}
function onVendorBlur() {
// Delay to allow click on dropdown item
setTimeout(() => {
showVendorDropdown.value = false
}, 200)
}
// ── File Methods ──
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) {
for (const file of Array.from(input.files)) {
attachedFiles.value.push(file)
}
}
// Reset input so same file can be re-selected
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function onFileDrop(event: DragEvent) {
isDragOver.value = false
if (event.dataTransfer?.files) {
for (const file of Array.from(event.dataTransfer.files)) {
attachedFiles.value.push(file)
}
}
}
function removeFile(idx: number) {
attachedFiles.value.splice(idx, 1)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// ── Submit ──
async function handleSubmit() {
if (!props.vehicle) {
errorMessage.value = 'Nincs kiválasztva jármű.'
return
}
if (!validateStep(currentStep.value)) return
isSubmitting.value = true
errorMessage.value = null
try {
// Determine category_id
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
let categoryId = Number(form.subCategory || form.mainCategory) || 10
// Build payload matching AssetCostBase schema
const payload: Record<string, any> = {
asset_id: props.vehicle.id,
category_id: categoryId,
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
amount_gross: form.grossAmount > 0 ? form.grossAmount : form.netAmount,
currency: form.currency,
date: new Date(form.date).toISOString(),
mileage_at_cost: null,
description: `Számla: ${form.invoiceNumber || 'N/A'}`,
data: {
main_category_id: form.mainCategory || null,
sub_category_id: form.subCategory || null,
net_amount: form.netAmount,
vat_rate: form.vatRate,
invoice_number: form.invoiceNumber || null,
invoice_date: form.invoiceDate ? new Date(form.invoiceDate).toISOString() : null,
payment_deadline: form.paymentDeadline ? new Date(form.paymentDeadline).toISOString() : null,
vendor_id: selectedVendor.value?.organization_id || null,
vendor_name: selectedVendor.value
? (selectedVendor.value.display_name || selectedVendor.value.name)
: (vendorSearch.value || null),
file_count: attachedFiles.value.length,
file_names: attachedFiles.value.map((f) => f.name),
},
}
// Add organization_id if vendor selected
if (selectedVendor.value?.organization_id) {
payload.organization_id = selectedVendor.value.organization_id
}
const result = await costStore.addExpense(payload)
if (result) {
resetForm()
emit('saved')
} else {
errorMessage.value = costStore.lastError || t('costWizard.error')
}
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
t('costWizard.error')
errorMessage.value = message
console.error('[CostEntryWizard] Submit error:', err)
} finally {
isSubmitting.value = false
}
}
function resetForm() {
form.date = getLocalDateString()
form.mainCategory = ''
form.subCategory = ''
form.netAmount = 0
form.vatRate = 20
form.grossAmount = 0
form.currency = 'HUF'
form.invoiceNumber = ''
form.invoiceDate = ''
form.paymentDeadline = ''
vendorSearch.value = ''
selectedVendor.value = null
attachedFiles.value = []
currentStep.value = 0
errorMessage.value = null
}
// ── Edit Cost Hydration ──
watch(() => props.editCost, (cost) => {
if (!cost) return
// Populate top-level fields
if (cost.date) {
form.date = getLocalDateString(cost.date)
}
if (cost.category_id) {
form.mainCategory = String(cost.category_id)
}
if (cost.amount_gross) {
form.grossAmount = Number(cost.amount_gross)
}
if (cost.currency) {
form.currency = cost.currency
}
// Populate JSONB data fields
const data = cost.data || {}
if (data.main_category_id) {
form.mainCategory = String(data.main_category_id)
}
if (data.sub_category_id) {
form.subCategory = String(data.sub_category_id)
}
if (data.net_amount) {
form.netAmount = Number(data.net_amount)
}
if (data.vat_rate) {
form.vatRate = Number(data.vat_rate)
}
if (data.invoice_number) {
form.invoiceNumber = data.invoice_number
}
if (data.invoice_date) {
form.invoiceDate = getLocalDateString(data.invoice_date)
}
if (data.payment_deadline) {
form.paymentDeadline = getLocalDateString(data.payment_deadline)
}
if (data.vendor_id) {
// Try to find the vendor in the loaded list
const vendor = vendors.value.find(
(v) => v.organization_id === data.vendor_id
)
if (vendor) {
selectedVendor.value = vendor
vendorSearch.value = vendor.display_name || vendor.name
}
}
if (data.vendor_name && !selectedVendor.value) {
vendorSearch.value = data.vendor_name
}
// Reset step to beginning for editing
currentStep.value = 0
errorMessage.value = null
}, { immediate: true })
// ── Lifecycle ──
watch(() => props.isOpen, (open) => {
if (open) {
currentStep.value = 0
form.date = getLocalDateString()
errorMessage.value = null
fetchCategories()
fetchVendors()
}
})
onMounted(() => {
if (props.isOpen) {
fetchCategories()
fetchVendors()
}
})
</script>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
</style>

View File

@@ -19,6 +19,37 @@
<span class="text-white font-bold text-lg tracking-wide">📊 {{ t('finance.costManager') }}</span> <span class="text-white font-bold text-lg tracking-wide">📊 {{ t('finance.costManager') }}</span>
</div> </div>
<!-- Control Bar: Filter + Add Button -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 shrink-0">
<!-- Left: Vehicle Filter Dropdown -->
<div class="flex items-center gap-3">
<label class="text-sm font-semibold text-slate-600 whitespace-nowrap">{{ t('finance.filterByVehicle') || 'Jármű szűrő' }}:</label>
<select
v-model="selectedVehicleFilter"
class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all min-w-[220px] appearance-none cursor-pointer"
@change="onVehicleFilterChange"
>
<option value="">{{ t('finance.allVehicles') || 'Teljes flotta (Összes jármű)' }}</option>
<option
v-for="v in vehicleStore.sortedVehicles"
:key="v.id"
:value="v.id"
>
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
</option>
</select>
</div>
<!-- Right: Add Cost Button -->
<button
@click="openCostWizard"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
>
<span class="text-base"></span>
{{ t('finance.addCost') || 'Új Költség' }}
</button>
</div>
<!-- Scrollable Content --> <!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-6 text-slate-800"> <div class="flex-1 overflow-y-auto p-6 text-slate-800">
<!-- Description --> <!-- Description -->
@@ -45,7 +76,7 @@
@click="fetchCosts" @click="fetchCosts"
class="mt-3 text-sm text-sf-accent hover:underline font-medium" class="mt-3 text-sm text-sf-accent hover:underline font-medium"
> >
{{ t('finance.retry') }} {{ t('common.retry') }}
</button> </button>
</div> </div>
@@ -88,22 +119,22 @@
<div class="col-span-2"> <div class="col-span-2">
<span <span
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-medium" class="inline-block rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="costTypeBadge(cost.cost_type)" :class="costTypeBadge(cost.category_code || cost.cost_type)"
> >
{{ cost.cost_type || '—' }} {{ cost.category_name || cost.cost_type || '—' }}
</span> </span>
</div> </div>
<!-- Amount --> <!-- Amount -->
<div class="col-span-2 text-sm font-bold text-slate-800"> <div class="col-span-2 text-sm font-bold text-slate-800">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costAmount') }}:</span> <span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costAmount') }}:</span>
{{ formatAmount(cost.amount, cost.currency) }} {{ formatAmount(cost.amount_gross, cost.currency) }}
</div> </div>
<!-- Vehicle --> <!-- Vehicle -->
<div class="col-span-3 text-sm text-slate-600 truncate"> <div class="col-span-3 text-sm text-slate-600 truncate">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costVehicle') }}:</span> <span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costVehicle') }}:</span>
{{ cost.vehicle_label || cost.license_plate || '—' }} {{ cost.vehicle_name || cost.license_plate || '—' }}
</div> </div>
<!-- Description --> <!-- Description -->
@@ -146,6 +177,14 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Cost Entry Wizard -->
<CostEntryWizard
:is-open="showWizard"
:vehicle="selectedVehicleForWizard"
@close="closeCostWizard"
@saved="onCostSaved"
/>
</div> </div>
</Teleport> </Teleport>
</template> </template>
@@ -154,8 +193,12 @@
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import api from '../../api/axios' import api from '../../api/axios'
import { useVehicleStore } from '../../stores/vehicle'
import type { Vehicle } from '../../stores/vehicle'
import CostEntryWizard from './CostEntryWizard.vue'
const { t } = useI18n() const { t } = useI18n()
const vehicleStore = useVehicleStore()
// ── Props ── // ── Props ──
const props = defineProps<{ const props = defineProps<{
@@ -174,6 +217,11 @@ const currentPage = ref(1)
const totalPages = ref(1) const totalPages = ref(1)
const pageSize = 20 const pageSize = 20
// ── Vehicle Filter State ──
const selectedVehicleFilter = ref('')
const showWizard = ref(false)
const selectedVehicleForWizard = ref<Vehicle | null>(null)
// ── Helpers ── // ── Helpers ──
function formatDate(dateStr: string | null): string { function formatDate(dateStr: string | null): string {
@@ -186,9 +234,10 @@ function formatDate(dateStr: string | null): string {
}) })
} }
function formatAmount(amount: number | null, currency?: string): string { function formatAmount(amount: string | number | null, currency?: string): string {
if (amount == null) return '—' if (amount == null) return '—'
const formatted = Number(amount).toLocaleString(undefined, { const num = typeof amount === 'string' ? parseFloat(amount) : amount
const formatted = Number(num).toLocaleString(undefined, {
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}) })
@@ -199,21 +248,27 @@ function costTypeBadge(type: string | null): string {
switch (type) { switch (type) {
case 'fuel': case 'fuel':
case 'FUEL': case 'FUEL':
case 'Üzemanyag':
return 'bg-orange-100 text-orange-700' return 'bg-orange-100 text-orange-700'
case 'service': case 'service':
case 'SERVICE': case 'SERVICE':
case 'Szerviz':
return 'bg-blue-100 text-blue-700' return 'bg-blue-100 text-blue-700'
case 'insurance': case 'insurance':
case 'INSURANCE': case 'INSURANCE':
case 'Biztosítás':
return 'bg-purple-100 text-purple-700' return 'bg-purple-100 text-purple-700'
case 'tax': case 'tax':
case 'TAX': case 'TAX':
case 'Adó':
return 'bg-red-100 text-red-700' return 'bg-red-100 text-red-700'
case 'parking': case 'parking':
case 'PARKING': case 'PARKING':
case 'Parkolás':
return 'bg-amber-100 text-amber-700' return 'bg-amber-100 text-amber-700'
case 'toll': case 'toll':
case 'TOLL': case 'TOLL':
case 'Útdíj':
return 'bg-yellow-100 text-yellow-700' return 'bg-yellow-100 text-yellow-700'
default: default:
return 'bg-slate-100 text-slate-600' return 'bg-slate-100 text-slate-600'
@@ -227,15 +282,19 @@ async function fetchCosts() {
error.value = null error.value = null
try { try {
const res = await api.get('/expenses', { const params: Record<string, any> = {
params: { page: currentPage.value,
page: currentPage.value, page_size: pageSize,
page_size: pageSize, }
}, // Add asset_id filter if a specific vehicle is selected
}) if (selectedVehicleFilter.value) {
params.asset_id = selectedVehicleFilter.value
}
const res = await api.get('/expenses', { params })
const data = res.data const data = res.data
costs.value = data.data || data.items || data.results || [] costs.value = data.data || data.items || data.results || []
totalPages.value = data.pagination?.total_pages || data.total_pages || 1 totalPages.value = data.total_pages || data.pagination?.total_pages || 1
} catch (err: any) { } catch (err: any) {
const message = const message =
err.response?.data?.detail || err.response?.data?.detail ||
@@ -248,6 +307,11 @@ async function fetchCosts() {
} }
} }
function onVehicleFilterChange() {
currentPage.value = 1
fetchCosts()
}
function prevPage() { function prevPage() {
if (currentPage.value > 1) { if (currentPage.value > 1) {
currentPage.value-- currentPage.value--
@@ -262,16 +326,49 @@ function nextPage() {
} }
} }
// ── Cost Entry Wizard ──
function openCostWizard() {
// If a vehicle is selected in the filter, pass it to the wizard
if (selectedVehicleFilter.value) {
selectedVehicleForWizard.value =
vehicleStore.vehicles.find(v => v.id === selectedVehicleFilter.value) || null
} else {
selectedVehicleForWizard.value = null
}
showWizard.value = true
}
function closeCostWizard() {
showWizard.value = false
selectedVehicleForWizard.value = null
}
function onCostSaved() {
// Refresh the table data after a cost is saved
closeCostWizard()
currentPage.value = 1
fetchCosts()
}
// ── Lifecycle ── // ── Lifecycle ──
watch(() => props.isOpen, (open) => { watch(() => props.isOpen, (open) => {
if (open) { if (open) {
currentPage.value = 1 currentPage.value = 1
selectedVehicleFilter.value = ''
// Ensure vehicles are loaded for the filter dropdown
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
fetchCosts() fetchCosts()
} }
}) })
onMounted(() => { onMounted(() => {
if (props.isOpen) { if (props.isOpen) {
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
fetchCosts() fetchCosts()
} }
}) })

View File

@@ -97,6 +97,10 @@ const props = withDefaults(defineProps<{
zone: 'dashboard_widget', zone: 'dashboard_widget',
}) })
const emit = defineEmits<{
(e: 'ads-loaded', hasAds: boolean): void
}>()
const isLoading = ref(true) const isLoading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const ads = ref<AdData[]>([]) const ads = ref<AdData[]>([])
@@ -108,6 +112,7 @@ async function fetchAds() {
try { try {
const res = await api.get<PlacementResponse>(`/marketing/placements/${props.zone}`) const res = await api.get<PlacementResponse>(`/marketing/placements/${props.zone}`)
ads.value = res.data.ads || [] ads.value = res.data.ads || []
emit('ads-loaded', ads.value.length > 0)
} catch (err: any) { } catch (err: any) {
// Silently handle 404 (placement not configured yet) — no console noise // Silently handle 404 (placement not configured yet) — no console noise
if (err.response?.status === 404) { if (err.response?.status === 404) {
@@ -117,6 +122,7 @@ async function fetchAds() {
error.value = 'Failed to load ads' error.value = 'Failed to load ads'
console.error('[AdPlacementWidget] Error:', err) console.error('[AdPlacementWidget] Error:', err)
} }
emit('ads-loaded', false)
} finally { } finally {
isLoading.value = false isLoading.value = false
} }

View File

@@ -2,9 +2,15 @@
<div <div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl" class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
> >
<!-- Top header bar --> <!-- Top header bar clickable to navigate to full Costs page -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2"> <div
class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2 cursor-pointer transition-colors hover:bg-slate-600"
@click="navigateToCostsPage"
>
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span> <span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span>
<svg class="ml-auto h-4 w-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden"> <div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
@@ -57,16 +63,27 @@
<span class="ml-auto text-slate-400 text-xs"></span> <span class="ml-auto text-slate-400 text-xs"></span>
</button> </button>
<!-- Additional Costs (Medium) --> <!-- Additional Costs (Medium) navigates to full Costs page -->
<button <button
v-if="selectedActionVehicle" v-if="selectedActionVehicle"
@click="openComplexModal" @click="navigateToCostsPage"
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]" class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
> >
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base"></span> <span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base"></span>
<span>{{ t('dashboard.additionalCosts') }}</span> <span>{{ t('dashboard.additionalCosts') }}</span>
<span class="ml-auto text-slate-400 text-xs"></span> <span class="ml-auto text-slate-400 text-xs"></span>
</button> </button>
<!-- 🧾 Detailed Invoice (Medium) -->
<button
v-if="selectedActionVehicle"
@click="openWizardModal"
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
>
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🧾</span>
<span>{{ t('dashboard.detailedInvoice') }}</span>
<span class="ml-auto text-slate-400 text-xs"></span>
</button>
</div> </div>
</div> </div>
@@ -93,19 +110,33 @@
@close="isComplexModalOpen = false" @close="isComplexModalOpen = false"
@saved="onModalSaved" @saved="onModalSaved"
/> />
<CostEntryWizard
:is-open="isWizardModalOpen"
:vehicle="selectedActionVehicle"
@close="isWizardModalOpen = false"
@saved="onModalSaved"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useVehicleStore } from '../../stores/vehicle' import { useVehicleStore } from '../../stores/vehicle'
import SimpleFuelModal from './SimpleFuelModal.vue' import SimpleFuelModal from './SimpleFuelModal.vue'
import ComplexExpenseModal from './ComplexExpenseModal.vue' import ComplexExpenseModal from './ComplexExpenseModal.vue'
import CostEntryWizard from '../cost/CostEntryWizard.vue'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter()
const vehicleStore = useVehicleStore() const vehicleStore = useVehicleStore()
/** Navigate to the full Costs page */
function navigateToCostsPage() {
router.push('/dashboard/costs')
}
// ── Emits ── // ── Emits ──
const emit = defineEmits<{ const emit = defineEmits<{
/** /**
@@ -120,6 +151,7 @@ const selectedActionVehicleId = ref<string>('')
const isFuelModalOpen = ref(false) const isFuelModalOpen = ref(false)
const isFeeModalOpen = ref(false) const isFeeModalOpen = ref(false)
const isComplexModalOpen = ref(false) const isComplexModalOpen = ref(false)
const isWizardModalOpen = ref(false)
/** The currently selected vehicle object for the action modals */ /** The currently selected vehicle object for the action modals */
const selectedActionVehicle = computed(() => { const selectedActionVehicle = computed(() => {
@@ -143,12 +175,17 @@ function openFeeModal() {
if (!selectedActionVehicle.value) return if (!selectedActionVehicle.value) return
isFeeModalOpen.value = true isFeeModalOpen.value = true
} }
function openComplexModal() { function openComplexModal() {
if (!selectedActionVehicle.value) return if (!selectedActionVehicle.value) return
isComplexModalOpen.value = true isComplexModalOpen.value = true
} }
function openWizardModal() {
if (!selectedActionVehicle.value) return
isWizardModalOpen.value = true
}
/** /**
* F5 Bug fix (RBAC Phase 3): Modal mentés után bezárja a modalt, * F5 Bug fix (RBAC Phase 3): Modal mentés után bezárja a modalt,
* és értesíti a szülőt, hogy az új költség megjelent. * és értesíti a szülőt, hogy az új költség megjelent.
@@ -157,6 +194,7 @@ function onModalSaved() {
isFuelModalOpen.value = false isFuelModalOpen.value = false
isFeeModalOpen.value = false isFeeModalOpen.value = false
isComplexModalOpen.value = false isComplexModalOpen.value = false
isWizardModalOpen.value = false
// Értesítjük a szülőt, hogy frissítse a VehicleDetailModal költségeit // Értesítjük a szülőt, hogy frissítse a VehicleDetailModal költségeit
if (selectedActionVehicleId.value) { if (selectedActionVehicleId.value) {

View File

@@ -37,7 +37,7 @@
@click="retry" @click="retry"
class="mt-2 text-xs text-sf-accent hover:underline" class="mt-2 text-xs text-sf-accent hover:underline"
> >
{{ t('finance.retry') }} {{ t('common.retry') }}
</button> </button>
</div> </div>

View File

@@ -5,7 +5,7 @@
> >
<!-- HEADER: Profile title --> <!-- HEADER: Profile title -->
<template #header> <template #header>
<span class="text-white font-bold text-sm tracking-wide">🛡 {{ t('header.profile') }}</span> <span class="text-white font-bold text-sm tracking-wide">🛡 {{ t('common.profileSettings') }}</span>
</template> </template>
<!-- BODY: User info + Trust Score + Stats --> <!-- BODY: User info + Trust Score + Stats -->
@@ -41,7 +41,7 @@
<!-- FOOTER: CTA button --> <!-- FOOTER: CTA button -->
<template #footer> <template #footer>
<button class="w-full py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md"> <button class="w-full py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('header.profile') }} {{ t('common.profileSettings') }}
</button> </button>
</template> </template>
</BaseCard> </BaseCard>

View File

@@ -116,7 +116,7 @@
</div> </div>
<div> <div>
<label class="mb-1.5 block text-sm font-medium text-slate-700"> <label class="mb-1.5 block text-sm font-medium text-slate-700">
{{ t('vehicle.label_vin') }} <span class="text-slate-400 text-xs">({{ t('common.optional') || 'opcionális' }})</span> {{ t('vehicle.vin') }} <span class="text-slate-400 text-xs">({{ t('common.optional') || 'opcionális' }})</span>
</label> </label>
<input <input
v-model="form.vin" v-model="form.vin"
@@ -145,7 +145,7 @@
</div> </div>
<div> <div>
<label class="mb-1.5 block text-sm font-medium text-slate-700"> <label class="mb-1.5 block text-sm font-medium text-slate-700">
{{ t('vehicle.label_vin') }} <span class="text-slate-400 text-xs">({{ t('common.optional') || 'opcionális' }})</span> {{ t('vehicle.vin') }} <span class="text-slate-400 text-xs">({{ t('common.optional') || 'opcionális' }})</span>
</label> </label>
<input <input
v-model="form.vin" v-model="form.vin"
@@ -408,13 +408,40 @@
/> />
</div> </div>
<div> <div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_power_kw') || 'Teljesítmény (kW)' }}</label> <label class="mb-1.5 block text-sm font-medium text-slate-700">
<span class="flex items-center gap-2">
{{ powerUnit === 'kw'
? (t('vehicle.label_power_kw') || 'Teljesítmény (kW)')
: (t('vehicle.label_power_le') || 'Teljesítmény (LE)')
}}
<!-- LE/KW Toggle -->
<button
type="button"
@click="togglePowerUnit"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-sf-accent/30"
:class="powerUnit === 'kw' ? 'bg-sf-accent' : 'bg-amber-500'"
>
<span
class="inline-flex h-3.5 w-3.5 items-center rounded-full bg-white text-[7px] font-bold shadow-sm transition-all duration-200"
:class="powerUnit === 'kw' ? 'translate-x-0.5 justify-center' : 'translate-x-4.5 justify-end pr-0.5'"
>
{{ powerUnit === 'kw' ? 'kW' : 'LE' }}
</span>
</button>
<span class="text-[10px] font-semibold text-slate-400 uppercase tracking-wider">{{ t('vehicleDetail.powerUnitLabel') }}</span>
</span>
</label>
<input <input
v-model="form.power_kw" :value="powerInputValue"
@input="onPowerInput"
type="number" type="number"
min="0" min="0"
step="0.1"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
:placeholder="t('vehicle.placeholder_power_kw') || '150'" :placeholder="powerUnit === 'kw'
? (t('vehicle.placeholder_power_kw') || '150')
: (t('vehicle.placeholder_power_le') || '204')
"
/> />
<p v-if="hpDisplay" class="text-xs text-slate-500 mt-0.5">{{ hpDisplay }}</p> <p v-if="hpDisplay" class="text-xs text-slate-500 mt-0.5">{{ hpDisplay }}</p>
</div> </div>
@@ -912,47 +939,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Dates Section -->
<div class="rounded-xl border border-slate-200 bg-slate-50/50 p-4 space-y-4">
<h4 class="text-sm font-bold text-slate-700 uppercase tracking-wider">&#128197; {{ t('vehicle.label_dates_section') || 'Dátumok' }}</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_first_registration') || 'Első forgalomba helyezés' }}</label>
<input
v-model="form.first_registration_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_next_mot') || 'Következő műszaki érvényesség' }}</label>
<input
v-model="form.next_mot_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_insurance_expiry') || 'Biztosítás lejárta' }}</label>
<input
v-model="form.insurance_expiry_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_purchase_date') || 'Vásárlás dátuma' }}</label>
<input
v-model="form.purchase_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
</div>
</div> </div>
<!-- --> <!-- -->
@@ -981,6 +967,41 @@
</div> </div>
</div> </div>
<!-- -->
<!-- DÁTUMOK SECTION (áthelyezve a Műszaki adatok alól) -->
<!-- -->
<div class="rounded-xl border border-slate-200 bg-slate-50/50 p-4 space-y-4">
<h4 class="text-sm font-bold text-slate-700 uppercase tracking-wider">&#128197; {{ t('vehicle.label_dates_section') || 'Dátumok' }}</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_first_registration') || 'Első forgalomba helyezés' }}</label>
<input
v-model="form.first_registration_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_insurance_expiry') || 'Biztosítás lejárta' }}</label>
<input
v-model="form.insurance_expiry_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_purchase_date') || 'Vásárlás dátuma' }}</label>
<input
v-model="form.purchase_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
</div>
<!-- --> <!-- -->
<!-- WARRANTY SECTION --> <!-- WARRANTY SECTION -->
<!-- --> <!-- -->
@@ -1005,6 +1026,111 @@
</div> </div>
</div> </div>
<!-- -->
<!-- REGISTRATION DOCUMENTS SECTION -->
<!-- -->
<div class="border-t border-slate-200 pt-4">
<h4 class="text-sm font-semibold text-slate-800 mb-3">{{ t('vehicle.registration_documents_title') || 'Forgalmi engedély és Törzskönyv' }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_registration_certificate_number') || 'Forgalmi engedély száma' }}</label>
<input
v-model="form.registration_certificate_number"
type="text"
maxlength="50"
:placeholder="t('vehicle.placeholder_registration_certificate_number') || 'Pl. AB-123456'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_registration_certificate_validity') || 'Forgalmi engedély érvényessége (megfelel a műszaki érvényességnek)' }}</label>
<input
v-model="form.registration_certificate_validity"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_vehicle_registration_document_number') || 'Törzskönyv száma' }}</label>
<input
v-model="form.vehicle_registration_document_number"
type="text"
maxlength="50"
:placeholder="t('vehicle.placeholder_vehicle_registration_document_number') || 'Pl. 1234567890'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
</div>
<!-- -->
<!-- ADMIN / EXTENDED ATTRIBUTES SECTION -->
<!-- -->
<div class="border-t border-slate-200 pt-4">
<h4 class="text-sm font-semibold text-slate-800 mb-3">{{ t('vehicle.admin_section_title') || 'Adminisztratív adatok' }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_registration_country') || 'Regisztráció országa' }}</label>
<input
v-model="form.registration_country"
type="text"
maxlength="100"
:placeholder="t('vehicle.placeholder_registration_country') || 'Pl. Magyarország'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_first_domestic_registration_date') || 'Első belföldi forgalomba helyezés' }}</label>
<input
v-model="form.first_domestic_registration_date"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_import_country') || 'Import ország' }}</label>
<input
v-model="form.import_country"
type="text"
maxlength="100"
:placeholder="t('vehicle.placeholder_import_country') || 'Pl. Németország'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_title_document_number') || 'Törzskönyv / Tulajdoni lap száma' }}</label>
<input
v-model="form.title_document_number"
type="text"
maxlength="50"
:placeholder="t('vehicle.placeholder_title_document_number') || 'Pl. TL-123456'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_engine_number') || 'Motorszám' }}</label>
<input
v-model="form.engine_number"
type="text"
maxlength="50"
:placeholder="t('vehicle.placeholder_engine_number') || 'Pl. M123456789'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_number_of_previous_owners') || 'Előző tulajdonosok száma' }}</label>
<input
v-model.number="form.number_of_previous_owners"
type="number"
min="0"
max="99"
:placeholder="t('vehicle.placeholder_number_of_previous_owners') || 'Pl. 2'"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
</div>
<!-- Notes --> <!-- Notes -->
<div> <div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_notes') || 'Megjegyzések' }}</label> <label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_notes') || 'Megjegyzések' }}</label>
@@ -1770,13 +1896,15 @@ const form = reactive({
ac_connector_type: '', ac_connector_type: '',
dc_connector_type: '', dc_connector_type: '',
first_registration_date: '', first_registration_date: '',
next_mot_date: '',
insurance_expiry_date: '', insurance_expiry_date: '',
purchase_date: '', purchase_date: '',
condition: '', condition: '',
color: '', color: '',
is_under_warranty: false, is_under_warranty: false,
warranty_expiry_date: '', warranty_expiry_date: '',
registration_certificate_number: '',
registration_certificate_validity: '',
vehicle_registration_document_number: '',
notes: '', notes: '',
features: [] as string[], features: [] as string[],
// Motorcycle-specific fields (stored in individual_equipment.motorcycle_specs) // Motorcycle-specific fields (stored in individual_equipment.motorcycle_specs)
@@ -1803,6 +1931,13 @@ const form = reactive({
ground_clearance_mm: null as number | null, ground_clearance_mm: null as number | null,
pto_type: '', pto_type: '',
has_differential_lock: false, has_differential_lock: false,
// ── New Admin Fields ──
registration_country: '',
first_domestic_registration_date: '',
import_country: '',
title_document_number: '',
engine_number: '',
number_of_previous_owners: null as number | null,
}) })
// ── Archive Form ── // ── Archive Form ──
@@ -1811,6 +1946,36 @@ const archiveForm = reactive({
reason: '', reason: '',
}) })
// ── Power Unit Toggle (LE / KW) ──
const powerUnit = ref<'kw' | 'le'>('kw')
/** The display value shown in the power input, converted to the selected unit */
const powerInputValue = computed(() => {
if (form.power_kw === null || form.power_kw === undefined) return ''
if (powerUnit.value === 'kw') return form.power_kw
// Convert kW to LE (1 kW = 1.34102 HP/LE)
return Math.round(form.power_kw * 1.34102 * 10) / 10
})
function onPowerInput(e: Event) {
const target = e.target as HTMLInputElement
const raw = parseFloat(target.value)
if (isNaN(raw) || raw <= 0) {
form.power_kw = null
return
}
if (powerUnit.value === 'kw') {
form.power_kw = raw
} else {
// Convert LE back to kW for storage
form.power_kw = Math.round((raw / 1.34102) * 100) / 100
}
}
function togglePowerUnit() {
powerUnit.value = powerUnit.value === 'kw' ? 'le' : 'kw'
}
// ── Computed ── // ── Computed ──
const isEditMode = computed(() => !!props.vehicle) const isEditMode = computed(() => !!props.vehicle)
@@ -1989,13 +2154,15 @@ function resetForm() {
form.ac_connector_type = '' form.ac_connector_type = ''
form.dc_connector_type = '' form.dc_connector_type = ''
form.first_registration_date = '' form.first_registration_date = ''
form.next_mot_date = ''
form.insurance_expiry_date = '' form.insurance_expiry_date = ''
form.purchase_date = '' form.purchase_date = ''
form.condition = '' form.condition = ''
form.color = '' form.color = ''
form.is_under_warranty = false form.is_under_warranty = false
form.warranty_expiry_date = '' form.warranty_expiry_date = ''
form.registration_certificate_number = ''
form.registration_certificate_validity = ''
form.vehicle_registration_document_number = ''
form.notes = '' form.notes = ''
form.features = [] form.features = []
form.strokes = '' form.strokes = ''
@@ -2019,6 +2186,12 @@ function resetForm() {
form.ground_clearance_mm = null form.ground_clearance_mm = null
form.pto_type = '' form.pto_type = ''
form.has_differential_lock = false form.has_differential_lock = false
form.registration_country = ''
form.first_domestic_registration_date = ''
form.import_country = ''
form.title_document_number = ''
form.engine_number = ''
form.number_of_previous_owners = null
activeTab.value = 0 activeTab.value = 0
showArchiveConfirm.value = false showArchiveConfirm.value = false
archiveForm.final_mileage = 0 archiveForm.final_mileage = 0
@@ -2055,13 +2228,15 @@ function populateForm(vehicle: Vehicle) {
form.ac_connector_type = vehicle.ac_connector_type || '' form.ac_connector_type = vehicle.ac_connector_type || ''
form.dc_connector_type = vehicle.dc_connector_type || '' form.dc_connector_type = vehicle.dc_connector_type || ''
form.first_registration_date = vehicle.first_registration_date || '' form.first_registration_date = vehicle.first_registration_date || ''
form.next_mot_date = vehicle.next_mot_date || ''
form.insurance_expiry_date = vehicle.insurance_expiry_date || '' form.insurance_expiry_date = vehicle.insurance_expiry_date || ''
form.purchase_date = vehicle.purchase_date || '' form.purchase_date = vehicle.purchase_date || ''
form.condition = vehicle.condition || '' form.condition = vehicle.condition || ''
form.color = vehicle.color || '' form.color = vehicle.color || ''
form.is_under_warranty = vehicle.is_under_warranty ?? false form.is_under_warranty = vehicle.is_under_warranty ?? false
form.warranty_expiry_date = vehicle.warranty_expiry_date || '' form.warranty_expiry_date = vehicle.warranty_expiry_date || ''
form.registration_certificate_number = vehicle.registration_certificate_number || ''
form.registration_certificate_validity = vehicle.registration_certificate_validity || ''
form.vehicle_registration_document_number = vehicle.vehicle_registration_document_number || ''
form.notes = vehicle.notes || '' form.notes = vehicle.notes || ''
// Extract type_designation from individual_equipment JSONB // Extract type_designation from individual_equipment JSONB
@@ -2111,6 +2286,14 @@ function populateForm(vehicle: Vehicle) {
form.pto_type = hgvSpecs.pto_type || '' form.pto_type = hgvSpecs.pto_type || ''
form.has_differential_lock = hgvSpecs.has_differential_lock || false form.has_differential_lock = hgvSpecs.has_differential_lock || false
} }
// ── New Admin Fields ──
form.registration_country = vehicle.registration_country || ''
form.first_domestic_registration_date = vehicle.first_domestic_registration_date || ''
form.import_country = vehicle.import_country || ''
form.title_document_number = vehicle.title_document_number || ''
form.engine_number = vehicle.engine_number || ''
form.number_of_previous_owners = vehicle.number_of_previous_owners ?? null
} }
// ── Handle Save ── // ── Handle Save ──
@@ -2149,13 +2332,15 @@ async function handleSave() {
ac_connector_type: form.ac_connector_type || null, ac_connector_type: form.ac_connector_type || null,
dc_connector_type: form.dc_connector_type || null, dc_connector_type: form.dc_connector_type || null,
first_registration_date: form.first_registration_date || null, first_registration_date: form.first_registration_date || null,
next_mot_date: form.next_mot_date || null,
insurance_expiry_date: form.insurance_expiry_date || null, insurance_expiry_date: form.insurance_expiry_date || null,
purchase_date: form.purchase_date || null, purchase_date: form.purchase_date || null,
condition: form.condition || null, condition: form.condition || null,
color: form.color || null, color: form.color || null,
is_under_warranty: form.is_under_warranty, is_under_warranty: form.is_under_warranty,
warranty_expiry_date: form.warranty_expiry_date || null, warranty_expiry_date: form.warranty_expiry_date || null,
registration_certificate_number: form.registration_certificate_number || null,
registration_certificate_validity: form.registration_certificate_validity || null,
vehicle_registration_document_number: form.vehicle_registration_document_number || null,
notes: form.notes || null, notes: form.notes || null,
// Store type_designation and features in individual_equipment JSONB // Store type_designation and features in individual_equipment JSONB
individual_equipment: { individual_equipment: {
@@ -2196,6 +2381,13 @@ async function handleSave() {
has_differential_lock: form.has_differential_lock || false, has_differential_lock: form.has_differential_lock || false,
} : undefined, } : undefined,
}, },
// ── New Admin Fields ──
registration_country: form.registration_country || null,
first_domestic_registration_date: form.first_domestic_registration_date || null,
import_country: form.import_country || null,
title_document_number: form.title_document_number || null,
engine_number: form.engine_number || null,
number_of_previous_owners: form.number_of_previous_owners ?? null,
} }
if (props.vehicle) { if (props.vehicle) {

View File

@@ -42,7 +42,7 @@
@click="retry" @click="retry"
class="mt-3 text-sm text-sf-accent hover:underline font-medium" class="mt-3 text-sm text-sf-accent hover:underline font-medium"
> >
{{ t('finance.retry') }} {{ t('common.retry') }}
</button> </button>
</div> </div>

View File

@@ -135,7 +135,7 @@ onMounted(async () => {
await authStore.fetchMyOrganizations() await authStore.fetchMyOrganizations()
} }
console.log('🔍 [HeaderCompanySwitcher] All orgs:', authStore.myOrganizations) console.log('🔍 [HeaderCompanySwitcher] All orgs:', authStore.myOrganizations)
console.log('🔍 [HeaderCompanySwitcher] Company orgs (filtered):', companyOrganizations.value) // console.log('🔍 [HeaderCompanySwitcher] Company orgs (filtered):', companyOrganizations.value)
}) })
// ── Filter: only real companies (exclude individual/private orgs) ── // ── Filter: only real companies (exclude individual/private orgs) ──

View File

@@ -48,7 +48,28 @@
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/> />
</svg> </svg>
{{ t('header.profile') }} {{ t('common.profileSettings') }}
</button>
<!-- Subscription Info -->
<button
@click="openSubscriptionModal"
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="h-4 w-4 text-white/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
{{ t('header.subscription') }}
</button> </button>
<!-- Admin Center only for admin/superadmin roles --> <!-- Admin Center only for admin/superadmin roles -->
@@ -101,6 +122,12 @@
</div> </div>
</div> </div>
</Transition> </Transition>
<!-- Subscription Info Modal -->
<SubscriptionInfoModal
:visible="showSubscriptionModal"
@close="showSubscriptionModal = false"
/>
</div> </div>
</template> </template>
@@ -109,6 +136,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import SubscriptionInfoModal from '@/components/subscription/SubscriptionInfoModal.vue'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
@@ -116,6 +144,7 @@ const authStore = useAuthStore()
const isOpen = ref(false) const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const showSubscriptionModal = ref(false)
// ── Computed display helpers ─────────────────────────────────────── // ── Computed display helpers ───────────────────────────────────────
const fullName = computed(() => { const fullName = computed(() => {
@@ -163,6 +192,12 @@ function goToAdmin() {
router.push('/admin') router.push('/admin')
} }
// ── Open Subscription Info Modal ───────────────────────────────────
function openSubscriptionModal() {
isOpen.value = false
showSubscriptionModal.value = true
}
// ── Logout handler ───────────────────────────────────────────────── // ── Logout handler ─────────────────────────────────────────────────
function handleLogout() { function handleLogout() {
isOpen.value = false isOpen.value = false

View File

@@ -142,7 +142,7 @@
@click="isEditing ? cancelEdit() : $emit('close')" @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" 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') }} {{ isEditing ? t('common.cancel') : t('company.close') || t('header.close') }}
</button> </button>
<!-- Edit button (read-only mode) --> <!-- Edit button (read-only mode) -->
@@ -168,7 +168,7 @@
</svg> </svg>
{{ t('company.submitting') }} {{ t('company.submitting') }}
</span> </span>
<span v-else>{{ t('profile.save') }}</span> <span v-else>{{ t('common.save') }}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -109,7 +109,7 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg> </svg>
</span> </span>
<span v-else>{{ t('profile.save') }}</span> <span v-else>{{ t('common.save') }}</span>
</button> </button>
</div> </div>
<p v-if="org?.subscription_plan" class="text-xs text-white/40 mt-1"> <p v-if="org?.subscription_plan" class="text-xs text-white/40 mt-1">
@@ -169,7 +169,7 @@
@click="$emit('close')" @click="$emit('close')"
class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors" class="px-4 py-2 rounded-lg text-sm text-white/70 hover:text-white hover:bg-white/10 transition-colors"
> >
{{ t('company.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
@click="handleSave" @click="handleSave"
@@ -183,7 +183,7 @@
</svg> </svg>
{{ t('company.submitting') }} {{ t('company.submitting') }}
</span> </span>
<span v-else>{{ t('profile.save') }}</span> <span v-else>{{ t('common.save') }}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -86,7 +86,7 @@
<!-- Street Name --> <!-- Street Name -->
<div class="mb-3"> <div class="mb-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> <label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.streetNameLabel') }} {{ t('common.streetName') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.streetNameOptional') }})</span> <span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.streetNameOptional') }})</span>
</label> </label>
<input <input
@@ -188,7 +188,7 @@
<!-- Phone --> <!-- Phone -->
<div class="mb-3"> <div class="mb-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> <label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.phoneLabel') }} {{ t('common.phone') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.phoneOptional') }})</span> <span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.phoneOptional') }})</span>
</label> </label>
<input <input
@@ -202,7 +202,7 @@
<!-- Email --> <!-- Email -->
<div class="mb-3"> <div class="mb-3">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> <label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.emailLabel') }} {{ t('common.email') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.emailOptional') }})</span> <span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.emailOptional') }})</span>
</label> </label>
<input <input

View File

@@ -89,7 +89,7 @@
<!-- Street Name --> <!-- Street Name -->
<div class="mb-3"> <div class="mb-3">
<label class="mb-1.5 block text-sm font-semibold text-slate-700"> <label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.streetNameLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.streetNameOptional') }})</span> {{ t('common.streetName') }} <span class="text-slate-400 text-xs">({{ t('provider.streetNameOptional') }})</span>
</label> </label>
<input <input
v-model="form.address_street_name" v-model="form.address_street_name"
@@ -190,7 +190,7 @@
<!-- Phone --> <!-- Phone -->
<div class="mb-3"> <div class="mb-3">
<label class="mb-1.5 block text-sm font-semibold text-slate-700"> <label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.phoneLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.phoneOptional') }})</span> {{ t('common.phone') }} <span class="text-slate-400 text-xs">({{ t('provider.phoneOptional') }})</span>
</label> </label>
<input <input
v-model="form.phone" v-model="form.phone"
@@ -204,7 +204,7 @@
<!-- Email --> <!-- Email -->
<div class="mb-3"> <div class="mb-3">
<label class="mb-1.5 block text-sm font-semibold text-slate-700"> <label class="mb-1.5 block text-sm font-semibold text-slate-700">
{{ t('provider.emailLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.emailOptional') }})</span> {{ t('common.email') }} <span class="text-slate-400 text-xs">({{ t('provider.emailOptional') }})</span>
</label> </label>
<input <input
v-model="form.email" v-model="form.email"

View File

@@ -0,0 +1,278 @@
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="$emit('close')"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<!-- Modal Panel Dark Glassmorphism -->
<div
class="relative w-full max-w-md rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- HEADER -->
<div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
<div class="flex items-center gap-3">
<!-- Crown icon -->
<svg class="w-6 h-6 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<h2 class="text-lg font-bold text-white">
{{ t('subscription.subscriptionInfo') }}
</h2>
</div>
<button
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white/70 hover:text-white"
@click="$emit('close')"
>
<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-5 py-5 space-y-5">
<!-- Plan Name & Status Badge -->
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-white/50 uppercase tracking-wider">{{ t('subscription.currentPlan') }}</p>
<p class="text-xl font-bold text-white mt-0.5">
{{ planDisplayName }}
</p>
</div>
<span
:class="isExpired
? 'bg-red-500/20 text-red-400 border-red-500/30'
: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'"
class="px-3 py-1 rounded-full text-xs font-semibold border"
>
{{ isExpired ? t('subscription.expired') : t('subscription.active') }}
</span>
</div>
<!-- Expiry Date -->
<div class="rounded-xl bg-white/5 border border-white/10 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span class="text-sm text-white/70">{{ t('subscription.expiresAt') }}</span>
</div>
<span class="text-sm font-semibold text-white">
{{ formattedExpiry }}
</span>
</div>
<!-- Days remaining bar -->
<div v-if="!isExpired && daysRemaining !== null" class="mt-3">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-white/50">{{ t('subscription.daysLeft', { count: daysRemaining }) }}</span>
<span :class="daysRemaining <= 7 ? 'text-red-400' : 'text-emerald-400'">
{{ daysRemaining }} {{ t('subscription.daysLeftShort') }}
</span>
</div>
<div class="w-full h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
:class="daysRemaining <= 7 ? 'bg-red-500' : 'bg-emerald-500'"
class="h-full rounded-full transition-all duration-500"
:style="{ width: daysProgressPercent + '%' }"
/>
</div>
</div>
</div>
<!-- Vehicle Limit Progress -->
<div class="rounded-xl bg-white/5 border border-white/10 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-white/70 flex items-center gap-2">
<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 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
</svg>
{{ t('subscription.vehicleLimit') }}
</span>
<span class="text-sm font-semibold text-white">
{{ vehicleCount }} / {{ maxVehicles }}
</span>
</div>
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="vehiclePercent >= 90 ? 'bg-red-500' : vehiclePercent >= 70 ? 'bg-amber-500' : 'bg-blue-500'"
:style="{ width: vehiclePercent + '%' }"
/>
</div>
</div>
<!-- Garage Limit Progress -->
<div class="rounded-xl bg-white/5 border border-white/10 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-white/70 flex items-center gap-2">
<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('subscription.garageLimit') }}
</span>
<span class="text-sm font-semibold text-white">
{{ garageCount }} / {{ maxGarages }}
</span>
</div>
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="garagePercent >= 90 ? 'bg-red-500' : garagePercent >= 70 ? 'bg-amber-500' : 'bg-blue-500'"
:style="{ width: garagePercent + '%' }"
/>
</div>
</div>
</div>
<!-- FOOTER -->
<div class="px-5 py-4 border-t border-white/10 flex gap-3">
<button
@click="$emit('close')"
class="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-all duration-150"
>
{{ t('common.close') }}
</button>
<button
@click="goToPlans"
class="flex-1 px-4 py-2.5 rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 text-sm font-semibold text-white hover:from-blue-500 hover:to-indigo-500 transition-all duration-150 shadow-lg shadow-blue-600/20"
>
{{ t('subscription.managePlan') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useVehicleStore } from '@/stores/vehicle'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
// ── Computed: Plan display name ──
const planDisplayName = computed(() => {
const plan = authStore.user?.subscription_plan
if (!plan || plan === 'FREE') return t('subscription.free')
return plan
})
// ── Computed: Expiry date ──
const subscriptionExpiresAt = computed(() => {
return authStore.user?.subscription_expires_at ?? null
})
const formattedExpiry = computed(() => {
const raw = subscriptionExpiresAt.value
// P0: Show "Active" / "Indefinite" instead of em-dash when no expiry
if (!raw) return t('subscription.indefinite')
const d = new Date(raw)
if (isNaN(d.getTime())) return t('subscription.indefinite')
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}.${m}.${day}`
})
const isExpired = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return false
return new Date(raw) < new Date()
})
const daysRemaining = computed(() => {
const raw = subscriptionExpiresAt.value
if (!raw) return null
const now = new Date()
const expiry = new Date(raw)
const diff = expiry.getTime() - now.getTime()
if (diff <= 0) return 0
return Math.ceil(diff / (1000 * 60 * 60 * 24))
})
const daysProgressPercent = computed(() => {
// Assume a 365-day cycle for the progress bar
const days = daysRemaining.value
if (days === null) return 0
return Math.min(100, Math.max(0, (days / 365) * 100))
})
// ── Computed: Vehicle limit progress ──
// P0: Real vehicle count from the vehicle store
const vehicleCount = computed(() => {
return vehicleStore.vehicles.length
})
// P0: Real max vehicles from the backend subscription tier allowances
const maxVehicles = computed(() => {
return authStore.user?.max_vehicles ?? 1
})
const vehiclePercent = computed(() => {
if (maxVehicles.value === 0) return 0
return Math.min(100, Math.round((vehicleCount.value / maxVehicles.value) * 100))
})
// ── Computed: Garage limit progress ──
const garageCount = computed(() => {
// Use the actual organizations count from the auth store
return authStore.myOrganizations?.length ?? 0
})
// P0: Real max garages from the backend subscription tier allowances
const maxGarages = computed(() => {
return authStore.user?.max_garages ?? 1
})
const garagePercent = computed(() => {
if (maxGarages.value === 0) return 0
return Math.min(100, Math.round((garageCount.value / maxGarages.value) * 100))
})
// ── Navigation ──
// P0: Navigate to the correct subscription plans route
function goToPlans() {
emit('close')
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
router.push({
name: 'org-subscription',
params: { id: authStore.user.active_organization_id }
})
} else {
router.push({ name: 'subscription' })
}
}
</script>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>

View File

@@ -6,8 +6,8 @@
> >
<!-- Image Area (Top 40%) --> <!-- Image Area (Top 40%) -->
<div class="h-36 rounded-t-2xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center overflow-hidden relative"> <div class="h-36 rounded-t-2xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center overflow-hidden relative">
<template v-if="vehicle.image"> <template v-if="vehicle.image_url || vehicle.image">
<img :src="vehicle.image" :alt="vehicle.brand" class="h-full w-full object-cover" /> <img :src="vehicle.image_url || vehicle.image" :alt="vehicle.brand" class="h-full w-full object-cover" />
</template> </template>
<template v-else> <template v-else>
<!-- Car SVG silhouette placeholder --> <!-- Car SVG silhouette placeholder -->
@@ -59,7 +59,7 @@
<VehiclePlateBadge <VehiclePlateBadge
:license-plate="vehicle.license_plate" :license-plate="vehicle.license_plate"
:brand="vehicle.brand" :brand="vehicle.brand"
:country-code="vehicle.countryCode || vehicle.country_code" :country-code="vehicle.registration_country || vehicle.countryCode || vehicle.country_code"
size="md" size="md"
/> />
@@ -92,7 +92,7 @@
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h-2m2 4h-2m-4-4h-2m2 4h-2m6-8h2a2 2 0 012 2v4a2 2 0 01-2 2h-2m-4 0H8a2 2 0 01-2-2V8a2 2 0 012-2h4m0 0V4m0 4v4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h-2m2 4h-2m-4-4h-2m2 4h-2m6-8h2a2 2 0 012 2v4a2 2 0 01-2 2h-2m-4 0H8a2 2 0 01-2-2V8a2 2 0 012-2h4m0 0V4m0 4v4" />
</svg> </svg>
<span>{{ vehicle.engine || vehicle.fuel_type || '—' }}</span> <span>{{ vehicle.power_kw || vehicle.engine_capacity ? (vehicle.power_kw ? vehicle.power_kw + ' kW' : '') + (vehicle.engine_capacity ? ' / ' + vehicle.engine_capacity + ' cm³' : '') : vehicle.engine || vehicle.fuel_type || '—' }}</span>
</div> </div>
<!-- Color --> <!-- Color -->
@@ -100,7 +100,7 @@
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-3.5 w-3.5 shrink-0 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg> </svg>
<span>{{ vehicle.individual_equipment?.color || vehicle.color || '—' }}</span> <span>{{ vehicle.individual_equipment?.color || vehicle.color || t('common.na') }}</span>
</div> </div>
<!-- Next Service (highlighted) --> <!-- Next Service (highlighted) -->
@@ -111,7 +111,7 @@
<svg class="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<span class="font-semibold">{{ t('vehicle.nextService') }}: {{ vehicle.nextService || t('vehicle.noData') }}</span> <span class="font-semibold">{{ t('common.nextService') }}: {{ vehicle.nextService || t('common.noData') }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -130,6 +130,14 @@
</svg> </svg>
</button> </button>
<!-- Primary Vehicle Badge Text (below star) -->
<div
v-if="vehicle.is_primary"
class="absolute top-11 left-3 z-20 rounded-full bg-amber-400/90 px-2 py-0.5 text-[10px] font-semibold text-white shadow-sm backdrop-blur-sm"
>
{{ t('vehicle.primaryVehicle') }}
</div>
<!-- Edit Button (top-right overlay) --> <!-- Edit Button (top-right overlay) -->
<button <button
class="absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-slate-400 opacity-0 shadow-sm backdrop-blur-sm transition-all hover:bg-sf-accent hover:text-white group-hover:opacity-100 z-20" class="absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-slate-400 opacity-0 shadow-sm backdrop-blur-sm transition-all hover:bg-sf-accent hover:text-white group-hover:opacity-100 z-20"

View File

@@ -58,14 +58,14 @@
<div class="flex justify-center pt-6 pb-4"> <div class="flex justify-center pt-6 pb-4">
<VehiclePlateBadge <VehiclePlateBadge
:license-plate="vehicle?.license_plate || ''" :license-plate="vehicle?.license_plate || ''"
:country-code="vehicle?.countryCode || vehicle?.country_code" :country-code="vehicle?.registration_country || vehicle?.countryCode || vehicle?.country_code"
size="lg" size="lg"
/> />
</div> </div>
<!-- <!--
4-TAB NAVIGATION 5-TAB NAVIGATION
--> -->
<div class="px-6"> <div class="px-6">
<div class="flex border-b border-slate-200 gap-1"> <div class="flex border-b border-slate-200 gap-1">
<button <button
@@ -93,9 +93,28 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Engine --> <!-- Engine -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4"> <div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Motor</p> <div class="flex items-center justify-between mb-1">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider">Motor</p>
<!-- LE/KW Display Toggle -->
<div v-if="vehicle?.power_kw" class="flex items-center gap-1.5">
<span class="text-[9px] font-semibold text-slate-400 uppercase tracking-wider">{{ t('vehicleDetail.powerUnitLabel') }}</span>
<button
type="button"
@click="togglePowerUnit"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-sf-accent/30"
:class="powerUnit === 'kw' ? 'bg-sf-accent' : 'bg-amber-500'"
>
<span
class="inline-flex h-3.5 w-3.5 items-center rounded-full bg-white text-[7px] font-bold shadow-sm transition-all duration-200"
:class="powerUnit === 'kw' ? 'translate-x-0.5 justify-center' : 'translate-x-4.5 justify-end pr-0.5'"
>
{{ powerUnit === 'kw' ? 'kW' : 'LE' }}
</span>
</button>
</div>
</div>
<p class="text-sm font-bold text-slate-800">{{ engineDisplay || '—' }}</p> <p class="text-sm font-bold text-slate-800">{{ engineDisplay || '—' }}</p>
<p v-if="vehicle?.power_kw" class="text-xs text-slate-500 mt-0.5">{{ vehicle.power_kw }} kW ({{ hpDisplay }})</p> <p v-if="vehicle?.power_kw" class="text-xs text-slate-500 mt-0.5">{{ powerDetailDisplay }}</p>
</div> </div>
<!-- Body / Karosszéria --> <!-- Body / Karosszéria -->
@@ -198,7 +217,7 @@
:key="feat" :key="feat"
class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 border border-emerald-200 shadow-sm" class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 border border-emerald-200 shadow-sm"
> >
{{ t('vehicle.features.' + feat, feat) }} {{ getFeatureTranslation(feat) }}
</span> </span>
</div> </div>
</div> </div>
@@ -260,17 +279,134 @@
</div> </div>
</div> </div>
<!-- TAB 2: Pénzügyek / TCO --> <!-- TAB 2: Műszaki adatok (Tech Data) -->
<div v-if="activeTab === 'techdata'" class="space-y-5">
<TechDataTab />
</div>
<!-- TAB 3: Pénzügyek / TCO -->
<div v-if="activeTab === 'finance'" class="space-y-5"> <div v-if="activeTab === 'finance'" class="space-y-5">
<!-- Loading State --> <!-- Loading State -->
<div <div
v-if="costsLoading" v-if="financeLoading"
class="flex items-center justify-center py-12" 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 class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div> </div>
<template v-if="!costsLoading"> <template v-if="!financeLoading">
<!-- Financials (Leasing / Purchase) -->
<div v-if="assetFinancials" class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">{{ t('finance.purchasing_section_title') }}</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-if="assetFinancials.purchase_price_gross">
<p class="text-xs text-slate-500">{{ t('finance.purchase_price_gross') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatAmount(assetFinancials.purchase_price_gross) }}</p>
</div>
<div v-if="assetFinancials.down_payment">
<p class="text-xs text-slate-500">{{ t('finance.down_payment') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatAmount(assetFinancials.down_payment) }}</p>
</div>
<div v-if="assetFinancials.monthly_installment">
<p class="text-xs text-slate-500">{{ t('finance.monthly_installment') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatAmount(assetFinancials.monthly_installment) }}</p>
</div>
<div v-if="assetFinancials.contract_number">
<p class="text-xs text-slate-500">{{ t('finance.contract_number') }}</p>
<p class="text-sm font-bold text-slate-800 font-mono">{{ assetFinancials.contract_number }}</p>
</div>
<div v-if="assetFinancials.financing_provider">
<p class="text-xs text-slate-500">{{ t('finance.financing_provider') }}</p>
<p class="text-sm font-bold text-slate-800">{{ assetFinancials.financing_provider }}</p>
</div>
<div v-if="assetFinancials.contract_start_date">
<p class="text-xs text-slate-500">{{ t('finance.contract_start_date') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatDate(assetFinancials.contract_start_date) }}</p>
</div>
<div v-if="assetFinancials.contract_end_date">
<p class="text-xs text-slate-500">{{ t('finance.contract_end_date') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatDate(assetFinancials.contract_end_date) }}</p>
</div>
<div v-if="assetFinancials.residual_value">
<p class="text-xs text-slate-500">{{ t('finance.residual_value') }}</p>
<p class="text-sm font-bold text-slate-800">{{ formatAmount(assetFinancials.residual_value) }}</p>
</div>
<div v-if="assetFinancials.interest_rate">
<p class="text-xs text-slate-500">{{ t('finance.interest_rate') }}</p>
<p class="text-sm font-bold text-slate-800">{{ assetFinancials.interest_rate }}%</p>
</div>
</div>
</div>
<!-- Insurance Policies -->
<div v-if="insurancePolicies.length > 0" class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">{{ t('finance.insurance_section_title') }}</p>
<div class="space-y-3">
<div
v-for="policy in insurancePolicies"
:key="policy.id"
class="rounded-lg border border-slate-200 bg-white p-3"
>
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-bold text-slate-800">{{ policy.insurer_name || '—' }}</p>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 uppercase">{{ policy.policy_type || '—' }}</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-slate-500">
<div v-if="policy.policy_number">
<span class="font-semibold text-slate-600">{{ t('finance.policy_number') }}:</span> {{ policy.policy_number }}
</div>
<div v-if="policy.premium">
<span class="font-semibold text-slate-600">{{ t('finance.premium') }}:</span> {{ formatAmount(policy.premium) }}
</div>
<div v-if="policy.valid_from">
<span class="font-semibold text-slate-600">{{ t('finance.valid_from') }}:</span> {{ formatDate(policy.valid_from) }}
</div>
<div v-if="policy.valid_until">
<span class="font-semibold text-slate-600">{{ t('finance.valid_until') }}:</span> {{ formatDate(policy.valid_until) }}
</div>
</div>
</div>
</div>
</div>
<!-- Tax Obligations -->
<div v-if="taxObligations.length > 0" class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">{{ t('finance.tax_section_title') }}</p>
<div class="space-y-3">
<div
v-for="tax in taxObligations"
:key="tax.id"
class="rounded-lg border border-slate-200 bg-white p-3"
>
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-bold text-slate-800">{{ tax.tax_type || '—' }}</p>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="tax.paid ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'"
>
{{ tax.paid ? t('finance.paid') : t('finance.unpaid') }}
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-slate-500">
<div v-if="tax.amount">
<span class="font-semibold text-slate-600">{{ t('finance.amount') }}:</span> {{ formatAmount(tax.amount) }}
</div>
<div v-if="tax.due_date">
<span class="font-semibold text-slate-600">{{ t('finance.due_date') }}:</span> {{ formatDate(tax.due_date) }}
</div>
</div>
</div>
</div>
</div>
<!-- Empty state for financial data -->
<div
v-if="!assetFinancials && insurancePolicies.length === 0 && taxObligations.length === 0"
class="rounded-xl border border-slate-200 bg-slate-50 p-6 text-center"
>
<p class="text-sm text-slate-500">{{ t('finance.noFinancialData') }}</p>
</div>
<!-- Annual Total Cost Hero --> <!-- 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"> <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-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Éves Összköltség (TCO)</p>
@@ -416,7 +552,7 @@
</template> </template>
</div> </div>
<!-- TAB 3: Figyelmeztetések & Időpontok --> <!-- TAB 4: Figyelmeztetések & Időpontok -->
<div v-if="activeTab === 'alerts'" class="space-y-4"> <div v-if="activeTab === 'alerts'" class="space-y-4">
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Idővonal Közelgő események</p> <p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Idővonal Közelgő események</p>
@@ -509,7 +645,7 @@
</div> </div>
</div> </div>
<!-- TAB 4: Szervizkönyv (Service Book) --> <!-- TAB 5: Szervizkönyv (Service Book) -->
<div v-if="activeTab === 'servicebook'" class="space-y-5"> <div v-if="activeTab === 'servicebook'" class="space-y-5">
<!-- Loading State --> <!-- Loading State -->
<div <div
@@ -636,7 +772,7 @@
@click="showAddForm = false" @click="showAddForm = false"
class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-600 transition-all hover:bg-slate-100 cursor-pointer" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-600 transition-all hover:bg-slate-100 cursor-pointer"
> >
{{ $t('servicebook.cancel') }} {{ $t('common.cancel') }}
</button> </button>
<button <button
@click="submitEvent" @click="submitEvent"
@@ -647,7 +783,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> <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" /> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg> </svg>
{{ savingEvent ? $t('servicebook.saving') : t('common.save') }} {{ savingEvent ? $t('common.saving') : t('common.save') }}
</button> </button>
</div> </div>
</div> </div>
@@ -754,7 +890,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch, provide } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { getLocalDateString } from '../../services/dateUtils' import { getLocalDateString } from '../../services/dateUtils'
import type { VehicleData } from '../../types/vehicle' import type { VehicleData } from '../../types/vehicle'
@@ -827,16 +963,37 @@ const vehicleFeatures = computed(() => {
return Array.isArray(features) ? features : [] return Array.isArray(features) ? features : []
}) })
/**
* i18n feature fallback: tries to translate a feature key with category-specific
* fallback support. If neither `vehicle.features.{feat}` nor
* `vehicle.{category}Features.{feat}` exists, returns the raw key.
*/
function getFeatureTranslation(feat: string): string {
const { te, t } = useI18n()
if (te(`vehicle.features.${feat}`)) return t(`vehicle.features.${feat}`)
// Attempt category-specific fallback (e.g. vehicle.safetyFeatures.ABS)
const category = props.vehicle?.individual_equipment?.car_specs?.body_type || ''
if (category && te(`vehicle.${category}Features.${feat}`)) {
return t(`vehicle.${category}Features.${feat}`)
}
return feat
}
// ── Tab state ── // ── Tab state ──
const activeTab = ref<'basics' | 'finance' | 'alerts' | 'servicebook'>('basics') const activeTab = ref<'basics' | 'techdata' | 'finance' | 'alerts' | 'servicebook'>('basics')
const tabs = [ const tabs = [
{ id: 'basics' as const, label: 'Alapadatok', icon: '📋' }, { id: 'basics' as const, label: 'Alapadatok', icon: '📋' },
{ id: 'techdata' as const, label: t('vehicleDetail.tabTechData'), icon: '🔧' },
{ id: 'finance' as const, label: 'Pénzügyek (TCO)', icon: '💰' }, { id: 'finance' as const, label: 'Pénzügyek (TCO)', icon: '💰' },
{ id: 'alerts' as const, label: 'Figyelmeztetések', icon: '🔔' }, { id: 'alerts' as const, label: 'Figyelmeztetések', icon: '🔔' },
{ id: 'servicebook' as const, label: 'Szervizkönyv', icon: '📖' }, { id: 'servicebook' as const, label: 'Szervizkönyv', icon: '📖' },
] ]
// ── Provide vehicle to child components (TechDataTab) ──
const vehicleComputed = computed(() => props.vehicle)
provide('vehicle', vehicleComputed)
// ── Reset state every time modal opens ── // ── Reset state every time modal opens ──
watch(() => props.isOpen, (newVal) => { watch(() => props.isOpen, (newVal) => {
if (newVal) { if (newVal) {
@@ -847,6 +1004,37 @@ watch(() => props.isOpen, (newVal) => {
} }
}) })
// ── Financials: Asset Financials, Insurance, Tax from API ──
const assetFinancials = ref<AssetFinancials | null>(null)
const insurancePolicies = ref<VehicleInsurancePolicy[]>([])
const taxObligations = ref<VehicleTaxObligation[]>([])
const financeLoading = ref(false)
/**
* Fetch all financial data (financials + insurance + tax) in parallel
* when the finance tab is activated.
*/
async function loadFinanceData(vehicleId: string | number) {
financeLoading.value = true
try {
const [financialsRes, insuranceRes, taxRes] = await Promise.all([
api.get(`/assets/vehicles/${vehicleId}/financials`).catch(() => null),
api.get(`/assets/vehicles/${vehicleId}/insurance`).catch(() => null),
api.get(`/assets/vehicles/${vehicleId}/tax`).catch(() => null),
])
assetFinancials.value = financialsRes?.data || null
insurancePolicies.value = (insuranceRes?.data as VehicleInsurancePolicy[]) || []
taxObligations.value = (taxRes?.data as VehicleTaxObligation[]) || []
} catch (err: any) {
console.error('[VehicleDetailModal] Failed to fetch financial data:', err)
assetFinancials.value = null
insurancePolicies.value = []
taxObligations.value = []
} finally {
financeLoading.value = false
}
}
// ── Financials: Vehicle Costs from API ── // ── Financials: Vehicle Costs from API ──
interface CostItem { interface CostItem {
id: string | number id: string | number
@@ -908,6 +1096,9 @@ function mapBackendCost(raw: any): CostItem {
*/ */
watch(() => activeTab.value, async (tab) => { watch(() => activeTab.value, async (tab) => {
if (tab === 'finance' && props.vehicle?.id) { if (tab === 'finance' && props.vehicle?.id) {
// Load financials, insurance, tax in parallel
await loadFinanceData(props.vehicle.id)
// Then load costs
costsLoading.value = true costsLoading.value = true
try { try {
const res = await api.get(`/assets/${props.vehicle.id}/costs`) const res = await api.get(`/assets/${props.vehicle.id}/costs`)
@@ -927,14 +1118,20 @@ watch(() => activeTab.value, async (tab) => {
*/ */
watch(() => props.vehicle, (newVehicle) => { watch(() => props.vehicle, (newVehicle) => {
if (activeTab.value === 'finance' && newVehicle?.id) { if (activeTab.value === 'finance' && newVehicle?.id) {
financeLoading.value = true
costsLoading.value = true costsLoading.value = true
api.get(`/assets/${newVehicle.id}/costs`) Promise.all([
.then(res => { loadFinanceData(newVehicle.id),
const rawData = (res.data as any[]) || [] api.get(`/assets/${newVehicle.id}/costs`)
vehicleCosts.value = rawData.map(mapBackendCost) .then(res => {
}) const rawData = (res.data as any[]) || []
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] }) vehicleCosts.value = rawData.map(mapBackendCost)
.finally(() => { costsLoading.value = false }) })
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
]).finally(() => {
financeLoading.value = false
costsLoading.value = false
})
} }
}) })
@@ -944,14 +1141,20 @@ watch(() => props.vehicle, (newVehicle) => {
*/ */
watch(() => props.refreshTrigger, (newVal, oldVal) => { watch(() => props.refreshTrigger, (newVal, oldVal) => {
if (newVal !== undefined && newVal !== oldVal && activeTab.value === 'finance' && props.vehicle?.id) { if (newVal !== undefined && newVal !== oldVal && activeTab.value === 'finance' && props.vehicle?.id) {
financeLoading.value = true
costsLoading.value = true costsLoading.value = true
api.get(`/assets/${props.vehicle.id}/costs`) Promise.all([
.then(res => { loadFinanceData(props.vehicle.id),
const rawData = (res.data as any[]) || [] api.get(`/assets/${props.vehicle.id}/costs`)
vehicleCosts.value = rawData.map(mapBackendCost) .then(res => {
}) const rawData = (res.data as any[]) || []
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] }) vehicleCosts.value = rawData.map(mapBackendCost)
.finally(() => { costsLoading.value = false }) })
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
]).finally(() => {
financeLoading.value = false
costsLoading.value = false
})
} }
}) })
@@ -986,88 +1189,6 @@ function getConnectorLabel(connector: string): string {
return labels[connector] || connector return labels[connector] || connector
} }
// ── Feature label helper (for features badges) ──
function getFeatureLabel(featureKey: string): string {
const labels: Record<string, string> = {
abs: 'ABS',
esp: 'ESP',
traction_control: 'Kipörgésgátló',
hill_start_assist: 'Hegyi start segéd',
hill_descent_control: 'Lejtmenet szabályzó',
cruise_control: 'Tempomat',
adaptive_cruise_control: 'Adaptív tempomat',
lane_assist: 'Sávtartó',
blind_spot: 'Holtpont figyelő',
parking_sensors_front: 'Első parkoló szenzorok',
parking_sensors_rear: 'Hátsó parkoló szenzorok',
rear_view_camera: 'Tolatókamera',
'360_camera': '360° kamera',
start_stop: 'Start-Stop',
keyless_go: 'Kulcs nélküli indítás',
auto_hold: 'Auto Hold',
airbag_driver: 'Vezető légzsák',
airbag_passenger: 'Utas légzsák',
airbag_side: 'Oldallégzsák',
airbag_curtain: 'Függönylégzsák',
isofix: 'Isofix',
tpms: 'Guminyomás monitor',
night_vision: 'Éjjellátó',
traffic_sign_recognition: 'Táblafelismerő',
driver_alert: 'Fáradtság figyelő',
leather_seats: 'Bőr ülések',
heated_seats_front: 'Első ülésfűtés',
heated_seats_rear: 'Hátsó ülésfűtés',
ventilated_seats: 'Szellőztetett ülések',
massage_seats: 'Masszázs ülések',
sport_seats: 'Sportülések',
electric_seat_adjust: 'Elektromos ülésállítás',
memory_seats: 'Memóriás ülések',
heated_steering_wheel: 'Fűtött kormány',
panoramic_roof: 'Panoráma tető',
sunroof: 'Napfénytető',
ambient_lighting: 'Hangulatvilágítás',
auto_dimming_mirror: 'Automata sötétedő tükör',
rear_blind: 'Hátsó roló',
trunk_cover: 'Csomagtér takaró',
alloy_wheels: 'Könnyűfém felni',
winter_tires: 'Téli gumi',
roof_rails: 'Tetősín',
tow_bar: 'Vontató',
tinted_windows: 'Színezett üvegek',
xenon_headlights: 'Xenon fényszóró',
led_headlights: 'LED fényszóró',
adaptive_headlights: 'Adaptív fényszóró',
fog_lights: 'Ködlámpa',
rain_sensor: 'Esőérzékelő',
light_sensor: 'Fényérzékelő',
electric_trunk: 'Elektromos csomagtér',
side_steps: 'Oldallépcső',
mudguards: 'Sárvédő',
navigation: 'Navigáció',
apple_carplay: 'Apple CarPlay',
android_auto: 'Android Auto',
bluetooth: 'Bluetooth',
usb_charging: 'USB töltő',
wireless_charging: 'Vezeték nélküli töltő',
head_up_display: 'Head-up Display',
digital_cockpit: 'Digitális műszerfal',
premium_sound: 'Prémium hangrendszer',
dab_radio: 'DAB rádió',
wifi_hotspot: 'WiFi hotspot',
voice_control: 'Hangvezérlés',
garage_kept: 'Garázsban tartott',
smoke_free: 'Nem dohányzó',
pet_free: 'Háziállat mentes',
service_book: 'Szervizkönyvvel',
original_mileage: 'Igazolt futásteljesítmény',
first_owner: 'Első tulajdonos',
fleet_vehicle: 'Flottás jármű',
imported: 'Importált',
accident_free: 'Balesetmentes',
}
return labels[featureKey] || featureKey
}
// ── Task 4: Trust Score computed helpers ── // ── Task 4: Trust Score computed helpers ──
const trustScorePercent = computed(() => { const trustScorePercent = computed(() => {
const v = props.vehicle const v = props.vehicle
@@ -1127,6 +1248,22 @@ const engineDisplay = computed(() => {
return parts.length > 0 ? parts.join(' ') : '—' return parts.length > 0 ? parts.join(' ') : '—'
}) })
// ── Power Unit Toggle (LE / KW) for display ──
const powerUnit = ref<'kw' | 'le'>('kw')
function togglePowerUnit() {
powerUnit.value = powerUnit.value === 'kw' ? 'le' : 'kw'
}
const powerDetailDisplay = computed(() => {
const kw = props.vehicle?.power_kw
if (!kw) return '—'
if (powerUnit.value === 'kw') {
return `${kw} kW`
}
return `${Math.round(kw * 1.35962)} LE`
})
const hpDisplay = computed(() => { const hpDisplay = computed(() => {
const kw = props.vehicle?.power_kw const kw = props.vehicle?.power_kw
if (!kw) return '—' if (!kw) return '—'

View File

@@ -1,16 +1,709 @@
<template> <template>
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm"> <div class="space-y-6">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabFinancials') }}</h3> <!-- -->
<div class="flex flex-col items-center justify-center py-12 text-white/50"> <!-- SECTION 1: Purchasing & Leasing -->
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <!-- -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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" /> <div class="rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md p-5">
</svg> <div class="flex items-center justify-between mb-4">
<p class="text-base">{{ t('vehicleDetail.placeholderFinancials') }}</p> <h3 class="text-base font-bold text-white flex items-center gap-2">
<span>&#128184;</span> {{ t('finance.purchasing_section_title') || 'Beszerzés & Lízing' }}
</h3>
<button
@click="openFinancialsModal"
class="rounded-lg bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent hover:bg-sf-accent/30 transition-colors cursor-pointer"
>
{{ t('common.edit') || 'Szerkesztés' }}
</button>
</div>
<!-- Loading State -->
<div v-if="loadingFinancials" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-sf-accent" 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>
<!-- Empty State -->
<div v-else-if="!financials" class="flex flex-col items-center justify-center py-8 text-slate-400">
<svg class="h-10 w-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-sm">{{ t('finance.no_financials') || 'Még nincsenek pénzügyi adatok rögzítve.' }}</p>
<button
@click="openFinancialsModal"
class="mt-3 rounded-lg bg-sf-accent px-4 py-2 text-xs font-medium text-white hover:bg-sf-accent/90 transition-colors cursor-pointer"
>
{{ t('finance.add_financials') || 'Pénzügyi adatok hozzáadása' }}
</button>
</div>
<!-- Data Grid -->
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.purchase_price_gross') || 'Bruttó vételár' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.purchase_price_gross, financials.currency) }}
</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.purchase_price_net') || 'Nettó vételár' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.purchase_price_net, financials.currency) }}
</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.down_payment') || 'Önerő' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.down_payment, financials.currency) }}
</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.monthly_installment') || 'Havi törlesztő' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.monthly_installment, financials.currency) }}
</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.contract_number') || 'Szerződésszám' }}</p>
<p class="text-sm font-semibold text-white">{{ financials.contract_number || '—' }}</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.financing_provider') || 'Finanszírozó' }}</p>
<p class="text-sm font-semibold text-white">{{ financials.financing_provider || '—' }}</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.interest_rate') || 'Kamat' }}</p>
<p class="text-sm font-semibold text-white">{{ financials.interest_rate != null ? `${financials.interest_rate}%` : '—' }}</p>
</div>
<div class="rounded-xl bg-slate-800/50 p-3">
<p class="text-xs text-slate-400 mb-1">{{ t('finance.residual_value') || 'Maradványérték' }}</p>
<p class="text-sm font-semibold text-white">
{{ formatCurrency(financials.residual_value, financials.currency) }}
</p>
</div>
</div>
<!-- Contract Dates -->
<div v-if="financials?.contract_start_date || financials?.contract_end_date" class="mt-3 flex flex-wrap gap-4 text-xs text-slate-400">
<span v-if="financials.contract_start_date">
{{ t('finance.contract_start_date') || 'Szerződés kezdete' }}: <span class="text-slate-300">{{ formatDate(financials.contract_start_date) }}</span>
</span>
<span v-if="financials.contract_end_date">
{{ t('finance.contract_end_date') || 'Szerződés vége' }}: <span class="text-slate-300">{{ formatDate(financials.contract_end_date) }}</span>
</span>
</div>
</div> </div>
<!-- -->
<!-- SECTION 2: Insurances & Taxes -->
<!-- -->
<div class="rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-white flex items-center gap-2">
<span>&#128737;</span> {{ t('finance.insurance_section_title') || 'Biztosítások & Adók' }}
</h3>
<div class="flex gap-2">
<button
@click="openInsuranceModal"
class="rounded-lg bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent hover:bg-sf-accent/30 transition-colors cursor-pointer"
>
{{ t('finance.add_insurance') || '+ Biztosítás' }}
</button>
<button
@click="openTaxModal"
class="rounded-lg bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-500/30 transition-colors cursor-pointer"
>
{{ t('finance.add_tax') || '+ Adó' }}
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="loadingInsurance" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-sf-accent" 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>
<!-- Empty State -->
<div v-else-if="insurancePolicies.length === 0 && taxObligations.length === 0" class="flex flex-col items-center justify-center py-8 text-slate-400">
<svg class="h-10 w-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-sm">{{ t('finance.no_insurance_taxes') || 'Még nincsenek biztosítások vagy adók rögzítve.' }}</p>
</div>
<div v-else class="space-y-4">
<!-- Insurance Cards -->
<div v-for="policy in insurancePolicies" :key="policy.id" class="rounded-xl border border-slate-700/50 bg-slate-800/30 p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="rounded-full bg-blue-500/20 px-2.5 py-0.5 text-xs font-medium text-blue-400">
{{ policy.policy_type === 'kgfb' ? 'KGFB' : policy.policy_type === 'casco' ? 'CASCO' : policy.policy_type === 'both' ? 'KGFB + CASCO' : (policy.policy_type || t('finance.insurance') || 'Biztosítás') }}
</span>
<span class="text-sm font-semibold text-white">{{ policy.insurer_name || '—' }}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div>
<span class="text-slate-400">{{ t('finance.policy_number') || 'Kötvényszám' }}:</span>
<span class="ml-1 text-slate-200">{{ policy.policy_number || '—' }}</span>
</div>
<div>
<span class="text-slate-400">{{ t('finance.premium') || 'Díj' }}:</span>
<span class="ml-1 text-slate-200">{{ formatCurrency(policy.premium, policy.currency) }}</span>
</div>
<div>
<span class="text-slate-400">{{ t('finance.valid_from') || 'Érvényes' }}:</span>
<span class="ml-1 text-slate-200">{{ formatDate(policy.valid_from) }} {{ formatDate(policy.valid_until) }}</span>
</div>
</div>
</div>
<button
@click="deleteInsurance(policy)"
class="ml-2 rounded-lg p-1.5 text-slate-500 hover:bg-red-500/20 hover:text-red-400 transition-colors cursor-pointer"
:title="t('common.delete') || 'Törlés'"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Tax Cards -->
<div v-for="tax in taxObligations" :key="tax.id" class="rounded-xl border border-slate-700/50 bg-slate-800/30 p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="rounded-full bg-amber-500/20 px-2.5 py-0.5 text-xs font-medium text-amber-400">
{{ tax.tax_type || (t('finance.tax') || 'Adó') }}
</span>
<span class="text-sm font-semibold text-white">{{ formatCurrency(tax.amount, tax.currency) }}</span>
<span v-if="tax.paid" class="rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
{{ t('finance.paid') || 'Fizetve' }}
</span>
<span v-else class="rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
{{ t('finance.unpaid') || 'Fizetendő' }}
</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
<div>
<span class="text-slate-400">{{ t('finance.due_date') || 'Esedékesség' }}:</span>
<span class="ml-1 text-slate-200">{{ formatDate(tax.due_date) }}</span>
</div>
</div>
</div>
<button
@click="deleteTax(tax)"
class="ml-2 rounded-lg p-1.5 text-slate-500 hover:bg-red-500/20 hover:text-red-400 transition-colors cursor-pointer"
:title="t('common.delete') || 'Törlés'"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- -->
<!-- SECTION 3: TCO / Expenses -->
<!-- -->
<div class="rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-white flex items-center gap-2">
<span>&#128202;</span> {{ t('finance.tco_section_title') || 'TCO / Költségek' }}
</h3>
<button
@click="openCostModal"
class="rounded-lg bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent hover:bg-sf-accent/30 transition-colors cursor-pointer"
>
{{ t('finance.add_cost') || '+ Költség' }}
</button>
</div>
<!-- Placeholder for TCO / Expenses -->
<div class="flex flex-col items-center justify-center py-8 text-slate-400">
<svg class="h-10 w-10 mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-sm">{{ t('finance.tco_placeholder') || 'A TCO / költségadatok itt jelennek meg.' }}</p>
</div>
</div>
<!-- -->
<!-- FINANCIALS MODAL (inline edit) -->
<!-- -->
<Teleport to="body">
<div v-if="showFinancialsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeFinancialsModal">
<div class="w-full max-w-lg rounded-2xl border border-slate-700 bg-slate-900 p-6 shadow-2xl">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-bold text-white">{{ t('finance.edit_financials') || 'Beszerzés & Lízing adatok' }}</h3>
<button @click="closeFinancialsModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors cursor-pointer">
<svg class="h-5 w-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>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.purchase_price_net') || 'Nettó vételár' }}</label>
<input v-model.number="financialsForm.purchase_price_net" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.purchase_price_gross') || 'Bruttó vételár' }}</label>
<input v-model.number="financialsForm.purchase_price_gross" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.down_payment') || 'Önerő' }}</label>
<input v-model.number="financialsForm.down_payment" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.monthly_installment') || 'Havi törlesztő' }}</label>
<input v-model.number="financialsForm.monthly_installment" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.contract_number') || 'Szerződésszám' }}</label>
<input v-model="financialsForm.contract_number" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.financing_provider') || 'Finanszírozó' }}</label>
<input v-model="financialsForm.financing_provider" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.interest_rate') || 'Kamat (%)' }}</label>
<input v-model.number="financialsForm.interest_rate" type="number" step="0.01" min="0" max="100" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.residual_value') || 'Maradványérték' }}</label>
<input v-model.number="financialsForm.residual_value" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.contract_start_date') || 'Szerződés kezdete' }}</label>
<input v-model="financialsForm.contract_start_date" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.contract_end_date') || 'Szerződés vége' }}</label>
<input v-model="financialsForm.contract_end_date" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button @click="closeFinancialsModal" class="rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800 transition-colors cursor-pointer">
{{ t('common.cancel') || 'Mégse' }}
</button>
<button @click="saveFinancials" :disabled="savingFinancials" class="rounded-xl bg-sf-accent px-4 py-2 text-sm font-medium text-white hover:bg-sf-accent/90 disabled:opacity-50 transition-colors cursor-pointer">
{{ savingFinancials ? (t('common.saving') || 'Mentés...') : (t('common.save') || 'Mentés') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- -->
<!-- INSURANCE MODAL -->
<!-- -->
<Teleport to="body">
<div v-if="showInsuranceModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeInsuranceModal">
<div class="w-full max-w-md rounded-2xl border border-slate-700 bg-slate-900 p-6 shadow-2xl">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-bold text-white">{{ t('finance.add_insurance') || 'Új biztosítás' }}</h3>
<button @click="closeInsuranceModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors cursor-pointer">
<svg class="h-5 w-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>
<div class="space-y-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.insurer_name') || 'Biztosító neve' }}</label>
<input v-model="insuranceForm.insurer_name" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.policy_number') || 'Kötvényszám' }}</label>
<input v-model="insuranceForm.policy_number" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.policy_type') || 'Biztosítás típusa' }}</label>
<select v-model="insuranceForm.policy_type" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
<option value="kgfb">KGFB</option>
<option value="casco">CASCO</option>
<option value="both">KGFB + CASCO</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.premium') || 'Díj' }}</label>
<input v-model.number="insuranceForm.premium" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.currency') || 'Pénznem' }}</label>
<select v-model="insuranceForm.currency" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
<option value="HUF">HUF</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.valid_from') || 'Érvényesség kezdete' }}</label>
<input v-model="insuranceForm.valid_from" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.valid_until') || 'Érvényesség vége' }}</label>
<input v-model="insuranceForm.valid_until" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button @click="closeInsuranceModal" class="rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800 transition-colors cursor-pointer">
{{ t('common.cancel') || 'Mégse' }}
</button>
<button @click="saveInsurance" :disabled="savingInsurance" class="rounded-xl bg-sf-accent px-4 py-2 text-sm font-medium text-white hover:bg-sf-accent/90 disabled:opacity-50 transition-colors cursor-pointer">
{{ savingInsurance ? (t('common.saving') || 'Mentés...') : (t('common.save') || 'Mentés') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- -->
<!-- TAX MODAL -->
<!-- -->
<Teleport to="body">
<div v-if="showTaxModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeTaxModal">
<div class="w-full max-w-md rounded-2xl border border-slate-700 bg-slate-900 p-6 shadow-2xl">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-bold text-white">{{ t('finance.add_tax') || 'Új adó' }}</h3>
<button @click="closeTaxModal" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white transition-colors cursor-pointer">
<svg class="h-5 w-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>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.tax_type') || 'Adó típusa' }}</label>
<input v-model="taxForm.tax_type" type="text" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.amount') || 'Összeg' }}</label>
<input v-model.number="taxForm.amount" type="number" step="0.01" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.currency') || 'Pénznem' }}</label>
<select v-model="taxForm.currency" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer">
<option value="HUF">HUF</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-slate-400">{{ t('finance.due_date') || 'Esedékesség' }}</label>
<input v-model="taxForm.due_date" type="date" class="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20" />
</div>
</div>
<div class="flex items-center gap-2">
<input v-model="taxForm.paid" type="checkbox" id="tax-paid" class="rounded border-slate-600 bg-slate-800 text-sf-accent focus:ring-sf-accent/20 cursor-pointer" />
<label for="tax-paid" class="text-sm text-slate-300 cursor-pointer">{{ t('finance.paid') || 'Fizetve' }}</label>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button @click="closeTaxModal" class="rounded-xl border border-slate-600 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800 transition-colors cursor-pointer">
{{ t('common.cancel') || 'Mégse' }}
</button>
<button @click="saveTax" :disabled="savingTax" class="rounded-xl bg-sf-accent px-4 py-2 text-sm font-medium text-white hover:bg-sf-accent/90 disabled:opacity-50 transition-colors cursor-pointer">
{{ savingTax ? (t('common.saving') || 'Mentés...') : (t('common.save') || 'Mentés') }}
</button>
</div>
</div>
</div>
</Teleport>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useVehicleStore } from '@/stores/vehicle'
import type { AssetFinancials, VehicleInsurancePolicy, VehicleTaxObligation } from '@/types/vehicle'
const { t } = useI18n() const { t } = useI18n()
const vehicleStore = useVehicleStore()
const props = defineProps<{ vehicleId: string }>()
// ── Reactive State ────────────────────────────────────────────────────────
const financials = ref<AssetFinancials | null>(null)
const insurancePolicies = ref<VehicleInsurancePolicy[]>([])
const taxObligations = ref<VehicleTaxObligation[]>([])
const loadingFinancials = ref(false)
const loadingInsurance = ref(false)
const savingFinancials = ref(false)
const savingInsurance = ref(false)
const savingTax = ref(false)
const showFinancialsModal = ref(false)
const showInsuranceModal = ref(false)
const showTaxModal = ref(false)
// ── Form Objects ──────────────────────────────────────────────────────────
const financialsForm = reactive<Partial<AssetFinancials>>({
purchase_price_net: null,
purchase_price_gross: null,
down_payment: null,
monthly_installment: null,
contract_number: '',
financing_provider: '',
interest_rate: null,
residual_value: null,
contract_start_date: '',
contract_end_date: '',
})
const insuranceForm = reactive<Partial<VehicleInsurancePolicy>>({
insurer_name: '',
policy_number: '',
policy_type: 'kgfb',
premium: null,
currency: 'HUF',
valid_from: '',
valid_until: '',
})
const taxForm = reactive<Partial<VehicleTaxObligation>>({
tax_type: '',
amount: null,
currency: 'HUF',
due_date: '',
paid: false,
})
// ── Helper Methods ────────────────────────────────────────────────────────
function formatCurrency(value: number | null | undefined, currency?: string): string {
if (value == null) return '—'
const cur = currency || 'HUF'
try {
return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: cur }).format(value)
} catch {
return `${value.toLocaleString('hu-HU')} ${cur}`
}
}
function formatDate(date: string | null | undefined): string {
if (!date) return '—'
try {
return new Intl.DateTimeFormat('hu-HU', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date))
} catch {
return date
}
}
// ── Data Loading ──────────────────────────────────────────────────────────
async function loadData() {
if (!props.vehicleId) return
loadingFinancials.value = true
loadingInsurance.value = true
try {
const [finResult, insResult, taxResult] = await Promise.all([
vehicleStore.fetchAssetFinancials(props.vehicleId),
vehicleStore.fetchInsurancePolicies(props.vehicleId),
vehicleStore.fetchTaxObligations(props.vehicleId),
])
financials.value = finResult
insurancePolicies.value = insResult
taxObligations.value = taxResult
} catch (err) {
console.error('[FinancialsTab] Failed to load data:', err)
} finally {
loadingFinancials.value = false
loadingInsurance.value = false
}
}
// ── Financials Modal ──────────────────────────────────────────────────────
function openFinancialsModal() {
if (financials.value) {
Object.assign(financialsForm, {
purchase_price_net: financials.value.purchase_price_net ?? null,
purchase_price_gross: financials.value.purchase_price_gross ?? null,
down_payment: financials.value.down_payment ?? null,
monthly_installment: financials.value.monthly_installment ?? null,
contract_number: financials.value.contract_number || '',
financing_provider: financials.value.financing_provider || '',
interest_rate: financials.value.interest_rate ?? null,
residual_value: financials.value.residual_value ?? null,
contract_start_date: financials.value.contract_start_date || '',
contract_end_date: financials.value.contract_end_date || '',
})
} else {
Object.assign(financialsForm, {
purchase_price_net: null,
purchase_price_gross: null,
down_payment: null,
monthly_installment: null,
contract_number: '',
financing_provider: '',
interest_rate: null,
residual_value: null,
contract_start_date: '',
contract_end_date: '',
})
}
showFinancialsModal.value = true
}
function closeFinancialsModal() {
showFinancialsModal.value = false
}
async function saveFinancials() {
if (!props.vehicleId) return
savingFinancials.value = true
try {
const result = await vehicleStore.saveAssetFinancials(props.vehicleId, { ...financialsForm })
if (result) {
financials.value = result
closeFinancialsModal()
}
} catch (err) {
console.error('[FinancialsTab] Failed to save financials:', err)
} finally {
savingFinancials.value = false
}
}
// ── Insurance Modal ───────────────────────────────────────────────────────
function openInsuranceModal() {
Object.assign(insuranceForm, {
insurer_name: '',
policy_number: '',
policy_type: 'kgfb',
premium: null,
currency: 'HUF',
valid_from: '',
valid_until: '',
})
showInsuranceModal.value = true
}
function closeInsuranceModal() {
showInsuranceModal.value = false
}
async function saveInsurance() {
if (!props.vehicleId) return
savingInsurance.value = true
try {
const result = await vehicleStore.createInsurancePolicy(props.vehicleId, { ...insuranceForm })
if (result) {
insurancePolicies.value.push(result)
closeInsuranceModal()
}
} catch (err) {
console.error('[FinancialsTab] Failed to save insurance:', err)
} finally {
savingInsurance.value = false
}
}
async function deleteInsurance(policy: VehicleInsurancePolicy) {
if (!props.vehicleId || !policy.id) return
if (!confirm(t('common.confirm_delete') || 'Biztosan törlöd?')) return
try {
const success = await vehicleStore.deleteInsurancePolicy(props.vehicleId, policy.id)
if (success) {
insurancePolicies.value = insurancePolicies.value.filter(p => p.id !== policy.id)
}
} catch (err) {
console.error('[FinancialsTab] Failed to delete insurance:', err)
}
}
// ── Tax Modal ─────────────────────────────────────────────────────────────
function openTaxModal() {
Object.assign(taxForm, {
tax_type: '',
amount: null,
currency: 'HUF',
due_date: '',
paid: false,
})
showTaxModal.value = true
}
function closeTaxModal() {
showTaxModal.value = false
}
async function saveTax() {
if (!props.vehicleId) return
savingTax.value = true
try {
const result = await vehicleStore.createTaxObligation(props.vehicleId, { ...taxForm })
if (result) {
taxObligations.value.push(result)
closeTaxModal()
}
} catch (err) {
console.error('[FinancialsTab] Failed to save tax:', err)
} finally {
savingTax.value = false
}
}
async function deleteTax(tax: VehicleTaxObligation) {
if (!props.vehicleId || !tax.id) return
if (!confirm(t('common.confirm_delete') || 'Biztosan törlöd?')) return
try {
const success = await vehicleStore.deleteTaxObligation(props.vehicleId, tax.id)
if (success) {
taxObligations.value = taxObligations.value.filter(t => t.id !== tax.id)
}
} catch (err) {
console.error('[FinancialsTab] Failed to delete tax:', err)
}
}
// ── TCO / Cost Modal (placeholder) ────────────────────────────────────────
function openCostModal() {
// TODO: Implement cost modal when TCO module is ready
console.warn('[FinancialsTab] TCO cost modal not yet implemented')
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
onMounted(() => {
loadData()
})
</script> </script>
<style scoped>
/* All styling is handled by Tailwind utility classes */
</style>

View File

@@ -1,100 +1,171 @@
<template> <template>
<div class="space-y-6"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
<!-- Row 1: Photo + Basic Info --> <!-- LEFT: Photo -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div class="lg:col-span-5">
<!-- Vehicle Photo --> <!-- Vehicle Photo -->
<div class="flex aspect-[16/9] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 backdrop-blur-sm lg:aspect-square"> <div
v-if="vehicle?.photo_url && !imageError"
class="flex aspect-[16/9] items-center justify-center overflow-hidden rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md shadow-xl lg:aspect-square"
>
<img <img
src="https://images.unsplash.com/photo-1503376712341-a67b5e40e2d1?auto=format&fit=crop&w=800&q=80" :src="vehicle.photo_url"
alt="Vehicle photo" alt="Vehicle photo"
class="h-full w-full object-cover rounded-2xl" class="h-full w-full rounded-2xl object-cover"
@error="imageError = true"
/> />
</div> </div>
<!-- Basic Info --> <!-- Photo Fallback (SVG Placeholder) -->
<div class="flex flex-col justify-center rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm lg:col-span-2"> <div
<!-- License Plate (large, prominent) --> v-else
<h2 class="text-4xl font-bold tracking-wider text-white"> class="flex aspect-[16/9] flex-col items-center justify-center gap-3 rounded-2xl border border-slate-700 bg-slate-900/70 backdrop-blur-md shadow-xl lg:aspect-square"
{{ vehicle?.license_plate || t('vehicleDetail.noPlate') }} >
</h2> <svg class="h-16 w-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
<!-- Brand / Model --> </svg>
<p class="mt-2 text-2xl text-white/80"> <p class="text-sm text-white/40">{{ t('vehicleDetail.noPhoto') }}</p>
{{ vehicle?.brand || '—' }} {{ vehicle?.model || '—' }}
</p>
<!-- Year of Manufacture -->
<p class="mt-1 text-lg text-white/50">
{{ vehicle?.year_of_manufacture || '—' }}
</p>
<!-- Divider -->
<div class="my-4 border-t border-white/10" />
<!-- Quick specs row -->
<div class="flex flex-wrap gap-x-8 gap-y-2">
<div>
<span class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.vin') }}</span>
<p class="text-sm text-white/70">{{ vehicle?.vin || '—' }}</p>
</div>
<div>
<span class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.fuelType') }}</span>
<p class="text-sm text-white/70">{{ fuelLabel }}</p>
</div>
<div>
<span class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.mileage') }}</span>
<p class="text-sm text-white/70">{{ formattedMileage }}</p>
</div>
</div>
</div> </div>
</div> </div>
<!-- Row 2: Vital Signs (3 cards) --> <!-- RIGHT: Master Data Card -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div class="flex flex-col rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl lg:col-span-7">
<!-- MOT Expiry --> <!-- SECTION 1: Main Data (Top) -->
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm"> <!-- License Plate (large, prominent) -->
<div class="flex items-center gap-3"> <h2 class="text-4xl font-bold tracking-wider text-white">
{{ vehicle?.license_plate || t('common.noPlate') }}
</h2>
<!-- Brand / Model / Year -->
<p class="mt-2 text-2xl text-white/80">
{{ vehicle?.brand || '—' }} {{ vehicle?.model || '—' }}
</p>
<p class="mt-1 text-lg text-white/60">
{{ vehicle?.year_of_manufacture || '—' }}
</p>
<!-- Quick stats grid: Fuel, Mileage, Monthly Cost, Monthly Mileage -->
<div class="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
<!-- Fuel -->
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.fuelType') }}</span>
<p class="text-base font-semibold text-white">{{ fuelLabel }}</p>
</div>
<!-- Current Mileage (only here) -->
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('common.mileage') }}</span>
<p class="text-base font-semibold text-white">{{ formattedMileage }}</p>
</div>
<!-- Monthly Cost (dynamic from Expense Store) -->
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.monthlyCost') }}</span>
<p class="flex items-center gap-1 text-base font-semibold text-white">
<svg class="h-4 w-4 text-rose-400" 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>
{{ monthlyCostLabel }}
</p>
</div>
<!-- Monthly Mileage -->
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.monthlyMileage') }}</span>
<p class="flex items-center gap-1 text-base font-semibold text-white">
<svg class="h-4 w-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
{{ monthlyMileageLabel }}
</p>
</div>
</div>
<!-- Divider -->
<hr class="my-5 border-slate-700" />
<!-- SECTION 2: Recent Expenses (Middle) -->
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/60">{{ t('vehicleDetail.recentExpenses') }}</h3>
<!-- Loading state -->
<div v-if="expenseStore.isLoading" class="flex items-center justify-center py-6">
<svg class="h-6 w-6 animate-spin text-white/40" 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>
<!-- Expense list -->
<div v-else-if="recentExpenses.length > 0" class="flex flex-col">
<div
v-for="(expense, index) in recentExpenses"
:key="expense.id || index"
class="flex items-center justify-between py-2"
>
<div class="flex items-center gap-3">
<!-- Dynamic icon based on category -->
<svg
v-if="expense.icon === 'fuel'"
class="h-4 w-4 text-amber-400" 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>
<svg
v-else-if="expense.icon === 'service'"
class="h-4 w-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<svg
v-else
class="h-4 w-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<span class="text-sm text-white/80">{{ expense.label }}</span>
</div>
<span class="text-sm font-semibold text-white">{{ formatCurrency(expense.amount) }}</span>
</div>
</div>
<!-- Empty state: no expenses -->
<div v-else class="flex flex-col items-center justify-center py-6 text-white/40">
<svg class="mb-2 h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p class="text-sm">{{ t('vehicleDetail.noExpenses') }}</p>
</div>
<!-- Divider -->
<hr class="my-5 border-slate-700" />
<!-- SECTION 3: Deadlines (Bottom) -->
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wider text-white/60">{{ t('vehicleDetail.deadlines') }}</h3>
<div class="grid grid-cols-2 gap-4">
<!-- MOT Expiry -->
<div class="flex items-center gap-3 rounded-xl border border-slate-700 bg-slate-900/70 p-4">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/20"> <div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500/20">
<svg class="h-5 w-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-5 w-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
</div> </div>
<div> <div>
<p class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.motExpiry') }}</p> <p class="text-xs uppercase tracking-wider text-white/50">{{ t('vehicleDetail.motExpiry') }}</p>
<p class="text-base font-semibold text-white" :class="motStatusClass"> <p class="text-base font-semibold" :class="motStatusClass">
{{ motExpiryLabel }} {{ motExpiryLabel }}
</p> </p>
</div> </div>
</div> </div>
</div>
<!-- Current Mileage --> <!-- Next Service -->
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm"> <div class="flex items-center gap-3 rounded-xl border border-slate-700 bg-slate-900/70 p-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/20">
<svg class="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.mileage') }}</p>
<p class="text-base font-semibold text-white">
{{ formattedMileage }}
</p>
</div>
</div>
</div>
<!-- Next Service -->
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/20"> <div class="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/20">
<svg class="h-5 w-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-5 w-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg> </svg>
</div> </div>
<div> <div>
<p class="text-xs uppercase tracking-wider text-white/40">{{ t('vehicleDetail.nextService') }}</p> <p class="text-xs uppercase tracking-wider text-white/50">{{ t('common.nextService') }}</p>
<p class="text-base font-semibold text-white"> <p class="text-base font-semibold text-white">
{{ nextServiceLabel }} {{ nextServiceLabel }}
</p> </p>
@@ -106,15 +177,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject } from 'vue' import { computed, inject, ref, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useExpenseStore, type ExpenseItem } from '../../../stores/expense'
import type { Vehicle } from '../../../stores/vehicle' import type { Vehicle } from '../../../stores/vehicle'
const { t } = useI18n() const { t } = useI18n()
const expenseStore = useExpenseStore()
// ── Inject vehicle from parent (provided by VehicleDetailsView) ── // ── Inject vehicle from parent (provided by VehicleDetailsView) ──
const vehicle = inject<computed<Vehicle | null>>('vehicle') const vehicle = inject<computed<Vehicle | null>>('vehicle')
// ── Image error state ──
const imageError = ref(false)
// ── Fuel type label ── // ── Fuel type label ──
const fuelLabel = computed(() => { const fuelLabel = computed(() => {
if (!vehicle?.value?.fuel_type) return '—' if (!vehicle?.value?.fuel_type) return '—'
@@ -129,10 +205,74 @@ const formattedMileage = computed(() => {
return `${Number(vehicle.value.current_mileage).toLocaleString()} km` return `${Number(vehicle.value.current_mileage).toLocaleString()} km`
}) })
// ── Monthly Cost (dynamic from Expense Store - current month aggregate) ──
const monthlyCostLabel = computed(() => {
if (!vehicle?.value?.id) return t('common.noData')
const monthlyCost = expenseStore.getMonthlyCost(vehicle.value.id)
if (monthlyCost > 0) {
return formatCurrency(monthlyCost)
}
return t('common.noData')
})
// ── Monthly Mileage (dynamic from individual_equipment or fallback) ──
const monthlyMileageLabel = computed(() => {
const ie = vehicle?.value?.individual_equipment
const monthlyMileage = ie?.monthly_mileage ?? ie?.monthlyMileage
if (monthlyMileage != null && !isNaN(Number(monthlyMileage))) {
return `${Number(monthlyMileage).toLocaleString()} km`
}
return t('common.noData')
})
// ── Recent Expenses (from Expense Store, sorted by date desc, latest 3) ──
interface DisplayExpense {
id: string
label: string
amount: number
icon: 'fuel' | 'service' | 'other'
}
const recentExpenses = computed<DisplayExpense[]>(() => {
if (!vehicle?.value?.id) return []
const expenses = expenseStore.getExpensesForAsset(vehicle.value.id)
if (!expenses.length) return []
// Sort by date descending (newest first)
const sorted = [...expenses].sort((a, b) => {
if (!a.date && !b.date) return 0
if (!a.date) return 1
if (!b.date) return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
// Take the latest 3 entries
return sorted.slice(0, 3).map((e: ExpenseItem) => ({
id: e.id,
label: e.description || e.category_name || e.category_code || t('common.noData'),
amount: e.amount_gross ? parseFloat(e.amount_gross) : 0,
icon: resolveIcon(e.category_code || e.category_name || ''),
}))
})
function resolveIcon(category: string): 'fuel' | 'service' | 'other' {
const cat = (category || '').toLowerCase()
if (cat.includes('fuel') || cat.includes('tankol') || cat.includes('üzemanyag') || cat.includes('benzin')) return 'fuel'
if (cat.includes('service') || cat.includes('szerviz') || cat.includes('javítás') || cat.includes('repair') || cat.includes('maintenance') || cat.includes('karbantartás')) return 'service'
return 'other'
}
// ── Currency formatter ──
function formatCurrency(amount: number): string {
if (amount == null || isNaN(amount)) return t('common.noData')
return `${amount.toLocaleString()} Ft`
}
// ── MOT Expiry ── // ── MOT Expiry ──
const motExpiryLabel = computed(() => { const motExpiryLabel = computed(() => {
const dates = vehicle?.value?.individual_equipment?.dates const dates = vehicle?.value?.individual_equipment?.dates
if (!dates?.mot_expiry) return t('vehicleDetail.noData') if (!dates?.mot_expiry) return t('common.noData')
return formatDate(dates.mot_expiry) return formatDate(dates.mot_expiry)
}) })
@@ -162,4 +302,25 @@ function formatDate(dateStr: string): string {
return dateStr return dateStr
} }
} }
// ── Fetch expenses when vehicle is available ──
async function loadExpenses() {
if (vehicle?.value?.id) {
await expenseStore.fetchExpensesByAsset(vehicle.value.id)
}
}
onMounted(() => {
loadExpenses()
})
// Watch for vehicle ID changes (e.g., when navigating between vehicles)
watch(
() => vehicle?.value?.id,
(newId) => {
if (newId) {
loadExpenses()
}
}
)
</script> </script>

View File

@@ -1,17 +1,278 @@
<template> <template>
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm"> <div class="space-y-6">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('vehicleDetail.tabTechData') }}</h3> <!-- Section 1: Basic Specs -->
<div class="flex flex-col items-center justify-center py-12 text-white/50"> <div
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> v-if="basicSpecs.length > 0"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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" /> class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> >
</svg> <div class="flex items-center justify-between mb-4">
<p class="text-base">{{ t('vehicleDetail.placeholderTechData') }}</p> <h3 class="text-lg font-semibold text-white">{{ t('vehicleDetail.sectionBasicSpecs') }}</h3>
</div>
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="spec in basicSpecs" :key="spec.key" class="flex flex-col">
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
<span class="text-sm text-white/90">{{ spec.value }}</span>
</div>
</div>
</div>
<!-- Section 2: Engine & Drivetrain -->
<div
v-if="engineDrivetrainSpecs.length > 0"
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">{{ t('vehicleDetail.sectionEngineDrivetrain') }}</h3>
<!-- LE/KW Display Toggle -->
<div class="flex items-center gap-2">
<span class="text-[10px] font-semibold text-white/50 uppercase tracking-wider">{{ t('vehicleDetail.powerUnitLabel') }}</span>
<button
type="button"
@click="togglePowerUnit"
class="relative inline-flex h-6 w-10 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-sf-accent/30"
:class="powerUnit === 'kw' ? 'bg-sf-accent' : 'bg-amber-500'"
>
<span
class="inline-flex h-4 w-4 items-center rounded-full bg-white text-[8px] font-bold shadow-sm transition-all duration-200"
:class="powerUnit === 'kw' ? 'translate-x-0.5 justify-center' : 'translate-x-5 justify-end pr-0.5'"
>
{{ powerUnit === 'kw' ? 'kW' : 'LE' }}
</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="spec in engineDrivetrainSpecs" :key="spec.key" class="flex flex-col">
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
<span class="text-sm text-white/90">{{ spec.value }}</span>
</div>
</div>
</div>
<!-- Section 3: Body & Dimensions -->
<div
v-if="bodyDimensionSpecs.length > 0"
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
>
<h3 class="mb-4 text-lg font-semibold text-white">{{ t('vehicleDetail.sectionBodyDimensions') }}</h3>
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="spec in bodyDimensionSpecs" :key="spec.key" class="flex flex-col">
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
<span class="text-sm text-white/90">{{ spec.value }}</span>
</div>
</div>
</div>
<!-- Section 4: EV Specs (conditional) -->
<div
v-if="evSpecs.length > 0"
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
>
<h3 class="mb-4 text-lg font-semibold text-white">{{ t('vehicleDetail.sectionEvSpecs') }}</h3>
<div class="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="spec in evSpecs" :key="spec.key" class="flex flex-col">
<span class="text-xs uppercase tracking-wider text-white/50">{{ spec.label }}</span>
<span class="text-sm text-white/90">{{ spec.value }}</span>
</div>
</div>
</div>
<!-- Section 5: Features / Extras (conditional) -->
<div
v-if="featuresList.length > 0"
class="rounded-2xl border border-slate-700 bg-slate-900/70 p-6 backdrop-blur-md shadow-xl"
>
<h3 class="mb-4 text-lg font-semibold text-white">{{ t('vehicleDetail.sectionFeatures') }}</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="feat in featuresList"
:key="feat"
class="px-3 py-1 text-sm bg-slate-800 border border-slate-600 rounded-full text-slate-300"
>
{{ getFeatureTranslation(feat) }}
</span>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() import type { Vehicle } from '../../../stores/vehicle'
const { t, te } = useI18n()
// ── Inject vehicle from parent ──
const vehicle = inject<computed<Vehicle | null>>('vehicle')
// ── Power Unit Toggle (LE / KW) for display ──
const powerUnit = ref<'kw' | 'le'>('kw')
function togglePowerUnit() {
powerUnit.value = powerUnit.value === 'kw' ? 'le' : 'kw'
}
/** Convert kW to LE (1 kW = 1.34102 HP/LE) */
function kwToLe(kw: number): number {
return Math.round(kw * 1.34102)
}
/** Display power value based on selected unit */
function powerDisplay(kw: number | null | undefined): string {
if (kw === null || kw === undefined) return '—'
if (powerUnit.value === 'kw') return `${kw} kW`
return `${kwToLe(kw)} LE`
}
// ── Helper: display value or dash ──
function val(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
return String(value)
}
// ── Helper: translate fuel type ──
function fuelLabel(fuel: string | null | undefined): string {
if (!fuel) return '—'
const key = `vehicle.fuelTypes.${fuel}`
const translated = t(key)
return translated !== key ? translated : fuel
}
// ── Helper: translate body type ──
function bodyTypeLabel(bodyType: string | null | undefined): string {
if (!bodyType) return '—'
const key = `vehicle.bodyTypes.${bodyType}`
const translated = t(key)
return translated !== key ? translated : bodyType
}
// ── Helper: translate transmission type ──
function transmissionLabel(transmission: string | null | undefined): string {
if (!transmission) return '—'
const key = `vehicle.transmissionTypes.${transmission}`
const translated = t(key)
return translated !== key ? translated : transmission
}
// ── Helper: translate drive type ──
function driveLabel(drive: string | null | undefined): string {
if (!drive) return '—'
const key = `vehicle.driveTypes.${drive}`
const translated = t(key)
return translated !== key ? translated : drive
}
// ── Helper: translate cylinder layout ──
function cylinderLabel(layout: string | null | undefined): string {
if (!layout) return '—'
const key = `vehicle.cylinderLayouts.${layout}`
const translated = t(key)
return translated !== key ? translated : layout
}
// ── Helper: translate AC type ──
function acLabel(acType: string | null | undefined): string {
if (!acType) return '—'
const key = `vehicle.acTypes.${acType}`
const translated = t(key)
return translated !== key ? translated : acType
}
/**
* i18n feature fallback: tries to translate a feature key with category-specific
* fallback support. If neither `vehicle.features.{feat}` nor
* `vehicle.{category}Features.{feat}` exists, returns the raw key.
*/
function getFeatureTranslation(feat: string): string {
if (te(`vehicle.features.${feat}`)) return t(`vehicle.features.${feat}`)
// Attempt category-specific fallback (e.g. vehicle.safetyFeatures.ABS)
const category = vehicle?.value?.individual_equipment?.car_specs?.body_type || ''
if (category && te(`vehicle.${category}Features.${feat}`)) {
return t(`vehicle.${category}Features.${feat}`)
}
return feat
}
// ── Section 1: Basic Specs ──
interface SpecItem {
key: string
label: string
value: string
}
const basicSpecs = computed<SpecItem[]>(() => {
const v = vehicle?.value
return [
{ key: 'brand', label: t('vehicle.label_brand'), value: val(v?.brand) },
{ key: 'model', label: t('vehicle.label_model'), value: val(v?.model) },
{ key: 'year', label: t('vehicle.label_year'), value: val(v?.year_of_manufacture) },
{ key: 'vin', label: t('common.vin'), value: val(v?.vin) },
{ key: 'class', label: t('vehicleDetail.vehicleClass'), value: val(v?.vehicle_class) },
{ key: 'trim', label: t('vehicleDetail.trimLevel'), value: val(v?.trim_level) },
].filter(item => item.value && item.value !== '—' && item.value !== '-')
})
// ── Section 2: Engine & Drivetrain ──
const engineDrivetrainSpecs = computed<SpecItem[]>(() => {
const v = vehicle?.value
return [
{ key: 'fuel', label: t('vehicleDetail.fuelType'), value: fuelLabel(v?.fuel_type) },
{ key: 'capacity', label: t('vehicleDetail.engineCapacity'), value: val(v?.engine_capacity) },
{
key: 'power',
label: powerUnit.value === 'kw' ? t('vehicleDetail.powerKw') : t('vehicleDetail.powerLe'),
value: powerDisplay(v?.power_kw),
},
{ key: 'torque', label: t('vehicleDetail.torqueNm'), value: val(v?.torque_nm) },
{ key: 'transmission', label: t('vehicleDetail.transmission'), value: transmissionLabel(v?.transmission_type) },
{ key: 'drive', label: t('vehicleDetail.driveType'), value: driveLabel(v?.drive_type) },
{ key: 'euroClass', label: t('vehicleDetail.euroClass'), value: val(v?.euro_classification) },
].filter(item => item.value && item.value !== '—' && item.value !== '-')
})
// ── Section 3: Body & Dimensions ──
const bodyDimensionSpecs = computed<SpecItem[]>(() => {
const v = vehicle?.value
const carSpecs = v?.individual_equipment?.car_specs
// Prefer top-level columns, fall back to individual_equipment.car_specs
const bodyType = carSpecs?.body_type || v?.vehicle_class
const doorCount = v?.door_count ?? carSpecs?.door_count
const seatCount = v?.seat_count ?? carSpecs?.seat_count
const cylinderLayout = v?.cylinder_layout ?? carSpecs?.cylinder_layout
return [
{ key: 'bodyType', label: t('vehicleDetail.bodyType'), value: bodyTypeLabel(bodyType) },
{ key: 'doors', label: t('vehicleDetail.doors'), value: val(doorCount) },
{ key: 'seats', label: t('vehicleDetail.seats'), value: val(seatCount) },
{ key: 'cylinderLayout', label: t('vehicleDetail.cylinderLayout'), value: cylinderLabel(cylinderLayout) },
{ key: 'acType', label: t('vehicleDetail.acType'), value: acLabel(carSpecs?.ac_type) },
{ key: 'curbWeight', label: t('vehicleDetail.curbWeight'), value: val(v?.curb_weight) },
{ key: 'maxWeight', label: t('vehicleDetail.maxWeight'), value: val(v?.max_weight) },
{ key: 'mileage', label: t('vehicleDetail.mileage'), value: val(v?.current_mileage) },
{ key: 'condition', label: t('vehicleDetail.conditionScore'), value: val(v?.condition_score) },
].filter(item => item.value && item.value !== '—' && item.value !== '-')
})
// ── Section 4: EV Specs (conditional) ──
const evSpecs = computed<SpecItem[]>(() => {
const ev = vehicle?.value?.individual_equipment?.ev_specs
if (!ev) return []
return [
{ key: 'battery', label: t('vehicleDetail.batteryCapacity'), value: val(ev.battery_capacity_kwh) },
{ key: 'range', label: t('vehicleDetail.range'), value: val(ev.range_wltp) },
{ key: 'acConnector', label: t('vehicleDetail.acConnector'), value: val(ev.ac_connector) },
{ key: 'dcConnector', label: t('vehicleDetail.dcConnector'), value: val(ev.dc_connector) },
].filter(item => item.value && item.value !== '—' && item.value !== '-')
})
// ── Section 5: Features / Extras ──
const featuresList = computed<string[]>(() => {
const v = vehicle?.value
// Features can be at individual_equipment.features (top-level array)
// or nested under individual_equipment.car_specs.features
const ie = v?.individual_equipment
const features = ie?.features || ie?.car_specs?.features
if (!features || !Array.isArray(features) || features.length === 0) return []
return features
})
</script> </script>

View File

@@ -13,6 +13,33 @@ export default {
fuel: 'Fuel', fuel: 'Fuel',
mileage: 'Mileage', mileage: 'Mileage',
year: 'Year', year: 'Year',
noPlate: 'No Plate',
nextService: 'Next Service',
noData: 'No data',
backToGarage: 'Back to Garage',
statusActive: 'Active',
country: 'Country',
zipCode: 'ZIP Code',
city: 'City',
houseNumber: 'House Number',
stairwell: 'Stairwell',
floor: 'Floor',
door: 'Door',
parcelId: 'Parcel ID',
streetName: 'Street Name',
dashboardSubtitle: 'Your dashboard and vehicle overview',
profileSettings: 'Profile Settings',
streetType: 'Street Type',
email: 'Email',
phone: 'Phone',
na: 'N/A',
edit: 'Edit',
delete: 'Delete',
close: 'Close',
next: 'Next →',
prev: '← Prev',
actions: 'Actions',
items: 'items',
}, },
subscription: { subscription: {
title: 'Subscription Plans', title: 'Subscription Plans',
@@ -45,6 +72,15 @@ export default {
prioritySupport: 'Priority support', prioritySupport: 'Priority support',
adFree: 'Ad-free', adFree: 'Ad-free',
}, },
// P0: Subscription Info Modal
subscriptionInfo: 'Subscription Info',
active: 'Active',
expiresAt: 'Expires at',
daysLeftShort: 'days',
vehicleLimit: 'Vehicle Limit',
garageLimit: 'Garage Limit',
managePlan: 'Manage Plan',
indefinite: 'Indefinite',
}, },
landing: { landing: {
openGarage: 'Open Garage', openGarage: 'Open Garage',
@@ -75,22 +111,17 @@ export default {
title: 'My Garage', title: 'My Garage',
subtitle: 'Managing {count} vehicles', subtitle: 'Managing {count} vehicles',
backToDashboard: 'Back to Dashboard', backToDashboard: 'Back to Dashboard',
backToGarage: 'Back to Garage',
noPlate: 'No Plate',
primary: 'Primary', primary: 'Primary',
brandModel: 'Brand / Model', brandModel: 'Brand / Model',
year: 'Year',
nextService: 'Next Service',
mileage: 'Mileage',
emptyTitle: 'Your garage is empty', emptyTitle: 'Your garage is empty',
emptySubtitle: 'Add your first vehicle to get started!', emptySubtitle: 'Add your first vehicle to get started!',
addVehicle: 'Add Vehicle',
}, },
vehicleDetail: { vehicleDetail: {
title: 'Vehicle Details', title: 'Vehicle Details',
placeholderTitle: 'Deep Dive Coming Soon', placeholderTitle: 'Deep Dive Coming Soon',
placeholderSubtitle: 'This page will soon feature a full tabbed layout with detailed vehicle information, service history, costs, and more.', placeholderSubtitle: 'This page will soon feature a full tabbed layout with detailed vehicle information, service history, costs, and more.',
notFound: 'Vehicle not found.', notFound: 'Vehicle not found.',
noPlate: 'No Plate',
// Tab labels // Tab labels
tabOverview: 'Overview', tabOverview: 'Overview',
tabTechData: 'Technical Data', tabTechData: 'Technical Data',
@@ -104,21 +135,52 @@ export default {
placeholderFinancials: 'Financial records will appear here.', placeholderFinancials: 'Financial records will appear here.',
placeholderDocuments: 'Documents will appear here.', placeholderDocuments: 'Documents will appear here.',
// OverviewTab keys // OverviewTab keys
noPhoto: 'No photo',
photoPlaceholder: 'Photo upload coming soon', photoPlaceholder: 'Photo upload coming soon',
vin: 'VIN',
fuelType: 'Fuel', fuelType: 'Fuel',
mileage: 'Mileage',
motExpiry: 'MOT Expiry', motExpiry: 'MOT Expiry',
nextService: 'Next Service',
noData: 'No data',
comingSoon: 'Coming soon', comingSoon: 'Coming soon',
monthlyCost: 'Monthly Cost',
monthlyMileage: 'Monthly Mileage',
recentExpenses: 'Recent Expenses',
noExpenses: 'No recorded expenses yet',
deadlines: 'Deadlines',
// TechDataTab section titles
sectionBasicSpecs: 'Basic Specs',
sectionEngineDrivetrain: 'Engine & Drivetrain',
sectionBodyDimensions: 'Body & Dimensions',
sectionEvSpecs: 'Electric Drive',
sectionFeatures: 'Features / Equipment',
// TechDataTab field labels
vehicleClass: 'Class',
trimLevel: 'Trim Level',
transmission: 'Transmission',
driveType: 'Drive Type',
bodyType: 'Body Type',
doors: 'Doors',
seats: 'Seats',
cylinderLayout: 'Cylinder Layout',
acType: 'A/C Type',
batteryCapacity: 'Battery (kWh)',
range: 'Range (km)',
acConnector: 'AC Connector',
dcConnector: 'DC Connector',
engineCapacity: 'Engine Capacity (cm³)',
powerKw: 'Power (kW)',
powerUnitLabel: 'HP/KW',
powerLe: 'Power (HP)',
torqueNm: 'Torque (Nm)',
euroClass: 'Euro Class',
curbWeight: 'Curb Weight (kg)',
maxWeight: 'Max Gross Weight (kg)',
mileage: 'Mileage (km)',
conditionScore: 'Condition Score',
}, },
header: { header: {
home: 'Home',
welcome: 'Welcome', welcome: 'Welcome',
userMenu: 'User menu', userMenu: 'User menu',
profile: 'Profile Settings',
logout: 'Logout', logout: 'Logout',
subtitle: 'Your dashboard and vehicle overview',
switchLanguage: 'Switch language', switchLanguage: 'Switch language',
close: 'Close', close: 'Close',
noCompanies: 'No companies yet', noCompanies: 'No companies yet',
@@ -145,16 +207,14 @@ export default {
regionDescription: 'Date, time & currency format', regionDescription: 'Date, time & currency format',
dashboard: 'Dashboard', dashboard: 'Dashboard',
menu: 'Menu', menu: 'Menu',
subscription: '💎 Subscription',
adminCenter: '🔐 Admin Center', adminCenter: '🔐 Admin Center',
}, },
dashboard: { dashboard: {
loading: 'Loading...', loading: 'Loading...',
errorRetry: 'Retry',
statusActive: 'Active',
statusUnknown: 'Unknown', statusUnknown: 'Unknown',
condition: 'Condition', condition: 'Condition',
mileage: 'km', mileage: 'km',
subtitle: 'Your dashboard and vehicle overview',
guest: 'Guest', guest: 'Guest',
// Bento Box layout // Bento Box layout
myVehicles: 'My Vehicles', myVehicles: 'My Vehicles',
@@ -183,6 +243,7 @@ export default {
recordFueling: 'Record Fueling', recordFueling: 'Record Fueling',
parkingToll: 'Parking / Toll', parkingToll: 'Parking / Toll',
additionalCosts: 'Additional Costs', additionalCosts: 'Additional Costs',
detailedInvoice: '🧾 Detailed Invoice',
findServicePartners: 'Find reliable service partners', findServicePartners: 'Find reliable service partners',
allVehicles: 'All Vehicles', allVehicles: 'All Vehicles',
// ── Dashboard Card 5: Profile & Trust ── // ── Dashboard Card 5: Profile & Trust ──
@@ -191,6 +252,9 @@ export default {
notifications: 'Notifications', notifications: 'Notifications',
settings: 'Settings', settings: 'Settings',
}, },
costs: {
backToDashboard: 'Back to Dashboard',
},
finance: { finance: {
wallet: 'Wallet', wallet: 'Wallet',
totalCredits: 'Total Credits', totalCredits: 'Total Credits',
@@ -200,7 +264,6 @@ export default {
voucher: 'Voucher', voucher: 'Voucher',
topUp: 'Top Up', topUp: 'Top Up',
live: 'LIVE', live: 'LIVE',
retry: 'Retry',
// WalletDetailsModal keys // WalletDetailsModal keys
walletDetails: 'Wallet Details', walletDetails: 'Wallet Details',
walletBreakdown: 'Balance Breakdown', walletBreakdown: 'Balance Breakdown',
@@ -224,6 +287,88 @@ export default {
noCosts: 'No costs found', noCosts: 'No costs found',
costManagerDescription: 'Overview of all costs across your vehicles', costManagerDescription: 'Overview of all costs across your vehicles',
payoutComingSoon: 'Payout feature coming soon!', payoutComingSoon: 'Payout feature coming soon!',
// ── Finance Tab (FinancialsTab.vue) ─────────────────────────────────
purchasing_section_title: 'Purchasing & Leasing',
insurance_section_title: 'Insurances & Taxes',
tco_section_title: 'TCO / Expenses',
no_financials: 'No financial data recorded yet.',
add_financials: 'Add Financial Data',
purchase_price_gross: 'Gross Purchase Price',
purchase_price_net: 'Net Purchase Price',
down_payment: 'Down Payment',
monthly_installment: 'Monthly Installment',
contract_number: 'Contract Number',
financing_provider: 'Financing Provider',
interest_rate: 'Interest Rate',
residual_value: 'Residual Value',
contract_start_date: 'Contract Start',
contract_end_date: 'Contract End',
edit_financials: 'Edit Purchasing & Leasing',
no_insurance_taxes: 'No insurances or taxes recorded yet.',
add_insurance: '+ Insurance',
add_tax: '+ Tax',
insurance: 'Insurance',
policy_number: 'Policy Number',
premium: 'Premium',
valid_from: 'Valid From',
valid_until: 'Valid Until',
insurer_name: 'Insurer Name',
policy_type: 'Policy Type',
currency: 'Currency',
tax_type: 'Tax Type',
amount: 'Amount',
due_date: 'Due Date',
paid: 'Paid',
unpaid: 'Unpaid',
tco_placeholder: 'TCO / expense data will appear here.',
add_cost: '+ Cost',
tax_section_title: 'Tax Obligations',
noFinancialData: 'No financial data available',
// ── CostsView Tabbed Dashboard ──
analyticsTab: '📊 Overview',
transactionsTab: '📋 Transactions',
filterByVehicle: 'Filter by Vehicle',
allVehicles: 'All Vehicles (Entire Fleet)',
periodFilter: 'Period',
periodThisYear: 'This Year',
periodLast30Days: 'Last 30 Days',
periodAll: 'All Time',
addCost: '+ Add Cost',
totalExpenses: 'Total Expenses',
totalFiltered: 'Total (Filtered)',
avgPerVehicle: 'Avg. per Vehicle',
monthlyChartPlaceholder: 'Monthly Summary Chart',
categoryChartPlaceholder: 'Category Pie Chart',
},
costWizard: {
title: '🧾 Detailed Invoice',
step1: 'Basic Data',
step2: 'Financials',
step3: 'Admin & Supplier',
step4: 'Files',
step1Desc: 'Vehicle, category & date',
step2Desc: 'Amount, VAT & currency',
step3Desc: 'Supplier, invoice & deadline',
step4Desc: 'Attach documents',
selectVehicle: 'Select Vehicle',
selectCategory: 'Select Category',
selectSubCategory: 'Select Subcategory',
costDate: 'Cost Date',
netAmount: 'Net Amount',
vatRate: 'VAT Rate',
grossAmount: 'Gross Amount',
currency: 'Currency',
vendor: 'Supplier',
vendorPlaceholder: 'Search or type supplier name...',
invoiceNumber: 'Invoice Number',
invoiceDate: 'Invoice Date',
paymentDeadline: 'Payment Deadline',
attachFiles: 'Attach Files',
dragDrop: 'Drag & drop files here, or click to browse',
noFiles: 'No files attached',
fileAttached: 'file(s) attached',
success: 'Invoice saved successfully!',
error: 'Failed to save invoice.',
}, },
zen: { zen: {
hide: 'Hide UI (Zen mode)', hide: 'Hide UI (Zen mode)',
@@ -287,15 +432,12 @@ export default {
restoreCodeSent: 'The restoration code has been sent to your email!', restoreCodeSent: 'The restoration code has been sent to your email!',
}, },
profile: { profile: {
title: 'Profile Settings',
loading: 'Loading profile data...', loading: 'Loading profile data...',
completion: 'Profile Completion', completion: 'Profile Completion',
accountInfo: 'Account Info', accountInfo: 'Account Info',
email: 'Email', email: 'Email',
phone: 'Phone Number', phone: 'Phone Number',
edit: 'Edit', edit: 'Edit',
cancel: 'Cancel',
save: 'Save',
changePassword: 'Change Password', changePassword: 'Change Password',
// Tabs // Tabs
tabPersonal: 'Personal & Address', tabPersonal: 'Personal & Address',
@@ -310,15 +452,7 @@ export default {
mothersFirstName: 'Mother\'s First Name', mothersFirstName: 'Mother\'s First Name',
// Address // Address
addressTitle: 'Address Data', addressTitle: 'Address Data',
zipCode: 'ZIP Code',
city: 'City',
street: 'Street', street: 'Street',
streetType: 'Street Type',
houseNumber: 'House Number',
stairwell: 'Stairwell',
floor: 'Floor',
door: 'Door',
parcelId: 'Parcel ID',
hrsz: 'Parcel No.', hrsz: 'Parcel No.',
selectPlaceholder: 'Select...', selectPlaceholder: 'Select...',
// Identity docs // Identity docs
@@ -376,8 +510,6 @@ export default {
// Last Admin Warning // Last Admin Warning
lastAdminWarningTitle: '⚠️ 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!', 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 // Diagnostic
xray: '🩻 X-RAY: person (address_* fields)', xray: '🩻 X-RAY: person (address_* fields)',
}, },
@@ -389,13 +521,8 @@ export default {
addressTitle: '📍 Address Data', addressTitle: '📍 Address Data',
addressDescription: 'ZIP code and city are required to activate your account.', addressDescription: 'ZIP code and city are required to activate your account.',
softKyc: '(Soft KYC)', softKyc: '(Soft KYC)',
country: 'Country',
zipCode: 'ZIP Code',
city: 'City',
optionalAddress: '📝 Additional Address Details (optional)', optionalAddress: '📝 Additional Address Details (optional)',
streetName: 'Street Name',
streetType: 'Type', streetType: 'Type',
houseNumber: 'House Number',
// Step 2 // Step 2
extrasTitle: '🔧 Additional Data', extrasTitle: '🔧 Additional Data',
extrasDescription: 'You can provide these details later in your profile.', extrasDescription: 'You can provide these details later in your profile.',
@@ -409,7 +536,6 @@ export default {
language: '🌐 Language', language: '🌐 Language',
currency: '💰 Currency', currency: '💰 Currency',
// Navigation // Navigation
back: 'Back',
next: 'Next', next: 'Next',
submitting: 'Processing...', submitting: 'Processing...',
submit: 'Activate Garage 🚀', submit: 'Activate Garage 🚀',
@@ -489,18 +615,8 @@ export default {
// Step 2 // Step 2
addressTitle: '📍 Address & Finance', addressTitle: '📍 Address & Finance',
addressDescription: 'Company headquarters address and default financial settings.', addressDescription: 'Company headquarters address and default financial settings.',
country: 'Country',
language: 'Language', language: 'Language',
currency: 'Currency', currency: 'Currency',
zipCode: 'ZIP Code',
city: 'City',
streetName: 'Street Name',
streetType: 'Type',
houseNumber: 'House Number',
stairwell: 'Stairwell',
floor: 'Floor',
door: 'Door',
parcelId: 'Parcel ID',
// Step 3 // Step 3
contactTitle: '📞 Contact', contactTitle: '📞 Contact',
contactDescription: 'Primary contact details (the logged-in user) cannot be modified.', contactDescription: 'Primary contact details (the logged-in user) cannot be modified.',
@@ -508,8 +624,6 @@ export default {
contactEmail: 'Email Address', contactEmail: 'Email Address',
contactPhone: 'Phone Number', contactPhone: 'Phone Number',
// Navigation // Navigation
cancel: 'Cancel',
back: 'Back',
next: 'Next', next: 'Next',
submitting: 'Creating...', submitting: 'Creating...',
submit: 'Create Company 🚀', submit: 'Create Company 🚀',
@@ -564,8 +678,6 @@ export default {
tabLabel: 'Service Book', tabLabel: 'Service Book',
noEvents: 'No service events recorded yet.', noEvents: 'No service events recorded yet.',
addEvent: 'Add Event', addEvent: 'Add Event',
cancel: 'Cancel',
saving: 'Saving...',
eventDate: 'Event Date', eventDate: 'Event Date',
odometer: 'Odometer (km)', odometer: 'Odometer (km)',
eventType: 'Event Type', eventType: 'Event Type',
@@ -591,13 +703,12 @@ export default {
// ── Vehicle ────────────────────────────────────────────────────── // ── Vehicle ──────────────────────────────────────────────────────
vehicle: { vehicle: {
add: 'Add New Vehicle',
edit_title: 'Edit Vehicle', edit_title: 'Edit Vehicle',
add_title: 'Add New Vehicle', add_title: 'Add New Vehicle',
features_hint: 'Select the vehicle equipment and extra options.', features_hint: 'Select the vehicle equipment and extra options.',
// ── Vehicle Card ── // ── Vehicle Card ──
noPhoto: 'No photo uploaded', noPhoto: 'No photo uploaded',
nextService: 'Next Service',
noData: 'No data',
primaryVehicle: 'Primary vehicle', primaryVehicle: 'Primary vehicle',
setPrimary: 'Set as primary', setPrimary: 'Set as primary',
// Fuel types // Fuel types
@@ -651,6 +762,7 @@ export default {
placeholder_custom_class: 'e.g. Quad, Segway, Golf Cart', placeholder_custom_class: 'e.g. Quad, Segway, Golf Cart',
placeholder_engine_capacity: '1995', placeholder_engine_capacity: '1995',
placeholder_power_kw: '150', placeholder_power_kw: '150',
placeholder_power_le: '204',
placeholder_torque_nm: '320', placeholder_torque_nm: '320',
placeholder_type_designation: 'e.g. GTI, M Sport, AMG', placeholder_type_designation: 'e.g. GTI, M Sport, AMG',
placeholder_curb_weight: '1500', placeholder_curb_weight: '1500',
@@ -664,7 +776,6 @@ export default {
label_custom_class: 'Please specify the vehicle type', label_custom_class: 'Please specify the vehicle type',
label_dates_section: 'Dates', label_dates_section: 'Dates',
label_first_registration: 'First Registration Date', label_first_registration: 'First Registration Date',
label_next_mot: 'Next MOT Expiry',
label_insurance_expiry: 'Insurance Expiry', label_insurance_expiry: 'Insurance Expiry',
label_purchase_date: 'Purchase Date', label_purchase_date: 'Purchase Date',
label_color: 'Color', label_color: 'Color',
@@ -725,7 +836,7 @@ export default {
label_transmission: 'Transmission', label_transmission: 'Transmission',
label_body_type: 'Body Type', label_body_type: 'Body Type',
label_engine: 'Engine', label_engine: 'Engine',
label_vin: 'VIN (Chassis Number)', vin: 'VIN (Chassis Number)',
label_mileage: 'Current Mileage', label_mileage: 'Current Mileage',
label_fuel_type: 'Fuel Type', label_fuel_type: 'Fuel Type',
label_trim_level: 'Trim Level', label_trim_level: 'Trim Level',
@@ -740,6 +851,7 @@ export default {
label_ev_section: 'Electric Drive / Battery', label_ev_section: 'Electric Drive / Battery',
label_engine_capacity: 'Engine Capacity (cm³)', label_engine_capacity: 'Engine Capacity (cm³)',
label_power_kw: 'Power (kW)', label_power_kw: 'Power (kW)',
label_power_le: 'Power (HP)',
label_torque_nm: 'Torque (Nm)', label_torque_nm: 'Torque (Nm)',
label_curb_weight: 'Curb Weight (kg)', label_curb_weight: 'Curb Weight (kg)',
label_max_weight: 'Max Gross Weight (kg)', label_max_weight: 'Max Gross Weight (kg)',
@@ -760,6 +872,13 @@ export default {
warranty_section_title: 'Warranty / Guarantee', warranty_section_title: 'Warranty / Guarantee',
label_is_under_warranty: 'Valid Warranty', label_is_under_warranty: 'Valid Warranty',
label_warranty_expiry_date: 'Warranty Expiry Date', label_warranty_expiry_date: 'Warranty Expiry Date',
// Registration Documents
registration_documents_title: 'Registration Certificate & Vehicle Document',
label_registration_certificate_number: 'Registration Certificate Number',
label_registration_certificate_validity: 'Registration Certificate Validity',
label_vehicle_registration_document_number: 'Vehicle Registration Document Number',
placeholder_registration_certificate_number: 'e.g. AB-123456',
placeholder_vehicle_registration_document_number: 'e.g. 1234567890',
// Tab labels // Tab labels
tab_basic: 'Basic Data', tab_basic: 'Basic Data',
tab_technical: 'Technical', tab_technical: 'Technical',
@@ -774,7 +893,6 @@ export default {
// Save button // Save button
save_changes: 'Save Changes', save_changes: 'Save Changes',
add_vehicle: 'Add Vehicle', add_vehicle: 'Add Vehicle',
saving: 'Saving...',
// Validation // Validation
required_fields_hint: 'Please fill in the required fields', required_fields_hint: 'Please fill in the required fields',
// Condition options // Condition options
@@ -792,7 +910,7 @@ export default {
}, },
// Features (extras) // Features (extras)
features: { features: {
abs: 'ABS', abs: 'ABS (Anti-lock Braking System)',
esp: 'ESP (Electronic Stability)', esp: 'ESP (Electronic Stability)',
traction_control: 'Traction Control (TCS)', traction_control: 'Traction Control (TCS)',
hill_assist: 'Hill Start Assist', hill_assist: 'Hill Start Assist',
@@ -884,7 +1002,7 @@ export default {
airbag_passenger: 'Passenger Airbag', airbag_passenger: 'Passenger Airbag',
airbag_side: 'Side Airbag', airbag_side: 'Side Airbag',
airbag_curtain: 'Curtain Airbag', airbag_curtain: 'Curtain Airbag',
isofix: 'ISOFIX', isofix: 'ISOFIX Child Seat Anchors',
tpms: 'Tire Pressure Monitoring System', tpms: 'Tire Pressure Monitoring System',
electric_seat_adjust: 'Electric Seat Adjustment', electric_seat_adjust: 'Electric Seat Adjustment',
heated_steering_wheel: 'Heated Steering Wheel', heated_steering_wheel: 'Heated Steering Wheel',
@@ -915,7 +1033,7 @@ export default {
smoking_package: 'Smoking Package', smoking_package: 'Smoking Package',
pet_friendly: 'Pet Friendly', pet_friendly: 'Pet Friendly',
child_lock: 'Child Lock', child_lock: 'Child Lock',
anti_theft: 'Anti-Theft', alarm: 'Alarm System',
gps_tracker: 'GPS Tracker', gps_tracker: 'GPS Tracker',
service_history: 'Service History', service_history: 'Service History',
}, },
@@ -952,7 +1070,7 @@ export default {
multimedia: 'Multimedia', multimedia: 'Multimedia',
other: 'Other', other: 'Other',
}, },
// Motorcycle features (extras) // Motorcycle features (extras) — only strictly unique items remain
motorcycleFeatures: { motorcycleFeatures: {
// Technical // Technical
dual_front_disc: 'Dual Front Disc Brake', dual_front_disc: 'Dual Front Disc Brake',
@@ -967,23 +1085,15 @@ export default {
catalyst: 'Catalytic Converter', catalyst: 'Catalytic Converter',
electric_starter: 'Electric Starter', electric_starter: 'Electric Starter',
all_wheel_drive: 'All-Wheel Drive', all_wheel_drive: 'All-Wheel Drive',
alarm: 'Alarm System',
sport_exhaust: 'Sport Exhaust', sport_exhaust: 'Sport Exhaust',
sport_air_filter: 'Sport Air Filter', sport_air_filter: 'Sport Air Filter',
cruise_control: 'Cruise Control',
turbo: 'Turbocharger', turbo: 'Turbocharger',
'12v_system': '12V Power Socket', '12v_system': '12V Power Socket',
heated_grip: 'Heated Grips', heated_grip: 'Heated Grips',
abs: 'ABS',
seat_belt: 'Seat Belt', seat_belt: 'Seat Belt',
dtc: 'DTC (Traction Control)', dtc: 'DTC (Traction Control)',
fog_light: 'Fog Light',
airbag: 'Airbag',
xenon_headlight: 'Xenon Headlight',
// Frame & Body // Frame & Body
full_extra: 'Full Extra',
leather_seat: 'Leather Seat', leather_seat: 'Leather Seat',
heated_seat: 'Heated Seat',
backrest: 'Backrest', backrest: 'Backrest',
center_stand: 'Center Stand', center_stand: 'Center Stand',
footrest: 'Footrest', footrest: 'Footrest',
@@ -995,7 +1105,6 @@ export default {
crash_bar: 'Crash Bar', crash_bar: 'Crash Bar',
hand_guards: 'Hand Guards', hand_guards: 'Hand Guards',
heated_mirror: 'Heated Mirror', heated_mirror: 'Heated Mirror',
tow_hitch: 'Tow Hitch',
// Luggage // Luggage
factory_cases: 'Factory Cases', factory_cases: 'Factory Cases',
rear_case: 'Rear Case (Top Box)', rear_case: 'Rear Case (Top Box)',
@@ -1018,15 +1127,12 @@ export default {
showroom_vehicle: 'Showroom Vehicle', showroom_vehicle: 'Showroom Vehicle',
orderable: 'Orderable', orderable: 'Orderable',
car_trade_in_possible: 'Car Trade-In Possible', car_trade_in_possible: 'Car Trade-In Possible',
first_owner: 'First Owner',
garage_kept: 'Garage Kept',
female_owner: 'Female Owner', female_owner: 'Female Owner',
low_mileage: 'Low Mileage', low_mileage: 'Low Mileage',
second_owner: 'Second Owner', second_owner: 'Second Owner',
motorcycle_trade_in_possible: 'Motorcycle Trade-In Possible', motorcycle_trade_in_possible: 'Motorcycle Trade-In Possible',
track_fairing: 'Track Fairing', track_fairing: 'Track Fairing',
regularly_maintained: 'Regularly Maintained', regularly_maintained: 'Regularly Maintained',
service_book: 'Service Book',
title_certificate: 'Title Certificate', title_certificate: 'Title Certificate',
}, },
// ── LCV-specific labels ── // ── LCV-specific labels ──
@@ -1058,46 +1164,21 @@ export default {
exterior: 'Exterior', exterior: 'Exterior',
multimedia: 'Multimedia', multimedia: 'Multimedia',
}, },
// LCV features (extras) // LCV features (extras) — only strictly unique items remain
lcvFeatures: { lcvFeatures: {
// Technical // Technical
abs: 'ABS',
asr: 'ASR (Traction Control)', asr: 'ASR (Traction Control)',
gps_tracker: 'GPS Tracker',
immobiliser: 'Immobiliser', immobiliser: 'Immobiliser',
alarm: 'Alarm',
cruise_control: 'Cruise Control',
parking_sensors_rear: 'Rear Parking Sensors',
// Interior // Interior
curtain_airbag: 'Curtain Airbag',
rear_side_airbag: 'Rear Side Airbag',
switchable_airbag: 'Switchable Passenger Airbag',
side_airbag: 'Side Airbag',
passenger_airbag: 'Passenger Airbag',
driver_airbag: 'Driver Airbag',
roll_bar: 'Roll Bar', roll_bar: 'Roll Bar',
cargo_tie_down: 'Cargo Tie-Down Points', cargo_tie_down: 'Cargo Tie-Down Points',
isofix: 'ISOFIX Child Seat Anchors',
full_extra: 'Full Extra',
auxiliary_heating: 'Auxiliary Heater (Webasto)', auxiliary_heating: 'Auxiliary Heater (Webasto)',
leather_interior: 'Leather Interior', leather_interior: 'Leather Interior',
heated_seat: 'Heated Seat',
partition_wall: 'Partition Wall', partition_wall: 'Partition Wall',
seat_height_adjustment: 'Seat Height Adjustment', seat_height_adjustment: 'Seat Height Adjustment',
adjustable_steering_wheel: 'Adjustable Steering Wheel', adjustable_steering_wheel: 'Adjustable Steering Wheel',
central_locking: 'Central Locking',
trip_computer: 'Trip Computer',
power_steering: 'Power Steering',
// Exterior // Exterior
power_windows: 'Power Windows',
power_mirrors: 'Power Mirrors', power_mirrors: 'Power Mirrors',
heated_mirrors: 'Heated Mirrors',
alloy_wheels: 'Alloy Wheels',
tinted_glass: 'Tinted Glass',
tow_bar: 'Tow Bar',
sunroof: 'Sunroof',
fog_lights: 'Fog Lights',
xenon_headlights: 'Xenon Headlights',
// Multimedia // Multimedia
cd_changer: 'CD Changer', cd_changer: 'CD Changer',
cd_radio: 'CD Radio', cd_radio: 'CD Radio',
@@ -1207,7 +1288,7 @@ export default {
cabin: 'Cabin', cabin: 'Cabin',
multimedia: 'Multimedia', multimedia: 'Multimedia',
}, },
// HGV features (extras) // HGV features (extras) — only strictly unique items remain
hgvFeatures: { hgvFeatures: {
// Technical / Work // Technical / Work
diff_lock_front: 'Front Differential Lock', diff_lock_front: 'Front Differential Lock',
@@ -1220,23 +1301,16 @@ export default {
engine_brake: 'Engine Brake', engine_brake: 'Engine Brake',
auxiliary_brake: 'Auxiliary Brake', auxiliary_brake: 'Auxiliary Brake',
hill_holder: 'Hill Holder', hill_holder: 'Hill Holder',
hill_descent_control: 'Hill Descent Control',
traction_control: 'Traction Control',
stability_control: 'Stability Control (ESP)', stability_control: 'Stability Control (ESP)',
roll_stability: 'Roll Stability Program (RSP)', roll_stability: 'Roll Stability Program (RSP)',
lane_departure: 'Lane Departure Warning', lane_departure: 'Lane Departure Warning',
adaptive_cruise: 'Adaptive Cruise Control (ACC)',
collision_warning: 'Collision Warning', collision_warning: 'Collision Warning',
emergency_brake_assist: 'Emergency Brake Assist', emergency_brake_assist: 'Emergency Brake Assist',
blind_spot_monitor: 'Blind Spot Monitor', blind_spot_monitor: 'Blind Spot Monitor',
traffic_sign_recognition: 'Traffic Sign Recognition',
driver_alert: 'Driver Alert System',
tyre_pressure_monitor: 'Tyre Pressure Monitor (TPMS)', tyre_pressure_monitor: 'Tyre Pressure Monitor (TPMS)',
reverse_camera: 'Reverse Camera', reverse_camera: 'Reverse Camera',
'360_camera': '360° Camera',
side_camera: 'Side Camera', side_camera: 'Side Camera',
front_camera: 'Front Camera', front_camera: 'Front Camera',
rear_camera: 'Rear Camera',
parking_sensors: 'Parking Sensors', parking_sensors: 'Parking Sensors',
front_parking_sensors: 'Front Parking Sensors', front_parking_sensors: 'Front Parking Sensors',
rear_parking_sensors: 'Rear Parking Sensors', rear_parking_sensors: 'Rear Parking Sensors',
@@ -1244,7 +1318,6 @@ export default {
tachograph: 'Tachograph', tachograph: 'Tachograph',
digital_tachograph: 'Digital Tachograph', digital_tachograph: 'Digital Tachograph',
smart_tachograph: 'Smart Tachograph (G2V2)', smart_tachograph: 'Smart Tachograph (G2V2)',
gps_tracker: 'GPS Tracker',
fleet_management: 'Fleet Management System', fleet_management: 'Fleet Management System',
fuel_monitoring: 'Fuel Monitoring System', fuel_monitoring: 'Fuel Monitoring System',
eco_driving: 'Eco Driving Assistant', eco_driving: 'Eco Driving Assistant',
@@ -1281,24 +1354,28 @@ export default {
armrest: 'Armrest Seat', armrest: 'Armrest Seat',
storage_box: 'Storage Box', storage_box: 'Storage Box',
wardrobe: 'Wardrobe', wardrobe: 'Wardrobe',
central_locking: 'Central Locking',
adjustable_steering: 'Adjustable Steering',
// Multimedia // Multimedia
navigation: 'Navigation (GPS)',
apple_carplay: 'Apple CarPlay',
android_auto: 'Android Auto',
bluetooth: 'Bluetooth',
usb_charging: 'USB Charging',
wireless_charging: 'Wireless Charging',
dab_radio: 'DAB+ Digital Radio',
cb_radio: 'CB Radio', cb_radio: 'CB Radio',
satellite_radio: 'Satellite Radio', satellite_radio: 'Satellite Radio',
digital_tv: 'Digital TV', digital_tv: 'Digital TV',
dvb_t: 'DVB-T TV', dvb_t: 'DVB-T TV',
dvb_s: 'DVB-S Satellite TV', dvb_s: 'DVB-S Satellite TV',
premium_sound: 'Premium Sound System',
subwoofer: 'Subwoofer',
rear_entertainment: 'Rear Entertainment System',
wifi_hotspot: 'WiFi Hotspot',
}, },
// ── Admin Fields (VehicleFormModal) ────────────────────────────────
admin_section_title: 'Administrative Data',
label_registration_country: 'Registration Country',
placeholder_registration_country: 'e.g. Hungary',
label_first_domestic_registration_date: 'First Domestic Registration',
label_import_country: 'Import Country',
placeholder_import_country: 'e.g. Germany',
label_title_document_number: 'Title Document Number',
placeholder_title_document_number: 'e.g. TL-123456',
label_engine_number: 'Engine Number',
placeholder_engine_number: 'e.g. M123456789',
label_number_of_previous_owners: 'Number of Previous Owners',
placeholder_number_of_previous_owners: 'e.g. 2',
}, },
// ── Service Finder ──────────────────────────────────────────────── // ── Service Finder ────────────────────────────────────────────────
@@ -1411,7 +1488,6 @@ export default {
tabServices: 'Services', tabServices: 'Services',
addressSection: 'Address Details', addressSection: 'Address Details',
contactSection: 'Contact Details', contactSection: 'Contact Details',
streetNameLabel: 'Street Name',
streetNameOptional: 'optional', streetNameOptional: 'optional',
streetNamePlaceholder: 'e.g. Egressy', streetNamePlaceholder: 'e.g. Egressy',
streetTypeLabel: 'Street Type', streetTypeLabel: 'Street Type',
@@ -1490,8 +1566,6 @@ export default {
field_description: 'Description', field_description: 'Description',
field_description_placeholder: 'Describe what this service does...', field_description_placeholder: 'Describe what this service does...',
field_credit_cost: 'Credit Cost', field_credit_cost: 'Credit Cost',
cancel: 'Cancel',
saving: 'Saving...',
save_changes: 'Save Changes', save_changes: 'Save Changes',
create: 'Create Service', create: 'Create Service',
}, },
@@ -1538,8 +1612,6 @@ export default {
date_until_label: 'Date Until', date_until_label: 'Date Until',
field_entitlements: 'Services (Entitlements)', field_entitlements: 'Services (Entitlements)',
entitlement_placeholder: 'Select a service to add...', entitlement_placeholder: 'Select a service to add...',
cancel: 'Cancel',
saving: 'Saving...',
save_changes: 'Save Changes', save_changes: 'Save Changes',
create: 'Create Package', create: 'Create Package',
}, },
@@ -1556,7 +1628,6 @@ export default {
clear_search: 'Clear search', clear_search: 'Clear search',
filter_all_roles: 'All Roles', filter_all_roles: 'All Roles',
filter_all_status: 'All Status', filter_all_status: 'All Status',
status_active: 'Active',
status_inactive: 'Inactive', status_inactive: 'Inactive',
status_deleted: 'Deleted', status_deleted: 'Deleted',
refresh: 'Refresh', refresh: 'Refresh',

View File

@@ -13,6 +13,33 @@ export default {
fuel: 'Üzemanyag', fuel: 'Üzemanyag',
mileage: 'Futásteljesítmény', mileage: 'Futásteljesítmény',
year: 'Évjárat', year: 'Évjárat',
noPlate: 'Nincs rendszám',
nextService: 'Következő Szerviz',
noData: 'Nincs adat',
backToGarage: 'Vissza a Garázsba',
statusActive: 'Aktív',
country: 'Ország',
zipCode: 'Irányítószám',
city: 'Város',
houseNumber: 'Házszám',
stairwell: 'Lépcsőház',
floor: 'Emelet',
door: 'Ajtó',
parcelId: 'Helyrajzi szám',
streetName: 'Utca neve',
dashboardSubtitle: 'Irányítópultod és járműveid áttekintése',
profileSettings: 'Profil beállítások',
streetType: 'Közterület jellege',
email: 'E-mail cím',
phone: 'Telefonszám',
na: 'N/A',
edit: 'Szerkesztés',
delete: 'Törlés',
close: 'Bezárás',
next: 'Következő →',
prev: '← Előző',
actions: 'Műveletek',
items: 'db',
}, },
subscription: { subscription: {
title: 'Előfizetési Csomagok', title: 'Előfizetési Csomagok',
@@ -45,6 +72,15 @@ export default {
prioritySupport: 'Prioritásos támogatás', prioritySupport: 'Prioritásos támogatás',
adFree: 'Reklámmentes', adFree: 'Reklámmentes',
}, },
// P0: Subscription Info Modal
subscriptionInfo: 'Előfizetés Információ',
active: 'Aktív',
expiresAt: 'Lejárat dátuma',
daysLeftShort: 'nap',
vehicleLimit: 'Jármű Limit',
garageLimit: 'Garázs Limit',
managePlan: 'Csomag Kezelése',
indefinite: 'Határozatlan',
}, },
landing: { landing: {
openGarage: 'Garázs Nyitása', openGarage: 'Garázs Nyitása',
@@ -75,22 +111,17 @@ export default {
title: 'Garázsom', title: 'Garázsom',
subtitle: '{count} jármű kezelése', subtitle: '{count} jármű kezelése',
backToDashboard: 'Vissza az irányítópultra', backToDashboard: 'Vissza az irányítópultra',
backToGarage: 'Vissza a Garázsba',
noPlate: 'Nincs rendszám',
primary: 'Elsődleges', primary: 'Elsődleges',
brandModel: 'Márka / Modell', brandModel: 'Márka / Modell',
year: 'Évjárat',
nextService: 'Következő Szerviz',
mileage: 'Futásteljesítmény',
emptyTitle: 'A garázsod üres', emptyTitle: 'A garázsod üres',
emptySubtitle: 'Add hozzá az első járművedet!', emptySubtitle: 'Add hozzá az első járművedet!',
addVehicle: 'Jármű hozzáadása',
}, },
vehicleDetail: { vehicleDetail: {
title: 'Jármű Részletek', title: 'Jármű Részletek',
placeholderTitle: 'Részletes nézet hamarosan', placeholderTitle: 'Részletes nézet hamarosan',
placeholderSubtitle: 'Ez az oldal hamarosan teljes tabokkal ellátott részletes járműinformációkat, szerviztörténetet, költségeket és egyebeket tartalmaz.', placeholderSubtitle: 'Ez az oldal hamarosan teljes tabokkal ellátott részletes járműinformációkat, szerviztörténetet, költségeket és egyebeket tartalmaz.',
notFound: 'A jármű nem található.', notFound: 'A jármű nem található.',
noPlate: 'Nincs rendszám',
tabOverview: 'Áttekintés', tabOverview: 'Áttekintés',
tabTechData: 'Műszaki adatok', tabTechData: 'Műszaki adatok',
tabServiceBook: 'Szervizkönyv', tabServiceBook: 'Szervizkönyv',
@@ -101,21 +132,52 @@ export default {
placeholderServiceBook: 'A szerviztörténet itt jelenik meg.', placeholderServiceBook: 'A szerviztörténet itt jelenik meg.',
placeholderFinancials: 'A pénzügyi adatok itt jelennek meg.', placeholderFinancials: 'A pénzügyi adatok itt jelennek meg.',
placeholderDocuments: 'A dokumentumok itt jelennek meg.', placeholderDocuments: 'A dokumentumok itt jelennek meg.',
noPhoto: 'Nincs fotó',
photoPlaceholder: 'Fotó feltöltés hamarosan', photoPlaceholder: 'Fotó feltöltés hamarosan',
vin: 'Alvázszám (VIN)',
fuelType: 'Üzemanyag', fuelType: 'Üzemanyag',
mileage: 'Futásteljesítmény',
motExpiry: 'Műszaki érvényesség', motExpiry: 'Műszaki érvényesség',
nextService: 'Következő Szerviz',
noData: 'Nincs adat',
comingSoon: 'Hamarosan', comingSoon: 'Hamarosan',
monthlyCost: 'Havi költség',
monthlyMileage: 'Havi futásteljesítmény',
recentExpenses: 'Legutóbbi kiadások',
noExpenses: 'Nincs még rögzített kiadás',
deadlines: 'Határidők',
// TechDataTab section titles
sectionBasicSpecs: 'Alapadatok',
sectionEngineDrivetrain: 'Motor és hajtás',
sectionBodyDimensions: 'Karosszéria és méretek',
sectionEvSpecs: 'Elektromos hajtás',
sectionFeatures: 'Felszereltség / Extrák',
// TechDataTab field labels
vehicleClass: 'Kategória',
trimLevel: 'Felszereltség',
transmission: 'Váltó',
driveType: 'Meghajtás',
bodyType: 'Karosszéria',
doors: 'Ajtók',
seats: 'Ülések',
cylinderLayout: 'Hengerelrendezés',
acType: 'Légkondícionáló',
batteryCapacity: 'Akkumulátor (kWh)',
range: 'Hatótáv (km)',
acConnector: 'AC csatlakozó',
dcConnector: 'DC csatlakozó',
engineCapacity: 'Lökettérfogat (cm³)',
powerKw: 'Teljesítmény (kW)',
powerUnitLabel: 'LE/KW',
powerLe: 'Teljesítmény (LE)',
torqueNm: 'Nyomaték (Nm)',
euroClass: 'Euro norma',
curbWeight: 'Saját tömeg (kg)',
maxWeight: 'Össztömeg (kg)',
mileage: 'Futásteljesítmény (km)',
conditionScore: 'Állapot pontszám',
}, },
header: { header: {
home: 'Kezdőlap',
welcome: 'Üdvözlünk', welcome: 'Üdvözlünk',
userMenu: 'Felhasználói menü', userMenu: 'Felhasználói menü',
profile: 'Profil beállítások',
logout: 'Kilépés', logout: 'Kilépés',
subtitle: 'Irányítópultod és járműveid áttekintése',
switchLanguage: 'Nyelv váltása', switchLanguage: 'Nyelv váltása',
close: 'Bezárás', close: 'Bezárás',
noCompanies: 'Még nincs céged', noCompanies: 'Még nincs céged',
@@ -142,16 +204,14 @@ export default {
regionDescription: 'Dátum, idő és pénznem formátum', regionDescription: 'Dátum, idő és pénznem formátum',
dashboard: 'Irányítópult', dashboard: 'Irányítópult',
menu: 'Menü', menu: 'Menü',
subscription: '💎 Előfizetés',
adminCenter: '🔐 Adminisztrációs Központ', adminCenter: '🔐 Adminisztrációs Központ',
}, },
dashboard: { dashboard: {
loading: 'Betöltés...', loading: 'Betöltés...',
errorRetry: 'Újrapróbálkozás',
statusActive: 'Aktív',
statusUnknown: 'Ismeretlen', statusUnknown: 'Ismeretlen',
condition: 'Állapot', condition: 'Állapot',
mileage: 'km', mileage: 'km',
subtitle: 'Irányítópultod és járműveid áttekintése',
guest: 'Vendég', guest: 'Vendég',
// Bento Box layout // Bento Box layout
myVehicles: 'Saját Járművek', myVehicles: 'Saját Járművek',
@@ -180,6 +240,7 @@ export default {
recordFueling: 'Tankolás rögzítése', recordFueling: 'Tankolás rögzítése',
parkingToll: 'Parkolás / Útdíj', parkingToll: 'Parkolás / Útdíj',
additionalCosts: 'További költségek', additionalCosts: 'További költségek',
detailedInvoice: '🧾 Részletes Számla',
findServicePartners: 'Keress megbízható szervizpartnereket', findServicePartners: 'Keress megbízható szervizpartnereket',
allVehicles: 'Összes jármű', allVehicles: 'Összes jármű',
// ── Dashboard Card 5: Profile & Trust ── // ── Dashboard Card 5: Profile & Trust ──
@@ -188,6 +249,9 @@ export default {
notifications: 'Értesítések', notifications: 'Értesítések',
settings: 'Beállítások', settings: 'Beállítások',
}, },
costs: {
backToDashboard: 'Vissza az irányítópultra',
},
finance: { finance: {
wallet: 'Pénztárca', wallet: 'Pénztárca',
totalCredits: 'Összes Kredit', totalCredits: 'Összes Kredit',
@@ -197,7 +261,6 @@ export default {
voucher: 'Voucher', voucher: 'Voucher',
topUp: 'Egyenleg Feltöltése', topUp: 'Egyenleg Feltöltése',
live: 'ÉLŐ', live: 'ÉLŐ',
retry: 'Újra',
// WalletDetailsModal keys // WalletDetailsModal keys
walletDetails: 'Pénztárca Részletek', walletDetails: 'Pénztárca Részletek',
walletBreakdown: 'Egyenleg Bontás', walletBreakdown: 'Egyenleg Bontás',
@@ -221,6 +284,88 @@ export default {
noCosts: 'Nincsenek költségek', noCosts: 'Nincsenek költségek',
costManagerDescription: 'Az összes járműhöz tartozó költségek áttekintése', costManagerDescription: 'Az összes járműhöz tartozó költségek áttekintése',
payoutComingSoon: 'Kifizetési funkció hamarosan!', payoutComingSoon: 'Kifizetési funkció hamarosan!',
// ── Finance Tab (FinancialsTab.vue) ─────────────────────────────────
purchasing_section_title: 'Beszerzés & Lízing',
insurance_section_title: 'Biztosítások & Adók',
tco_section_title: 'TCO / Költségek',
no_financials: 'Még nincsenek pénzügyi adatok rögzítve.',
add_financials: 'Pénzügyi adatok hozzáadása',
purchase_price_gross: 'Bruttó vételár',
purchase_price_net: 'Nettó vételár',
down_payment: 'Önerő',
monthly_installment: 'Havi törlesztő',
contract_number: 'Szerződésszám',
financing_provider: 'Finanszírozó',
interest_rate: 'Kamat',
residual_value: 'Maradványérték',
contract_start_date: 'Szerződés kezdete',
contract_end_date: 'Szerződés vége',
edit_financials: 'Beszerzés & Lízing adatok',
no_insurance_taxes: 'Még nincsenek biztosítások vagy adók rögzítve.',
add_insurance: '+ Biztosítás',
add_tax: '+ Adó',
insurance: 'Biztosítás',
policy_number: 'Kötvényszám',
premium: 'Díj',
valid_from: 'Érvényesség kezdete',
valid_until: 'Érvényesség vége',
insurer_name: 'Biztosító neve',
policy_type: 'Biztosítás típusa',
currency: 'Pénznem',
tax_type: 'Adó típusa',
amount: 'Összeg',
due_date: 'Esedékesség',
paid: 'Fizetve',
unpaid: 'Fizetendő',
tco_placeholder: 'A TCO / költségadatok itt jelennek meg.',
add_cost: '+ Költség',
tax_section_title: 'Adókötelezettségek',
noFinancialData: 'Nincsenek pénzügyi adatok',
// ── CostsView Tabbed Dashboard ──
analyticsTab: '📊 Áttekintés',
transactionsTab: '📋 Tranzakciók',
filterByVehicle: 'Jármű szűrő',
allVehicles: 'Teljes Flotta (Összes jármű)',
periodFilter: 'Időszak',
periodThisYear: 'Idei Év',
periodLast30Days: 'Elmúlt 30 nap',
periodAll: 'Összes',
addCost: ' Új Költség',
totalExpenses: 'Összes Költség',
totalFiltered: 'Összesen (Szűrt)',
avgPerVehicle: 'Átlag / Jármű',
monthlyChartPlaceholder: 'Havi Összesítő Chart',
categoryChartPlaceholder: 'Kategória Kördiagram',
},
costWizard: {
title: '🧾 Részletes Számla',
step1: 'Alapadatok',
step2: 'Pénzügyek',
step3: 'Admin & Szállító',
step4: 'Fájlok',
step1Desc: 'Jármű, kategória & dátum',
step2Desc: 'Összeg, ÁFA & pénznem',
step3Desc: 'Szállító, számla & határidő',
step4Desc: 'Dokumentumok csatolása',
selectVehicle: 'Jármű kiválasztása',
selectCategory: 'Kategória kiválasztása',
selectSubCategory: 'Alkategória kiválasztása',
costDate: 'Költség dátuma',
netAmount: 'Nettó összeg',
vatRate: 'ÁFA kulcs',
grossAmount: 'Bruttó összeg',
currency: 'Pénznem',
vendor: 'Szállító',
vendorPlaceholder: 'Keresés vagy szállító nevének beírása...',
invoiceNumber: 'Számlaszám',
invoiceDate: 'Számla dátuma',
paymentDeadline: 'Fizetési határidő',
attachFiles: 'Fájlok csatolása',
dragDrop: 'Húzd ide a fájlokat, vagy kattints a tallózáshoz',
noFiles: 'Nincs fájl csatolva',
fileAttached: 'fájl csatolva',
success: 'Számla sikeresen elmentve!',
error: 'Nem sikerült a számla mentése.',
}, },
zen: { zen: {
hide: 'UI elrejtése (Zen mód)', hide: 'UI elrejtése (Zen mód)',
@@ -284,15 +429,12 @@ export default {
restoreCodeSent: 'A visszaállítási kódot elküldtük az e-mail címedre!', restoreCodeSent: 'A visszaállítási kódot elküldtük az e-mail címedre!',
}, },
profile: { profile: {
title: 'Profil beállítások',
loading: 'Profil adatok betöltése...', loading: 'Profil adatok betöltése...',
completion: 'Profil kitöltöttsége', completion: 'Profil kitöltöttsége',
accountInfo: 'Fiók adatok', accountInfo: 'Fiók adatok',
email: 'Email', email: 'Email',
phone: 'Telefonszám', phone: 'Telefonszám',
edit: 'Szerkesztés', edit: 'Szerkesztés',
cancel: 'Mégse',
save: 'Mentés',
changePassword: 'Jelszó módosítása', changePassword: 'Jelszó módosítása',
// Tabs // Tabs
tabPersonal: 'Személyes és Lakcím', tabPersonal: 'Személyes és Lakcím',
@@ -307,15 +449,7 @@ export default {
mothersFirstName: 'Anyja keresztneve', mothersFirstName: 'Anyja keresztneve',
// Address // Address
addressTitle: 'Lakcím adatok', addressTitle: 'Lakcím adatok',
zipCode: 'Irányítószám',
city: 'Város',
street: 'Utca', street: 'Utca',
streetType: 'Közterület jellege',
houseNumber: 'Házszám',
stairwell: 'Lépcsőház',
floor: 'Emelet',
door: 'Ajtó',
parcelId: 'Helyrajzi szám',
hrsz: 'Hrsz.', hrsz: 'Hrsz.',
selectPlaceholder: 'Válassz...', selectPlaceholder: 'Válassz...',
// Identity docs // Identity docs
@@ -373,8 +507,6 @@ export default {
// Last Admin Warning // Last Admin Warning
lastAdminWarningTitle: '⚠️ Utolsó adminisztrátor figyelmeztetés', 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!', 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 // Diagnostic
xray: '🩻 RÖNTGEN: person (address_* fields)', xray: '🩻 RÖNTGEN: person (address_* fields)',
}, },
@@ -386,13 +518,8 @@ export default {
addressTitle: '📍 Lakcím adatok', addressTitle: '📍 Lakcím adatok',
addressDescription: 'Az irányítószám és város kötelező a fiókod aktiválásához.', addressDescription: 'Az irányítószám és város kötelező a fiókod aktiválásához.',
softKyc: '(Soft KYC)', softKyc: '(Soft KYC)',
country: 'Ország',
zipCode: 'Irányítószám',
city: 'Város',
optionalAddress: '📝 További címrészletek (opcionális)', optionalAddress: '📝 További címrészletek (opcionális)',
streetName: 'Utca neve',
streetType: 'Típus', streetType: 'Típus',
houseNumber: 'Házszám',
// Step 2 // Step 2
extrasTitle: '🔧 Kiegészítő adatok', extrasTitle: '🔧 Kiegészítő adatok',
extrasDescription: 'Ezeket az adatokat később is megadhatod a profilodban.', extrasDescription: 'Ezeket az adatokat később is megadhatod a profilodban.',
@@ -406,7 +533,6 @@ export default {
language: '🌐 Nyelv', language: '🌐 Nyelv',
currency: '💰 Pénznem', currency: '💰 Pénznem',
// Navigation // Navigation
back: 'Vissza',
next: 'Tovább', next: 'Tovább',
submitting: 'Feldolgozás...', submitting: 'Feldolgozás...',
submit: 'Garázs Aktiválása 🚀', submit: 'Garázs Aktiválása 🚀',
@@ -444,7 +570,6 @@ export default {
// Dashboard cards // Dashboard cards
fleetTitle: 'Flotta áttekintés', fleetTitle: 'Flotta áttekintés',
fleetVehicles: 'Kezelt járművek: {count}', fleetVehicles: 'Kezelt járművek: {count}',
fleetActive: 'Aktív',
fleetInactive: 'Inaktív', fleetInactive: 'Inaktív',
gamificationTitle: 'Gamifikáció', gamificationTitle: 'Gamifikáció',
gamificationScore: 'Havi Pontszám: {score}', gamificationScore: 'Havi Pontszám: {score}',
@@ -486,18 +611,8 @@ export default {
// Step 2 // Step 2
addressTitle: '📍 Székhely és pénzügy', addressTitle: '📍 Székhely és pénzügy',
addressDescription: 'A cég székhelyének címe és az alapértelmezett pénzügyi beállítások.', addressDescription: 'A cég székhelyének címe és az alapértelmezett pénzügyi beállítások.',
country: 'Ország',
language: 'Nyelv', language: 'Nyelv',
currency: 'Pénznem', currency: 'Pénznem',
zipCode: 'Irányítószám',
city: 'Város',
streetName: 'Utca neve',
streetType: 'Típus',
houseNumber: 'Házszám',
stairwell: 'Lépcsőház',
floor: 'Emelet',
door: 'Ajtó',
parcelId: 'Helyrajzi szám',
// Step 3 // Step 3
contactTitle: '📞 Kapcsolat', contactTitle: '📞 Kapcsolat',
contactDescription: 'Az elsődleges kapcsolattartó adatai (a bejelentkezett felhasználó) nem módosíthatók.', contactDescription: 'Az elsődleges kapcsolattartó adatai (a bejelentkezett felhasználó) nem módosíthatók.',
@@ -505,8 +620,6 @@ export default {
contactEmail: 'E-mail cím', contactEmail: 'E-mail cím',
contactPhone: 'Telefonszám', contactPhone: 'Telefonszám',
// Navigation // Navigation
cancel: 'Mégsem',
back: 'Vissza',
next: 'Tovább', next: 'Tovább',
submitting: 'Létrehozás...', submitting: 'Létrehozás...',
submit: 'Cég Létrehozása 🚀', submit: 'Cég Létrehozása 🚀',
@@ -561,8 +674,6 @@ export default {
tabLabel: 'Szervizkönyv', tabLabel: 'Szervizkönyv',
noEvents: 'Még nincsenek szerviz események rögzítve.', noEvents: 'Még nincsenek szerviz események rögzítve.',
addEvent: 'Új bejegyzés', addEvent: 'Új bejegyzés',
cancel: 'Mégsem',
saving: 'Mentés...',
eventDate: 'Esemény dátuma', eventDate: 'Esemény dátuma',
odometer: 'Km óra állás (km)', odometer: 'Km óra állás (km)',
eventType: 'Esemény típusa', eventType: 'Esemény típusa',
@@ -588,13 +699,12 @@ export default {
// ── Vehicle / Jármű ───────────────────────────────────────────── // ── Vehicle / Jármű ─────────────────────────────────────────────
vehicle: { vehicle: {
add: 'Új jármű hozzáadása',
edit_title: 'Jármű szerkesztése', edit_title: 'Jármű szerkesztése',
add_title: 'Új jármű hozzáadása', add_title: 'Új jármű hozzáadása',
features_hint: 'Válaszd ki a jármű felszereltségét és extra opcióit.', features_hint: 'Válaszd ki a jármű felszereltségét és extra opcióit.',
// ── Vehicle Card ── // ── Vehicle Card ──
noPhoto: 'Nincs feltöltött fotó', noPhoto: 'Nincs feltöltött fotó',
nextService: 'Következő Szerviz',
noData: 'Nincs adat',
primaryVehicle: 'Elsődleges jármű', primaryVehicle: 'Elsődleges jármű',
setPrimary: 'Beállítás elsődlegessé', setPrimary: 'Beállítás elsődlegessé',
// Fuel types // Fuel types
@@ -648,6 +758,7 @@ export default {
placeholder_custom_class: 'Pl. Quad, Segway, Golfkocsi', placeholder_custom_class: 'Pl. Quad, Segway, Golfkocsi',
placeholder_engine_capacity: '1995', placeholder_engine_capacity: '1995',
placeholder_power_kw: '150', placeholder_power_kw: '150',
placeholder_power_le: '204',
placeholder_torque_nm: '320', placeholder_torque_nm: '320',
placeholder_type_designation: 'Pl. GTI, M Sport, AMG', placeholder_type_designation: 'Pl. GTI, M Sport, AMG',
placeholder_curb_weight: '1500', placeholder_curb_weight: '1500',
@@ -661,7 +772,6 @@ export default {
label_custom_class: 'Kérjük, adja meg a jármű típusát', label_custom_class: 'Kérjük, adja meg a jármű típusát',
label_dates_section: 'Dátumok', label_dates_section: 'Dátumok',
label_first_registration: 'Első forgalomba helyezés', label_first_registration: 'Első forgalomba helyezés',
label_next_mot: 'Következő műszaki érvényesség',
label_insurance_expiry: 'Biztosítás lejárta', label_insurance_expiry: 'Biztosítás lejárta',
label_purchase_date: 'Vásárlás dátuma', label_purchase_date: 'Vásárlás dátuma',
label_color: 'Szín', label_color: 'Szín',
@@ -722,7 +832,7 @@ export default {
label_transmission: 'Váltó', label_transmission: 'Váltó',
label_body_type: 'Karosszéria típus', label_body_type: 'Karosszéria típus',
label_engine: 'Motor', label_engine: 'Motor',
label_vin: 'Alvázszám (VIN)', vin: 'Alvázszám (VIN)',
label_mileage: 'Jelenlegi km óra állás', label_mileage: 'Jelenlegi km óra állás',
label_fuel_type: 'Üzemanyag', label_fuel_type: 'Üzemanyag',
label_trim_level: 'Felszereltség', label_trim_level: 'Felszereltség',
@@ -737,6 +847,7 @@ export default {
label_ev_section: 'Elektromos Hajtás / Akkumulátor', label_ev_section: 'Elektromos Hajtás / Akkumulátor',
label_engine_capacity: 'Hengerűrtartalom (cm³)', label_engine_capacity: 'Hengerűrtartalom (cm³)',
label_power_kw: 'Teljesítmény (kW)', label_power_kw: 'Teljesítmény (kW)',
label_power_le: 'Teljesítmény (LE)',
label_torque_nm: 'Nyomaték (Nm)', label_torque_nm: 'Nyomaték (Nm)',
label_curb_weight: 'Saját tömeg (kg)', label_curb_weight: 'Saját tömeg (kg)',
label_max_weight: 'Össztömeg (kg)', label_max_weight: 'Össztömeg (kg)',
@@ -757,6 +868,13 @@ export default {
warranty_section_title: 'Jótállás / Garancia', warranty_section_title: 'Jótállás / Garancia',
label_is_under_warranty: 'Érvényes jótállás', label_is_under_warranty: 'Érvényes jótállás',
label_warranty_expiry_date: 'Jótállás érvényessége', label_warranty_expiry_date: 'Jótállás érvényessége',
// Registration Documents
registration_documents_title: 'Forgalmi engedély és Törzskönyv',
label_registration_certificate_number: 'Forgalmi engedély száma',
label_registration_certificate_validity: 'Forgalmi engedély érvényessége',
label_vehicle_registration_document_number: 'Törzskönyv száma',
placeholder_registration_certificate_number: 'Pl. AB-123456',
placeholder_vehicle_registration_document_number: 'Pl. 1234567890',
// Tab labels // Tab labels
tab_basic: 'Alapadatok', tab_basic: 'Alapadatok',
tab_technical: 'Műszaki', tab_technical: 'Műszaki',
@@ -771,7 +889,6 @@ export default {
// Save button // Save button
save_changes: 'Módosítások mentése', save_changes: 'Módosítások mentése',
add_vehicle: 'Jármű hozzáadása', add_vehicle: 'Jármű hozzáadása',
saving: 'Mentés...',
// Validation // Validation
required_fields_hint: 'Kérjük töltse ki a kötelező mezőket', required_fields_hint: 'Kérjük töltse ki a kötelező mezőket',
// Condition options // Condition options
@@ -789,7 +906,7 @@ export default {
}, },
// Features (extras) // Features (extras)
features: { features: {
abs: 'ABS', abs: 'Blokkolásgátló (ABS)',
esp: 'ESP (Menetstabilizáló)', esp: 'ESP (Menetstabilizáló)',
traction_control: 'Kipörgésgátló (TCS)', traction_control: 'Kipörgésgátló (TCS)',
hill_assist: 'Hegymeneti tartó', hill_assist: 'Hegymeneti tartó',
@@ -829,7 +946,7 @@ export default {
head_up_display: 'Head-Up Display (HUD)', head_up_display: 'Head-Up Display (HUD)',
alloy_wheels: 'Könnyűfém felni', alloy_wheels: 'Könnyűfém felni',
roof_rails: 'Tetősín (Roof Rails)', roof_rails: 'Tetősín (Roof Rails)',
tow_bar: 'Vontatóhorog (vonóhorog)', tow_bar: 'Vontatóhorog',
tinted_windows: 'Sötétített üvegek', tinted_windows: 'Sötétített üvegek',
privacy_glass: 'Privacy Glass', privacy_glass: 'Privacy Glass',
led_headlights: 'LED fényszóró', led_headlights: 'LED fényszóró',
@@ -881,7 +998,7 @@ export default {
airbag_passenger: 'Utas légzsák', airbag_passenger: 'Utas légzsák',
airbag_side: 'Oldallégzsák', airbag_side: 'Oldallégzsák',
airbag_curtain: 'Függönylégzsák', airbag_curtain: 'Függönylégzsák',
isofix: 'Isofix', isofix: 'Isofix gyerekülés rögzítés',
tpms: 'Guminyomás monitor', tpms: 'Guminyomás monitor',
electric_seat_adjust: 'Elektromos ülésállítás', electric_seat_adjust: 'Elektromos ülésállítás',
heated_steering_wheel: 'Fűtött kormány', heated_steering_wheel: 'Fűtött kormány',
@@ -892,9 +1009,9 @@ export default {
garage_kept: 'Garázsban tartott', garage_kept: 'Garázsban tartott',
smoke_free: 'Nem dohányzó', smoke_free: 'Nem dohányzó',
pet_free: 'Háziállat mentes', pet_free: 'Háziállat mentes',
service_book: 'Szervizkönyvvel', service_book: 'Szervizkönyv',
original_mileage: 'Igazolt futásteljesítmény', original_mileage: 'Igazolt futásteljesítmény',
first_owner: 'Első tulajdonos', first_owner: 'Első tulajdonostól',
fleet_vehicle: 'Flottás jármű', fleet_vehicle: 'Flottás jármű',
imported: 'Importált', imported: 'Importált',
accident_free: 'Balesetmentes', accident_free: 'Balesetmentes',
@@ -912,8 +1029,8 @@ export default {
smoking_package: 'Dohányzó csomag', smoking_package: 'Dohányzó csomag',
pet_friendly: 'Kisállat barát', pet_friendly: 'Kisállat barát',
child_lock: 'Gyerekzár', child_lock: 'Gyerekzár',
anti_theft: 'Riasztó', alarm: 'Riasztó',
gps_tracker: 'GPS követő', gps_tracker: 'GPS nyomkövető',
service_history: 'Szerviztörténet', service_history: 'Szerviztörténet',
}, },
// ── Motorcycle-specific labels ── // ── Motorcycle-specific labels ──
@@ -949,7 +1066,7 @@ export default {
multimedia: 'Multimédia', multimedia: 'Multimédia',
other: 'Egyéb', other: 'Egyéb',
}, },
// Motorcycle features (extras) // Motorcycle features (extras) — only strictly unique items remain
motorcycleFeatures: { motorcycleFeatures: {
// Technical // Technical
dual_front_disc: 'Dupla tárcsafék elöl', dual_front_disc: 'Dupla tárcsafék elöl',
@@ -964,23 +1081,15 @@ export default {
catalyst: 'Katalizátor', catalyst: 'Katalizátor',
electric_starter: 'Önindító', electric_starter: 'Önindító',
all_wheel_drive: 'Összkerékhajtás', all_wheel_drive: 'Összkerékhajtás',
alarm: 'Riasztó',
sport_exhaust: 'Sport kipufogó', sport_exhaust: 'Sport kipufogó',
sport_air_filter: 'Sport légszűrő', sport_air_filter: 'Sport légszűrő',
cruise_control: 'Tempomat',
turbo: 'Turbó', turbo: 'Turbó',
'12v_system': '12 V rendszer', '12v_system': '12 V rendszer',
heated_grip: 'Markolat fűtés', heated_grip: 'Markolat fűtés',
abs: 'ABS (blokkolásgátló)',
seat_belt: 'Biztonsági öv', seat_belt: 'Biztonsági öv',
dtc: 'DTC', dtc: 'DTC',
fog_light: 'Ködlámpa',
airbag: 'Légzsák',
xenon_headlight: 'Xenon fényszóró',
// Frame / Body // Frame / Body
full_extra: 'Full extra',
leather_seat: 'Bőrülés', leather_seat: 'Bőrülés',
heated_seat: 'Fűthető ülés',
backrest: 'Háttámla', backrest: 'Háttámla',
center_stand: 'Középsztender', center_stand: 'Középsztender',
footrest: 'Lábtartó', footrest: 'Lábtartó',
@@ -992,7 +1101,6 @@ export default {
crash_bar: 'Bukócső / bukógomba', crash_bar: 'Bukócső / bukógomba',
hand_guards: 'Kézvédők', hand_guards: 'Kézvédők',
heated_mirror: 'Fűthető tükör', heated_mirror: 'Fűthető tükör',
tow_hitch: 'Vonóhorog',
// Luggage // Luggage
factory_cases: 'Gyári dobozok', factory_cases: 'Gyári dobozok',
rear_case: 'Hátsó doboz', rear_case: 'Hátsó doboz',
@@ -1015,15 +1123,12 @@ export default {
showroom_vehicle: 'Bemutató jármű', showroom_vehicle: 'Bemutató jármű',
orderable: 'Rendelhető', orderable: 'Rendelhető',
car_trade_in_possible: 'Autóbeszámítás lehetséges', car_trade_in_possible: 'Autóbeszámítás lehetséges',
first_owner: 'Első tulajdonostól',
garage_kept: 'Garázsban tartott',
female_owner: 'Hölgy tulajdonostól', female_owner: 'Hölgy tulajdonostól',
low_mileage: 'Keveset futott', low_mileage: 'Keveset futott',
second_owner: 'Második tulajdonostól', second_owner: 'Második tulajdonostól',
motorcycle_trade_in_possible: 'Motorbeszámítás lehetséges', motorcycle_trade_in_possible: 'Motorbeszámítás lehetséges',
track_fairing: 'Pályaidom', track_fairing: 'Pályaidom',
regularly_maintained: 'Rendszeresen karbantartott', regularly_maintained: 'Rendszeresen karbantartott',
service_book: 'Szervizkönyv',
title_certificate: 'Törzskönyv', title_certificate: 'Törzskönyv',
}, },
// ── LCV-specific labels ── // ── LCV-specific labels ──
@@ -1055,46 +1160,21 @@ export default {
exterior: 'Külső', exterior: 'Külső',
multimedia: 'Multimédia', multimedia: 'Multimédia',
}, },
// LCV features (extras) // LCV features (extras) — only strictly unique items remain
lcvFeatures: { lcvFeatures: {
// Technical // Technical
abs: 'ABS',
asr: 'ASR (Kipörgésgátló)', asr: 'ASR (Kipörgésgátló)',
gps_tracker: 'GPS nyomkövető',
immobiliser: 'Immobiliser', immobiliser: 'Immobiliser',
alarm: 'Riasztó',
cruise_control: 'Tempomat',
parking_sensors_rear: 'Hátsó parkolóérzékelők',
// Interior // Interior
curtain_airbag: 'Függönylégzsák',
rear_side_airbag: 'Hátsó oldallégzsák',
switchable_airbag: 'Kikapcsolható utaslégzsák',
side_airbag: 'Oldallégzsák',
passenger_airbag: 'Utaslégzsák',
driver_airbag: 'Vezető légzsák',
roll_bar: 'Bukókeret', roll_bar: 'Bukókeret',
cargo_tie_down: 'Rögzítő pontok a raktérben', cargo_tie_down: 'Rögzítő pontok a raktérben',
isofix: 'Isofix gyerekülés rögzítés',
full_extra: 'Full extra',
auxiliary_heating: 'Kiegészítő fűtés (Webasto)', auxiliary_heating: 'Kiegészítő fűtés (Webasto)',
leather_interior: 'Bőr belső', leather_interior: 'Bőr belső',
heated_seat: 'Fűthető ülés',
partition_wall: 'Válaszfal', partition_wall: 'Válaszfal',
seat_height_adjustment: 'Ülésmagasság állítás', seat_height_adjustment: 'Ülésmagasság állítás',
adjustable_steering_wheel: 'Állítható kormány', adjustable_steering_wheel: 'Állítható kormány',
central_locking: 'Központi zár',
trip_computer: 'Fedélzeti számítógép',
power_steering: 'Szervokormány',
// Exterior // Exterior
power_windows: 'Elektromos ablakemelő',
power_mirrors: 'Elektromos tükör állítás', power_mirrors: 'Elektromos tükör állítás',
heated_mirrors: 'Fűthető tükör',
alloy_wheels: 'Könnyűfém felni',
tinted_glass: 'Sötétített üvegek',
tow_bar: 'Vontatóhorog',
sunroof: 'Napfénytető',
fog_lights: 'Ködlámpa',
xenon_headlights: 'Xenon fényszóró',
// Multimedia // Multimedia
cd_changer: 'CD váltó', cd_changer: 'CD váltó',
cd_radio: 'CD rádió', cd_radio: 'CD rádió',
@@ -1204,7 +1284,7 @@ export default {
cabin: 'Fülke', cabin: 'Fülke',
multimedia: 'Multimédia', multimedia: 'Multimédia',
}, },
// HGV features (extras) // HGV features (extras) — only strictly unique items remain
hgvFeatures: { hgvFeatures: {
// Technical / Work // Technical / Work
diff_lock_front: 'Első differenciálzár', diff_lock_front: 'Első differenciálzár',
@@ -1217,23 +1297,16 @@ export default {
engine_brake: 'Motorfék', engine_brake: 'Motorfék',
auxiliary_brake: 'Segédfék', auxiliary_brake: 'Segédfék',
hill_holder: 'Hegymeneti tartó', hill_holder: 'Hegymeneti tartó',
hill_descent_control: 'Lejtmenetvezérlő',
traction_control: 'Kipörgésgátló',
stability_control: 'Stabilitás szabályzó (ESP)', stability_control: 'Stabilitás szabályzó (ESP)',
roll_stability: 'Borulásgátló (RSP)', roll_stability: 'Borulásgátló (RSP)',
lane_departure: 'Sávelhagyás figyelő', lane_departure: 'Sávelhagyás figyelő',
adaptive_cruise: 'Adaptív tempomat (ACC)',
collision_warning: 'Ütközés figyelmeztető', collision_warning: 'Ütközés figyelmeztető',
emergency_brake_assist: 'Vészfék asszisztens', emergency_brake_assist: 'Vészfék asszisztens',
blind_spot_monitor: 'Holtpont figyelő', blind_spot_monitor: 'Holtpont figyelő',
traffic_sign_recognition: 'Táblafelismerő',
driver_alert: 'Fáradtságfigyelő',
tyre_pressure_monitor: 'Guminyomás monitor (TPMS)', tyre_pressure_monitor: 'Guminyomás monitor (TPMS)',
reverse_camera: 'Tolatókamera', reverse_camera: 'Tolatókamera',
'360_camera': '360°-os kamera',
side_camera: 'Oldalsó kamera', side_camera: 'Oldalsó kamera',
front_camera: 'Első kamera', front_camera: 'Első kamera',
rear_camera: 'Hátsó kamera',
parking_sensors: 'Parkolóérzékelők', parking_sensors: 'Parkolóérzékelők',
front_parking_sensors: 'Első parkolóérzékelők', front_parking_sensors: 'Első parkolóérzékelők',
rear_parking_sensors: 'Hátsó parkolóérzékelők', rear_parking_sensors: 'Hátsó parkolóérzékelők',
@@ -1241,7 +1314,6 @@ export default {
tachograph: 'Menetíró (Tachográf)', tachograph: 'Menetíró (Tachográf)',
digital_tachograph: 'Digitális menetíró', digital_tachograph: 'Digitális menetíró',
smart_tachograph: 'Smart Tachográf (G2V2)', smart_tachograph: 'Smart Tachográf (G2V2)',
gps_tracker: 'GPS nyomkövető',
fleet_management: 'Flottamenedzsment rendszer', fleet_management: 'Flottamenedzsment rendszer',
fuel_monitoring: 'Üzemanyag monitorozás', fuel_monitoring: 'Üzemanyag monitorozás',
eco_driving: 'Eco Driving asszisztens', eco_driving: 'Eco Driving asszisztens',
@@ -1278,24 +1350,28 @@ export default {
armrest: 'Karfás ülés', armrest: 'Karfás ülés',
storage_box: 'Tároló rekesz', storage_box: 'Tároló rekesz',
wardrobe: 'Gardrób', wardrobe: 'Gardrób',
central_locking: 'Központi zár',
adjustable_steering: 'Állítható kormány',
// Multimedia // Multimedia
navigation: 'Navigáció (GPS)',
apple_carplay: 'Apple CarPlay',
android_auto: 'Android Auto',
bluetooth: 'Bluetooth',
usb_charging: 'USB töltő',
wireless_charging: 'Vezeték nélküli töltő',
dab_radio: 'DAB+ digitális rádió',
cb_radio: 'CB Rádió', cb_radio: 'CB Rádió',
satellite_radio: 'Műholdas rádió', satellite_radio: 'Műholdas rádió',
digital_tv: 'Digitális TV', digital_tv: 'Digitális TV',
dvb_t: 'DVB-T TV', dvb_t: 'DVB-T TV',
dvb_s: 'DVB-S műholdas TV', dvb_s: 'DVB-S műholdas TV',
premium_sound: 'Prémium hangrendszer',
subwoofer: 'Mélynyomó',
rear_entertainment: 'Hátsó szórakoztató rendszer',
wifi_hotspot: 'WiFi Hotspot',
}, },
// ── Admin Fields (VehicleFormModal) ────────────────────────────────
admin_section_title: 'Adminisztratív adatok',
label_registration_country: 'Regisztráció országa',
placeholder_registration_country: 'Pl. Magyarország',
label_first_domestic_registration_date: 'Első belföldi forgalomba helyezés',
label_import_country: 'Import ország',
placeholder_import_country: 'Pl. Németország',
label_title_document_number: 'Törzskönyv / Tulajdoni lap száma',
placeholder_title_document_number: 'Pl. TL-123456',
label_engine_number: 'Motorszám',
placeholder_engine_number: 'Pl. M123456789',
label_number_of_previous_owners: 'Előző tulajdonosok száma',
placeholder_number_of_previous_owners: 'Pl. 2',
}, },
// ── Service Finder ──────────────────────────────────────────────── // ── Service Finder ────────────────────────────────────────────────
@@ -1408,7 +1484,6 @@ export default {
tabServices: 'Szolgáltatások', tabServices: 'Szolgáltatások',
addressSection: 'Cím adatok', addressSection: 'Cím adatok',
contactSection: 'Elérhetőségek', contactSection: 'Elérhetőségek',
streetNameLabel: 'Utca neve',
streetNameOptional: 'opcionális', streetNameOptional: 'opcionális',
streetNamePlaceholder: 'Pl. Egressy', streetNamePlaceholder: 'Pl. Egressy',
streetTypeLabel: 'Közterület jellege', streetTypeLabel: 'Közterület jellege',
@@ -1488,7 +1563,6 @@ export default {
field_description_placeholder: 'Írd le, mit nyújt ez a szolgáltatás...', field_description_placeholder: 'Írd le, mit nyújt ez a szolgáltatás...',
field_credit_cost: 'Kredit Ár', field_credit_cost: 'Kredit Ár',
cancel: 'Mégsem', cancel: 'Mégsem',
saving: 'Mentés...',
save_changes: 'Változások Mentése', save_changes: 'Változások Mentése',
create: 'Szolgáltatás Létrehozása', create: 'Szolgáltatás Létrehozása',
}, },
@@ -1536,7 +1610,6 @@ export default {
field_entitlements: 'Szolgáltatások (Jogosultságok)', field_entitlements: 'Szolgáltatások (Jogosultságok)',
entitlement_placeholder: 'Válassz egy szolgáltatást...', entitlement_placeholder: 'Válassz egy szolgáltatást...',
cancel: 'Mégsem', cancel: 'Mégsem',
saving: 'Mentés...',
save_changes: 'Változások Mentése', save_changes: 'Változások Mentése',
create: 'Csomag Létrehozása', create: 'Csomag Létrehozása',
}, },
@@ -1553,7 +1626,6 @@ export default {
clear_search: 'Keresés törlése', clear_search: 'Keresés törlése',
filter_all_roles: 'Összes szerepkör', filter_all_roles: 'Összes szerepkör',
filter_all_status: 'Összes állapot', filter_all_status: 'Összes állapot',
status_active: 'Aktív',
status_inactive: 'Inaktív', status_inactive: 'Inaktív',
status_deleted: 'Törölt', status_deleted: 'Törölt',
refresh: 'Frissítés', refresh: 'Frissítés',

View File

@@ -147,10 +147,13 @@ const isHamburgerOpen = ref(false)
function navigateToCard(cardId: string) { function navigateToCard(cardId: string) {
isHamburgerOpen.value = false isHamburgerOpen.value = false
// 'vehicles' navigates to the full FleetView page // 'vehicles' navigates to the full FleetView page
// 'costs' and 'service' navigate to dashboard where their cards are already visible inline // 'costs' navigates to the full CostsView page
// 'service' navigates to dashboard where its card is already visible inline
if (cardId === 'vehicles') { if (cardId === 'vehicles') {
router.push('/dashboard/vehicles') router.push('/dashboard/vehicles')
} else if (cardId === 'costs' || cardId === 'service') { } else if (cardId === 'costs') {
router.push('/dashboard/costs')
} else if (cardId === 'service') {
router.push('/dashboard') router.push('/dashboard')
} else { } else {
router.push({ path: '/dashboard', query: { card: cardId } }) router.push({ path: '/dashboard', query: { card: cardId } })

View File

@@ -34,6 +34,11 @@ const router = createRouter({
name: 'FleetView', name: 'FleetView',
component: () => import('../views/vehicles/FleetView.vue') component: () => import('../views/vehicles/FleetView.vue')
}, },
{
path: 'costs',
name: 'CostsView',
component: () => import('../views/costs/CostsView.vue')
},
{ {
path: 'vehicles/:id', path: 'vehicles/:id',
name: 'VehicleDetails', name: 'VehicleDetails',
@@ -42,7 +47,7 @@ const router = createRouter({
] ]
}, },
{ {
path: '/organization/:id', path: '/organization/:org_id',
component: () => import('../layouts/OrganizationLayout.vue'), component: () => import('../layouts/OrganizationLayout.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
children: [ children: [
@@ -62,7 +67,7 @@ const router = createRouter({
component: () => import('../views/vehicles/FleetView.vue') component: () => import('../views/vehicles/FleetView.vue')
}, },
{ {
path: 'vehicles/:id', path: 'vehicles/:vehicle_id',
name: 'OrgVehicleDetails', name: 'OrgVehicleDetails',
component: () => import('../views/vehicles/VehicleDetailsView.vue') component: () => import('../views/vehicles/VehicleDetailsView.vue')
} }

View File

@@ -60,6 +60,10 @@ export interface UserProfile {
role: string role: string
region_code: string region_code: string
subscription_plan: string subscription_plan: string
subscription_expires_at?: string | null
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
max_vehicles?: number
max_garages?: number
scope_level: string scope_level: string
scope_id: string | null scope_id: string | null
ui_mode: string ui_mode: string

View File

@@ -0,0 +1,149 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../api/axios'
/**
* Expense item shape returned by GET /expenses/{asset_id}
*/
export interface ExpenseItem {
id: string
asset_id: string
organization_id: number
category_id: number
category_code: string | null
category_name: string | null
amount_gross: string | null
amount_net: string | null
vat_rate: string | null
currency: string
date: string | null
status: string
description: string | null
mileage_at_cost: number | null
invoice_number: string | null
linked_asset_event_id: string | null
}
/**
* Paginated response from GET /expenses/{asset_id}
*/
interface ExpenseListResponse {
data: ExpenseItem[]
total: number
limit: number
offset: number
}
export const useExpenseStore = defineStore('expense', () => {
// ── State ──────────────────────────────────────────────────────────
/** Map of asset_id → ExpenseItem[] for quick lookup */
const expensesByAsset = ref<Record<string, ExpenseItem[]>>({})
const isLoading = ref(false)
const error = ref<string | null>(null)
// ── Getters ────────────────────────────────────────────────────────
/**
* Get expenses for a specific asset, sorted by date descending.
*/
function getExpensesForAsset(assetId: string): ExpenseItem[] {
return expensesByAsset.value[assetId] || []
}
/**
* Get the latest N expenses for a specific asset.
*/
function getRecentExpenses(assetId: string, count: number = 3): ExpenseItem[] {
const expenses = getExpensesForAsset(assetId)
return expenses.slice(0, count)
}
/**
* Calculate the total gross amount for the current month for a specific asset.
*/
function getMonthlyCost(assetId: string): number {
const expenses = getExpensesForAsset(assetId)
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth()
return expenses
.filter(e => {
if (!e.date) return false
const d = new Date(e.date)
return d.getFullYear() === currentYear && d.getMonth() === currentMonth
})
.reduce((sum, e) => {
const gross = e.amount_gross ? parseFloat(e.amount_gross) : 0
return sum + gross
}, 0)
}
// ── Actions ────────────────────────────────────────────────────────
/**
* Fetch expenses for a specific asset from the backend.
* GET /expenses/{asset_id}
*/
async function fetchExpensesByAsset(assetId: string, limit: number = 50): Promise<ExpenseItem[]> {
// If we already have data, return cached
if (expensesByAsset.value[assetId]?.length) {
return expensesByAsset.value[assetId]
}
isLoading.value = true
error.value = null
try {
const res = await api.get<ExpenseListResponse>(`/expenses/${assetId}`, {
params: { limit },
})
const data = res.data
expensesByAsset.value[assetId] = data.data || []
return expensesByAsset.value[assetId]
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Nem sikerült betölteni a költségeket.'
error.value = message
console.error('[ExpenseStore] fetchExpensesByAsset error:', err)
return []
} finally {
isLoading.value = false
}
}
/**
* Force-refresh expenses for a specific asset (bypass cache).
*/
async function refreshExpensesByAsset(assetId: string, limit: number = 50): Promise<ExpenseItem[]> {
// Clear cache first
delete expensesByAsset.value[assetId]
return fetchExpensesByAsset(assetId, limit)
}
/**
* Clear all cached expense data.
*/
function clearCache() {
expensesByAsset.value = {}
}
return {
// State
expensesByAsset,
isLoading,
error,
// Getters
getExpensesForAsset,
getRecentExpenses,
getMonthlyCost,
// Actions
fetchExpensesByAsset,
refreshExpensesByAsset,
clearCache,
}
})

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import api from '../api/axios' import api from '../api/axios'
import type { AssetFinancials, VehicleInsurancePolicy, VehicleTaxObligation } from '../types/vehicle'
/** /**
* Vehicle data shape returned by GET /assets/vehicles * Vehicle data shape returned by GET /assets/vehicles
@@ -23,8 +24,15 @@ export interface Vehicle {
fuel_type: string | null fuel_type: string | null
engine_capacity: number | null engine_capacity: number | null
power_kw: number | null power_kw: number | null
torque_nm: number | null
cylinder_layout: string | null
transmission_type: string | null transmission_type: string | null
drive_type: string | null drive_type: string | null
euro_classification: string | null
curb_weight: number | null
max_weight: number | null
door_count: number | null
seat_count: number | null
// Status // Status
current_mileage: number current_mileage: number
@@ -41,6 +49,19 @@ export interface Vehicle {
is_under_warranty: boolean is_under_warranty: boolean
warranty_expiry_date: string | null warranty_expiry_date: string | null
// Registration Documents
registration_certificate_number?: string | null
registration_certificate_validity?: string | null
vehicle_registration_document_number?: string | null
// ── New Admin Fields ────────────────────────────────────────────────
registration_country?: string | null
first_domestic_registration_date?: string | null
import_country?: string | null
title_document_number?: string | null
engine_number?: string | null
number_of_previous_owners?: number | null
// Sales // Sales
is_for_sale: boolean is_for_sale: boolean
price: number | null price: number | null
@@ -53,6 +74,11 @@ export interface Vehicle {
current_organization_id: number | null current_organization_id: number | null
owner_organization_id: number | null owner_organization_id: number | null
// ── Finance / Leasing ───────────────────────────────────────────────
asset_financials?: AssetFinancials | null
insurance_policies?: VehicleInsurancePolicy[] | null
tax_obligations?: VehicleTaxObligation[] | null
// JSONB individual_equipment from backend (car_specs, ev_specs, dates, etc.) // JSONB individual_equipment from backend (car_specs, ev_specs, dates, etc.)
individual_equipment?: { individual_equipment?: {
car_specs?: { car_specs?: {
@@ -283,6 +309,120 @@ export const useVehicleStore = defineStore('vehicle', () => {
} }
} }
// ── Finance Query Methods ──────────────────────────────────────────
/**
* Fetch asset financials for a vehicle.
* GET /assets/vehicles/{id}/financials
*/
async function fetchAssetFinancials(assetId: string): Promise<AssetFinancials | null> {
try {
const res = await api.get(`/assets/vehicles/${assetId}/financials`)
return res.data as AssetFinancials
} catch (err: any) {
console.error('[VehicleStore] fetchAssetFinancials error:', err)
return null
}
}
/**
* Save asset financials for a vehicle (create or update).
* PUT /assets/vehicles/{id}/financials
*/
async function saveAssetFinancials(assetId: string, data: Partial<AssetFinancials>): Promise<AssetFinancials | null> {
try {
const res = await api.put(`/assets/vehicles/${assetId}/financials`, data)
return res.data as AssetFinancials
} catch (err: any) {
console.error('[VehicleStore] saveAssetFinancials error:', err)
throw err
}
}
/**
* Fetch insurance policies for a vehicle.
* GET /assets/vehicles/{id}/insurance
*/
async function fetchInsurancePolicies(assetId: string): Promise<VehicleInsurancePolicy[]> {
try {
const res = await api.get(`/assets/vehicles/${assetId}/insurance`)
return res.data as VehicleInsurancePolicy[]
} catch (err: any) {
console.error('[VehicleStore] fetchInsurancePolicies error:', err)
return []
}
}
/**
* Create a new insurance policy for a vehicle.
* POST /assets/vehicles/{id}/insurance
*/
async function createInsurancePolicy(assetId: string, data: Partial<VehicleInsurancePolicy>): Promise<VehicleInsurancePolicy | null> {
try {
const res = await api.post(`/assets/vehicles/${assetId}/insurance`, data)
return res.data as VehicleInsurancePolicy
} catch (err: any) {
console.error('[VehicleStore] createInsurancePolicy error:', err)
throw err
}
}
/**
* Delete an insurance policy.
* DELETE /assets/vehicles/{assetId}/insurance/{policyId}
*/
async function deleteInsurancePolicy(assetId: string, policyId: number): Promise<boolean> {
try {
await api.delete(`/assets/vehicles/${assetId}/insurance/${policyId}`)
return true
} catch (err: any) {
console.error('[VehicleStore] deleteInsurancePolicy error:', err)
return false
}
}
/**
* Fetch tax obligations for a vehicle.
* GET /assets/vehicles/{id}/taxes
*/
async function fetchTaxObligations(assetId: string): Promise<VehicleTaxObligation[]> {
try {
const res = await api.get(`/assets/vehicles/${assetId}/taxes`)
return res.data as VehicleTaxObligation[]
} catch (err: any) {
console.error('[VehicleStore] fetchTaxObligations error:', err)
return []
}
}
/**
* Create a new tax obligation for a vehicle.
* POST /assets/vehicles/{id}/taxes
*/
async function createTaxObligation(assetId: string, data: Partial<VehicleTaxObligation>): Promise<VehicleTaxObligation | null> {
try {
const res = await api.post(`/assets/vehicles/${assetId}/taxes`, data)
return res.data as VehicleTaxObligation
} catch (err: any) {
console.error('[VehicleStore] createTaxObligation error:', err)
throw err
}
}
/**
* Delete a tax obligation.
* DELETE /assets/vehicles/{assetId}/taxes/{taxId}
*/
async function deleteTaxObligation(assetId: string, taxId: number): Promise<boolean> {
try {
await api.delete(`/assets/vehicles/${assetId}/taxes/${taxId}`)
return true
} catch (err: any) {
console.error('[VehicleStore] deleteTaxObligation error:', err)
return false
}
}
return { return {
// State // State
vehicles, vehicles,
@@ -299,5 +439,15 @@ export const useVehicleStore = defineStore('vehicle', () => {
updateVehicle, updateVehicle,
setPrimaryVehicle, setPrimaryVehicle,
archiveVehicle, archiveVehicle,
// Finance Actions
fetchAssetFinancials,
saveAssetFinancials,
fetchInsurancePolicies,
createInsurancePolicy,
deleteInsurancePolicy,
fetchTaxObligations,
createTaxObligation,
deleteTaxObligation,
} }
}) })

View File

@@ -13,8 +13,13 @@ export interface OrganizationItem {
is_active: boolean is_active: boolean
is_deleted: boolean is_deleted: boolean
subscription_plan: string subscription_plan: string
/** ISO datetime string of when the subscription expires */
subscription_expires_at?: string | null
/** P0 Feature: FK to system.subscription_tiers — the assigned subscription package */ /** P0 Feature: FK to system.subscription_tiers — the assigned subscription package */
subscription_tier_id?: number | null subscription_tier_id?: number | null
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
max_vehicles?: number
max_garages?: number
/** org_type is not in the current /my response but we keep it for future use */ /** org_type is not in the current /my response but we keep it for future use */
org_type?: string org_type?: string
/** /**

View File

@@ -23,6 +23,7 @@ export interface VehicleData {
color: string color: string
nextService?: string nextService?: string
image: string | null image: string | null
image_url?: string | null
nickname?: string nickname?: string
countryCode?: string countryCode?: string
@@ -45,6 +46,19 @@ export interface VehicleData {
price?: number | null price?: number | null
currency?: string currency?: string
// ── New Admin Fields (Asset extended attributes) ──────────────────────
registration_country?: string | null
first_domestic_registration_date?: string | null
import_country?: string | null
title_document_number?: string | null
engine_number?: string | null
number_of_previous_owners?: number | null
// ── Finance / Leasing ────────────────────────────────────────────────
asset_financials?: AssetFinancials | null
insurance_policies?: VehicleInsurancePolicy[] | null
tax_obligations?: VehicleTaxObligation[] | null
// JSONB individual_equipment from backend (color, notes, engine_code, etc.) // JSONB individual_equipment from backend (color, notes, engine_code, etc.)
individual_equipment?: { individual_equipment?: {
color?: string color?: string
@@ -53,3 +67,56 @@ export interface VehicleData {
[key: string]: any [key: string]: any
} }
} }
/**
* AssetFinancials — leasing / purchasing financial data for a vehicle asset.
* Maps to the fleet_finance.asset_financials table.
*/
export interface AssetFinancials {
id?: number
asset_id?: string
purchase_price_net?: number | null
purchase_price_gross?: number | null
currency?: string
down_payment?: number | null
monthly_installment?: number | null
contract_number?: string | null
financing_provider?: string | null
contract_start_date?: string | null
contract_end_date?: string | null
residual_value?: number | null
interest_rate?: number | null
[key: string]: any
}
/**
* VehicleInsurancePolicy — insurance policies linked to a vehicle asset.
* Maps to the fleet_finance.vehicle_insurance_policies table.
*/
export interface VehicleInsurancePolicy {
id?: number
asset_id?: string
insurer_name?: string | null
policy_number?: string | null
policy_type?: string | null // 'kgfb' | 'casco' | 'both'
premium?: number | null
currency?: string
valid_from?: string | null
valid_until?: string | null
[key: string]: any
}
/**
* VehicleTaxObligation — tax obligations linked to a vehicle asset.
* Maps to the fleet_finance.vehicle_tax_obligations table.
*/
export interface VehicleTaxObligation {
id?: number
asset_id?: string
tax_type?: string | null
amount?: number | null
currency?: string
due_date?: string | null
paid?: boolean
[key: string]: any
}

View File

@@ -65,7 +65,7 @@
<div class="space-y-5"> <div class="space-y-5">
<!-- Country selector (EU-ready) --> <!-- Country selector (EU-ready) -->
<div> <div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.country') }}</label> <label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.country') }}</label>
<select <select
v-model="kycForm.region_code" v-model="kycForm.region_code"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all" class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
@@ -82,7 +82,7 @@
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div class="col-span-1"> <div class="col-span-1">
<label class="block text-sm font-medium text-white/80 mb-2"> <label class="block text-sm font-medium text-white/80 mb-2">
{{ t('kyc.zipCode') }} <span class="text-red-400">*</span> {{ t('common.zipCode') }} <span class="text-red-400">*</span>
</label> </label>
<input <input
v-model="kycForm.address_zip" v-model="kycForm.address_zip"
@@ -93,7 +93,7 @@
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-sm font-medium text-white/80 mb-2"> <label class="block text-sm font-medium text-white/80 mb-2">
{{ t('kyc.city') }} <span class="text-red-400">*</span> {{ t('common.city') }} <span class="text-red-400">*</span>
</label> </label>
<div class="relative"> <div class="relative">
<input <input
@@ -144,7 +144,7 @@
<div v-if="showOptionalAddress" class="mt-4 space-y-4"> <div v-if="showOptionalAddress" class="mt-4 space-y-4">
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.streetName') }}</label> <label class="block text-xs text-white/60 mb-1">{{ t('common.streetName') }}</label>
<input <input
v-model="kycForm.address_street_name" v-model="kycForm.address_street_name"
type="text" type="text"
@@ -170,7 +170,7 @@
</div> </div>
<div> <div>
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.houseNumber') }}</label> <label class="block text-xs text-white/60 mb-1">{{ t('common.houseNumber') }}</label>
<input <input
v-model="kycForm.address_house_number" v-model="kycForm.address_house_number"
type="text" type="text"
@@ -319,7 +319,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M15 19l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
{{ t('kyc.back') }} {{ t('common.back') }}
</button> </button>
<div v-else /> <div v-else />
@@ -474,7 +474,7 @@ function validateStep(): boolean {
return false return false
} }
if (!kycForm.address_city) { if (!kycForm.address_city) {
errorMessage.value = t('kyc.cityRequired') errorMessage.value = t('common.cityRequired')
return false return false
} }
} }

View File

@@ -81,9 +81,15 @@
<SubscriptionStatusWidget /> <SubscriptionStatusWidget />
</div> </div>
<!-- Ad Placement Widget --> <!-- Ad Placement Widget (only visible when ads exist) -->
<div class="rounded-xl border border-white/10 bg-white/5 backdrop-blur-sm p-3"> <div
<AdPlacementWidget zone="dashboard_widget" /> v-if="adWidgetHasAds"
class="rounded-xl border border-white/10 bg-white/5 backdrop-blur-sm p-3"
>
<AdPlacementWidget
zone="dashboard_widget"
@ads-loaded="(hasAds: boolean) => adWidgetHasAds = hasAds"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -203,6 +209,9 @@ const vehicleStore = useVehicleStore()
// ── Zen Mode ── // ── Zen Mode ──
const isUiVisible = ref(true) const isUiVisible = ref(true)
// ── Ad Widget State ──
const adWidgetHasAds = ref(false)
// ── Flip Modal State ── // ── Flip Modal State ──
const activeCard = ref<string | null>(null) const activeCard = ref<string | null>(null)
const selectedTargetId = ref<string | null>(null) const selectedTargetId = ref<string | null>(null)

View File

@@ -12,7 +12,7 @@
</template> </template>
<template #center> <template #center>
<span class="text-sm font-semibold text-white/70 tracking-wide"> <span class="text-sm font-semibold text-white/70 tracking-wide">
{{ t('profile.title') }} {{ t('common.profileSettings') }}
</span> </span>
</template> </template>
<template #right> <template #right>
@@ -139,7 +139,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
{{ t('profile.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
@click="savePerson" @click="savePerson"
@@ -153,7 +153,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
{{ t('profile.save') }} {{ t('common.save') }}
</button> </button>
</template> </template>
@@ -307,19 +307,19 @@
<div v-if="isEditing" class="space-y-3"> <div v-if="isEditing" class="space-y-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.zipCode') }}</label> <label class="mb-1 block text-xs text-white/50">{{ t('common.zipCode') }}</label>
<input <input
v-model="editForm.address_zip" v-model="editForm.address_zip"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none" class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.zipCode')" :placeholder="t('common.zipCode')"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.city') }}</label> <label class="mb-1 block text-xs text-white/50">{{ t('common.city') }}</label>
<input <input
v-model="editForm.address_city" v-model="editForm.address_city"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none" class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.city')" :placeholder="t('common.city')"
/> />
</div> </div>
</div> </div>
@@ -333,7 +333,7 @@
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.streetType') }}</label> <label class="mb-1 block text-xs text-white/50">{{ t('common.streetType') }}</label>
<select <select
v-model="editForm.address_street_type" v-model="editForm.address_street_type"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none appearance-none cursor-pointer" class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none appearance-none cursor-pointer"
@@ -348,41 +348,41 @@
</select> </select>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.houseNumber') }}</label> <label class="mb-1 block text-xs text-white/50">{{ t('common.houseNumber') }}</label>
<input <input
v-model="editForm.address_house_number" v-model="editForm.address_house_number"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none" class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.houseNumber')" :placeholder="t('common.houseNumber')"
/> />
</div> </div>
</div> </div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-4"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div> <div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.stairwell') }}</label> <label class="mb-1 block text-xs text-white/50">{{ t('common.stairwell') }}</label>
<input <input
v-model="editForm.address_stairwell" v-model="editForm.address_stairwell"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none" class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.stairwell')" :placeholder="t('common.stairwell')"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.floor') }}</label> <label class="mb-1 block text-xs text-white/50">{{ t('common.floor') }}</label>
<input <input
v-model="editForm.address_floor" v-model="editForm.address_floor"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none" class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.floor')" :placeholder="t('common.floor')"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.door') }}</label> <label class="mb-1 block text-xs text-white/50">{{ t('common.door') }}</label>
<input <input
v-model="editForm.address_door" v-model="editForm.address_door"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none" class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
:placeholder="t('profile.door')" :placeholder="t('common.door')"
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-xs text-white/50">{{ t('profile.parcelId') }}</label> <label class="mb-1 block text-xs text-white/50">{{ t('common.parcelId') }}</label>
<input <input
v-model="editForm.address_hrsz" v-model="editForm.address_hrsz"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none" class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white font-medium placeholder-white/30 backdrop-blur-sm focus:border-[#00E5A0]/50 focus:outline-none"
@@ -652,7 +652,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
{{ t('profile.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
@click="savePerson" @click="savePerson"
@@ -666,7 +666,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
{{ t('profile.save') }} {{ t('common.save') }}
</button> </button>
</template> </template>
</div> </div>
@@ -691,7 +691,7 @@
<line x1="19" y1="12" x2="5" y2="12" /> <line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" /> <polyline points="12 19 5 12 12 5" />
</svg> </svg>
{{ t('profile.backToGarage') }} {{ t('common.backToGarage') }}
</button> </button>
</div> </div>
</template> </template>
@@ -781,7 +781,7 @@
@click="closePasswordModal" @click="closePasswordModal"
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" 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"
> >
{{ t('profile.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
@click="submitPasswordChange" @click="submitPasswordChange"
@@ -792,7 +792,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> <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" /> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg> </svg>
{{ t('profile.save') }} {{ t('common.save') }}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -190,14 +190,14 @@
class="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors" 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" @click="closeModal"
> >
{{ $t('admin.services.cancel') }} {{ $t('common.cancel') }}
</button> </button>
<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" 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" :disabled="store.isLoading"
@click="saveService" @click="saveService"
> >
<span v-if="store.isLoading">{{ $t('admin.services.saving') }}</span> <span v-if="store.isLoading">{{ $t('common.saving') }}</span>
<span v-else>{{ editingService ? $t('admin.services.save_changes') : $t('admin.services.create') }}</span> <span v-else>{{ editingService ? $t('admin.services.save_changes') : $t('admin.services.create') }}</span>
</button> </button>
</div> </div>

View File

@@ -73,7 +73,7 @@
@change="applyFilters" @change="applyFilters"
> >
<option value="">{{ $t('admin.users.filter_all_status') }}</option> <option value="">{{ $t('admin.users.filter_all_status') }}</option>
<option value="active">{{ $t('admin.users.status_active') }}</option> <option value="active">{{ $t('common.statusActive') }}</option>
<option value="inactive">{{ $t('admin.users.status_inactive') }}</option> <option value="inactive">{{ $t('admin.users.status_inactive') }}</option>
<option value="deleted">{{ $t('admin.users.status_deleted') }}</option> <option value="deleted">{{ $t('admin.users.status_deleted') }}</option>
</select> </select>

View File

@@ -0,0 +1,566 @@
<template>
<div class="relative min-h-screen">
<!-- Garage Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Dark overlay for readability -->
<div class="fixed inset-0 z-0 bg-slate-900/65 pointer-events-none"></div>
<!-- Content -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Page header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">{{ t('finance.costManager') }}</h1>
<p class="mt-1 text-sm text-white/60">
{{ t('finance.costManagerDescription') }}
</p>
</div>
<div class="flex items-center gap-3">
<!-- Back to Dashboard button -->
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('costs.backToDashboard') }}
</button>
</div>
</div>
<!-- Cost Manager Content -->
<div class="rounded-2xl bg-white/95 shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden">
<!--
GLOBAL CONTROL BAR
-->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 px-6 py-4 border-b border-slate-100">
<!-- Left: Filters -->
<div class="flex flex-wrap items-center gap-3">
<!-- Vehicle Filter -->
<div class="flex items-center gap-2">
<label class="text-sm font-semibold text-slate-600 whitespace-nowrap">{{ t('finance.filterByVehicle') }}:</label>
<select
v-model="selectedVehicleFilter"
class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all min-w-[180px] appearance-none cursor-pointer"
@change="onFilterChange"
>
<option value="">{{ t('finance.allVehicles') }}</option>
<option
v-for="v in vehicleStore.sortedVehicles"
:key="v.id"
:value="v.id"
>
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
</option>
</select>
</div>
<!-- Period Filter -->
<div class="flex items-center gap-2">
<label class="text-sm font-semibold text-slate-600 whitespace-nowrap">{{ t('finance.periodFilter') }}:</label>
<select
v-model="selectedPeriodFilter"
class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all min-w-[140px] appearance-none cursor-pointer"
@change="onFilterChange"
>
<option value="thisYear">{{ t('finance.periodThisYear') }}</option>
<option value="last30">{{ t('finance.periodLast30Days') }}</option>
<option value="all">{{ t('finance.periodAll') }}</option>
</select>
</div>
</div>
<!-- Right: Add Cost Button -->
<button
@click="openCostWizard"
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg"
>
<span class="text-base"></span>
{{ t('finance.addCost') }}
</button>
</div>
<!--
TABBED LAYOUT
-->
<div class="px-6 pt-4">
<!-- Tab Headers -->
<div class="flex gap-1 border-b border-slate-200">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex items-center gap-2 px-5 py-3 text-sm font-semibold rounded-t-xl transition-all duration-200"
:class="activeTab === tab.key
? 'bg-white text-sf-accent border-b-2 border-sf-accent shadow-sm -mb-px'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-50'"
>
<span>{{ tab.icon }}</span>
<span>{{ tab.label }}</span>
</button>
</div>
</div>
<!-- Tab Content -->
<div class="p-6">
<!-- Loading State (shared) -->
<div
v-if="isLoading"
class="flex items-center justify-center py-20"
>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div>
<!-- Error State (shared) -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-12 text-center"
>
<svg class="w-12 h-12 text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-sm text-slate-500">{{ error }}</p>
<button
@click="fetchCosts"
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
>
{{ t('common.retry') }}
</button>
</div>
<!--
TAB 1: ANALYTICS (Áttekintés)
-->
<div v-else-if="activeTab === 'analytics'">
<!-- KPI Cards Row -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Total Expenses -->
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('finance.totalExpenses') }}</p>
<p class="mt-2 text-2xl font-bold text-slate-800">{{ formatAmount(totalSum, 'Ft') }}</p>
</div>
<!-- Total (Filtered) -->
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('finance.totalFiltered') }}</p>
<p class="mt-2 text-2xl font-bold text-slate-800">{{ formatAmount(filteredSum, 'Ft') }}</p>
</div>
<!-- Avg per Vehicle -->
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('finance.avgPerVehicle') }}</p>
<p class="mt-2 text-2xl font-bold text-slate-800">{{ formatAmount(avgPerVehicle, 'Ft') }}</p>
</div>
<!-- Cost Count -->
<div class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('finance.costType') }}</p>
<p class="mt-2 text-2xl font-bold text-slate-800">{{ costs.length }} {{ t('common.items') || 'db' }}</p>
</div>
</div>
<!-- Chart Placeholders Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Monthly Summary Chart Placeholder -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-6 flex flex-col items-center justify-center min-h-[200px]">
<svg class="w-10 h-10 text-slate-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-sm font-medium text-slate-500">{{ t('finance.monthlyChartPlaceholder') }}</p>
</div>
<!-- Category Pie Chart Placeholder -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-6 flex flex-col items-center justify-center min-h-[200px]">
<svg class="w-10 h-10 text-slate-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
</svg>
<p class="text-sm font-medium text-slate-500">{{ t('finance.categoryChartPlaceholder') }}</p>
</div>
</div>
</div>
<!--
TAB 2: TRANSACTIONS (Tranzakciók)
-->
<div v-else-if="activeTab === 'transactions'">
<!-- Table Header -->
<div class="hidden md:grid grid-cols-12 gap-3 px-4 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider bg-slate-50 rounded-xl mb-2">
<div class="col-span-2">{{ t('finance.costDate') }}</div>
<div class="col-span-2">{{ t('finance.costType') }}</div>
<div class="col-span-2">{{ t('finance.costAmount') }}</div>
<div class="col-span-3">{{ t('finance.costVehicle') }}</div>
<div class="col-span-2">{{ t('finance.costDescription') }}</div>
<div class="col-span-1 text-center">{{ t('common.actions') }}</div>
</div>
<!-- Empty State -->
<div
v-if="costs.length === 0"
class="flex flex-col items-center justify-center py-16 text-slate-400"
>
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-sm">{{ t('finance.noCosts') }}</p>
</div>
<!-- Cost Rows -->
<div v-else class="space-y-2">
<div
v-for="cost in costs"
:key="cost.id"
class="grid grid-cols-1 md:grid-cols-12 gap-2 md:gap-3 px-4 py-3 rounded-xl border border-slate-100 bg-white hover:bg-slate-50 transition-colors items-center"
>
<!-- Date -->
<div class="col-span-2 text-sm text-slate-700">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costDate') }}:</span>
{{ formatDate(cost.created_at || cost.date) }}
</div>
<!-- Type -->
<div class="col-span-2">
<span
class="inline-block rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="costTypeBadge(cost.category_code || cost.cost_type)"
>
{{ cost.category_name || cost.cost_type || '—' }}
</span>
</div>
<!-- Amount -->
<div class="col-span-2 text-sm font-bold text-slate-800">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costAmount') }}:</span>
{{ formatAmount(cost.amount_gross, cost.currency) }}
</div>
<!-- Vehicle -->
<div class="col-span-3 text-sm text-slate-600 truncate">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costVehicle') }}:</span>
{{ cost.vehicle_name || cost.license_plate || '—' }}
</div>
<!-- Description -->
<div class="col-span-2 text-sm text-slate-500 truncate">
<span class="md:hidden text-xs text-slate-400 font-medium mr-1">{{ t('finance.costDescription') }}:</span>
{{ cost.description || '—' }}
</div>
<!-- Actions -->
<div class="col-span-1 flex justify-center">
<button
@click="openEditWizard(cost)"
class="inline-flex items-center gap-1 rounded-lg bg-sf-accent/10 px-3 py-1.5 text-xs font-semibold text-sf-accent hover:bg-sf-accent/20 transition-colors"
:title="t('common.edit')"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{{ t('common.edit') }}
</button>
</div>
</div>
</div>
<!-- Pagination -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between pt-4 mt-4 border-t border-slate-100"
>
<button
@click="prevPage"
:disabled="currentPage <= 1"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="currentPage > 1
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
>
{{ t('common.prev') }}
</button>
<span class="text-xs text-slate-400">
{{ currentPage }} / {{ totalPages }}
</span>
<button
@click="nextPage"
:disabled="currentPage >= totalPages"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="currentPage < totalPages
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
: 'bg-slate-50 text-slate-300 cursor-not-allowed'"
>
{{ t('common.next') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Cost Entry Wizard (Create Mode) -->
<CostEntryWizard
:is-open="showWizard"
:vehicle="selectedVehicleForWizard"
@close="closeCostWizard"
@saved="onCostSaved"
/>
<!-- Cost Entry Wizard (Edit Mode) -->
<CostEntryWizard
:is-open="showEditWizard"
:vehicle="editVehicle"
:edit-cost="editCostData"
@close="closeEditWizard"
@saved="onCostSaved"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import api from '../../api/axios'
import { useVehicleStore } from '../../stores/vehicle'
import { useAuthStore } from '../../stores/auth'
import type { Vehicle } from '../../stores/vehicle'
import CostEntryWizard from '../../components/cost/CostEntryWizard.vue'
const router = useRouter()
const { t } = useI18n()
const vehicleStore = useVehicleStore()
const authStore = useAuthStore()
// ── Tab Definitions ──
const tabs = [
{ key: 'analytics', icon: '📊', label: t('finance.analyticsTab') },
{ key: 'transactions', icon: '📋', label: t('finance.transactionsTab') },
]
const activeTab = ref('analytics')
// ── State ──
const costs = ref<any[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const currentPage = ref(1)
const totalPages = ref(1)
const pageSize = 20
// ── Filters ──
const selectedVehicleFilter = ref('')
const selectedPeriodFilter = ref('thisYear')
// ── Wizard State ──
const showWizard = ref(false)
const selectedVehicleForWizard = ref<Vehicle | null>(null)
const showEditWizard = ref(false)
const editVehicle = ref<Vehicle | null>(null)
const editCostData = ref<any>(null)
// ── Computed Analytics ──
const totalSum = computed(() => {
return costs.value.reduce((sum, c) => {
const amt = parseFloat(c.amount_gross) || 0
return sum + amt
}, 0)
})
const filteredSum = computed(() => totalSum.value)
const avgPerVehicle = computed(() => {
if (costs.value.length === 0) return 0
const uniqueVehicles = new Set(costs.value.map((c) => c.asset_id || c.vehicle_id)).size
if (uniqueVehicles === 0) return totalSum.value
return totalSum.value / uniqueVehicles
})
// ── Helpers ──
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function formatAmount(amount: string | number | null, currency?: string): string {
if (amount == null) return '—'
const num = typeof amount === 'string' ? parseFloat(amount) : amount
const formatted = Number(num).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})
return currency ? `${formatted} ${currency}` : `${formatted} Ft`
}
function costTypeBadge(type: string | null): string {
switch (type) {
case 'fuel':
case 'FUEL':
case 'Üzemanyag':
return 'bg-orange-100 text-orange-700'
case 'service':
case 'SERVICE':
case 'Szerviz':
return 'bg-blue-100 text-blue-700'
case 'insurance':
case 'INSURANCE':
case 'Biztosítás':
return 'bg-purple-100 text-purple-700'
case 'tax':
case 'TAX':
case 'Adó':
return 'bg-red-100 text-red-700'
case 'parking':
case 'PARKING':
case 'Parkolás':
return 'bg-amber-100 text-amber-700'
case 'toll':
case 'TOLL':
case 'Útdíj':
return 'bg-yellow-100 text-yellow-700'
default:
return 'bg-slate-100 text-slate-600'
}
}
// ── Data Fetching ──
async function fetchCosts() {
isLoading.value = true
error.value = null
try {
const params: Record<string, any> = {
page: currentPage.value,
page_size: pageSize,
}
// Add asset_id filter if a specific vehicle is selected
if (selectedVehicleFilter.value) {
params.asset_id = selectedVehicleFilter.value
}
// Add period filter
if (selectedPeriodFilter.value === 'thisYear') {
const now = new Date()
params.date_from = new Date(now.getFullYear(), 0, 1).toISOString()
params.date_to = now.toISOString()
} else if (selectedPeriodFilter.value === 'last30') {
const now = new Date()
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
params.date_from = thirtyDaysAgo.toISOString()
params.date_to = now.toISOString()
}
// 'all' => no date filter
// Pass the active organization_id so the backend returns the correct org's expenses
let orgId = authStore.user?.active_organization_id
if (orgId == null && authStore.myOrganizations.length > 0) {
orgId = authStore.myOrganizations[0].organization_id
}
if (orgId != null) {
params.organization_id = orgId
}
const res = await api.get('/expenses', { params })
const data = res.data
costs.value = data.data || data.items || data.results || []
totalPages.value = data.total_pages || data.pagination?.total_pages || 1
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Nem sikerült betölteni a költségeket.'
error.value = message
console.error('[CostsView] fetchCosts error:', err)
console.error('[CostsView] error response:', err.response?.status, err.response?.data)
} finally {
isLoading.value = false
}
}
function onFilterChange() {
currentPage.value = 1
fetchCosts()
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
fetchCosts()
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
fetchCosts()
}
}
// ── Cost Entry Wizard (Create) ──
function openCostWizard() {
// If a vehicle is selected in the filter, pass it to the wizard
if (selectedVehicleFilter.value) {
selectedVehicleForWizard.value =
vehicleStore.vehicles.find(v => v.id === selectedVehicleFilter.value) || null
} else {
selectedVehicleForWizard.value = null
}
showWizard.value = true
}
function closeCostWizard() {
showWizard.value = false
selectedVehicleForWizard.value = null
}
// ── Cost Entry Wizard (Edit) ──
function openEditWizard(cost: any) {
// Find the vehicle for this cost
const vehicleId = cost.asset_id || cost.vehicle_id
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
editCostData.value = cost
showEditWizard.value = true
}
function closeEditWizard() {
showEditWizard.value = false
editVehicle.value = null
editCostData.value = null
}
function onCostSaved() {
// Refresh the table data after a cost is saved
closeCostWizard()
closeEditWizard()
currentPage.value = 1
fetchCosts()
}
// ── Watch: Re-fetch when active organization changes ──
watch(
() => authStore.user?.active_organization_id,
() => {
currentPage.value = 1
fetchCosts()
}
)
// ── Lifecycle ──
onMounted(() => {
// Ensure vehicles are loaded for the filter dropdown
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles()
}
fetchCosts()
})
</script>

View File

@@ -34,7 +34,7 @@
<div class="flex items-center gap-4 text-sm"> <div class="flex items-center gap-4 text-sm">
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-green-400"></span> <span class="w-2 h-2 rounded-full bg-green-400"></span>
<span class="text-green-700">10 {{ t('company.fleetActive') }}</span> <span class="text-green-700">10 {{ t('common.statusActive') }}</span>
</span> </span>
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-red-400"></span> <span class="w-2 h-2 rounded-full bg-red-400"></span>

View File

@@ -292,7 +292,7 @@
<!-- Country, Language, Currency --> <!-- Country, Language, Currency -->
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div> <div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.country') }}</label> <label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.country') }}</label>
<select <select
v-model="form.country_code" v-model="form.country_code"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all" class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
@@ -335,7 +335,7 @@
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div class="col-span-1"> <div class="col-span-1">
<label class="block text-sm font-medium text-white/80 mb-2"> <label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.zipCode') }} <span class="text-red-400">*</span> {{ t('common.zipCode') }} <span class="text-red-400">*</span>
</label> </label>
<input <input
v-model="form.address_zip" v-model="form.address_zip"
@@ -346,7 +346,7 @@
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-sm font-medium text-white/80 mb-2"> <label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.city') }} <span class="text-red-400">*</span> {{ t('common.city') }} <span class="text-red-400">*</span>
</label> </label>
<div class="relative"> <div class="relative">
<input <input
@@ -378,7 +378,7 @@
<!-- Street details --> <!-- Street details -->
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.streetName') }}</label> <label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.streetName') }}</label>
<input <input
v-model="form.address_street_name" v-model="form.address_street_name"
type="text" type="text"
@@ -387,12 +387,12 @@
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.streetType') }}</label> <label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.streetType') }}</label>
<select <select
v-model="form.address_street_type" v-model="form.address_street_type"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all" class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
> >
<option value="" disabled class="bg-[#04151F]">{{ t('company.streetType') }}</option> <option value="" disabled class="bg-[#04151F]">{{ t('common.streetType') }}</option>
<option value="utca" class="bg-[#04151F]">utca</option> <option value="utca" class="bg-[#04151F]">utca</option>
<option value="út" class="bg-[#04151F]">út</option> <option value="út" class="bg-[#04151F]">út</option>
<option value="tér" class="bg-[#04151F]">tér</option> <option value="tér" class="bg-[#04151F]">tér</option>
@@ -406,7 +406,7 @@
<!-- House number + additional address fields --> <!-- House number + additional address fields -->
<div class="grid grid-cols-4 gap-3"> <div class="grid grid-cols-4 gap-3">
<div> <div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.houseNumber') }}</label> <label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.houseNumber') }}</label>
<input <input
v-model="form.address_house_number" v-model="form.address_house_number"
type="text" type="text"
@@ -415,7 +415,7 @@
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.stairwell') }}</label> <label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.stairwell') }}</label>
<input <input
v-model="form.address_stairwell" v-model="form.address_stairwell"
type="text" type="text"
@@ -424,7 +424,7 @@
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.floor') }}</label> <label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.floor') }}</label>
<input <input
v-model="form.address_floor" v-model="form.address_floor"
type="text" type="text"
@@ -433,7 +433,7 @@
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.door') }}</label> <label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.door') }}</label>
<input <input
v-model="form.address_door" v-model="form.address_door"
type="text" type="text"
@@ -445,7 +445,7 @@
<!-- HRsz (parcel ID) --> <!-- HRsz (parcel ID) -->
<div> <div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.parcelId') }}</label> <label class="block text-sm font-medium text-white/80 mb-2">{{ t('common.parcelId') }}</label>
<input <input
v-model="form.address_hrsz" v-model="form.address_hrsz"
type="text" type="text"
@@ -515,7 +515,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
{{ t('company.cancel') }} {{ t('common.cancel') }}
</button> </button>
<!-- Back button --> <!-- Back button -->
@@ -527,7 +527,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M15 19l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
{{ t('company.back') }} {{ t('common.back') }}
</button> </button>
<div v-else class="flex-1" /> <div v-else class="flex-1" />
@@ -733,7 +733,7 @@ function validateStep(): boolean {
return false return false
} }
if (!form.address_city) { if (!form.address_city) {
errorMessage.value = t('company.cityRequired') errorMessage.value = t('common.cityRequired')
return false return false
} }
} }

View File

@@ -1,10 +1,13 @@
<template> <template>
<div class="relative min-h-screen"> <div class="relative min-h-screen">
<!-- Garage Background Image (no dark overlay) --> <!-- Garage Background Image -->
<div class="fixed inset-0 z-0"> <div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div> <div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div> </div>
<!-- Dark overlay for readability -->
<div class="fixed inset-0 z-0 bg-slate-900/65 pointer-events-none"></div>
<!-- Content --> <!-- Content -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Page header --> <!-- Page header -->
@@ -15,16 +18,42 @@
{{ t('garage.subtitle', { count: vehicleStore.vehicles.length }) }} {{ t('garage.subtitle', { count: vehicleStore.vehicles.length }) }}
</p> </p>
</div> </div>
<!-- Back to Dashboard button --> <div class="flex items-center gap-3">
<button <!-- P0: Vehicle limit counter -->
@click="router.push('/dashboard')" <div
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white" class="hidden sm:flex items-center gap-1.5 rounded-xl bg-white/5 border border-white/10 px-3 py-2 text-xs text-white/60"
> :title="t('subscription.vehicleLimit')"
<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="M10 19l-7-7m0 0l7-7m-7 7h18" /> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
{{ t('garage.backToDashboard') }} </svg>
</button> <span>{{ vehicleStore.vehicles.length }} / {{ maxVehicles }}</span>
</div>
<!-- Add Vehicle button -->
<button
@click="handleAddNew"
:disabled="isVehicleLimitReached"
class="flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold text-white shadow-lg transition-all duration-200 active:scale-95"
:class="isVehicleLimitReached
? 'bg-slate-600 cursor-not-allowed opacity-50'
: 'bg-[#70BC84] hover:bg-[#5da870] hover:shadow-xl'"
>
<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>
{{ t('garage.addVehicle') }}
</button>
<!-- Back to Dashboard button -->
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
@@ -65,7 +94,7 @@
<template #header> <template #header>
<div class="flex w-full items-center justify-between"> <div class="flex w-full items-center justify-between">
<span class="text-white font-bold text-sm tracking-wide truncate"> <span class="text-white font-bold text-sm tracking-wide truncate">
{{ vehicle.license_plate || t('garage.noPlate') }} {{ vehicle.license_plate || t('common.noPlate') }}
</span> </span>
<span v-if="vehicle.is_primary" class="ml-2 rounded-full bg-yellow-400/20 px-2 py-0.5 text-[10px] font-semibold text-yellow-300"> <span v-if="vehicle.is_primary" class="ml-2 rounded-full bg-yellow-400/20 px-2 py-0.5 text-[10px] font-semibold text-yellow-300">
{{ t('garage.primary') }} {{ t('garage.primary') }}
@@ -91,19 +120,19 @@
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.year') }}</span> <span class="text-slate-500">{{ t('common.year') }}</span>
<span class="font-medium text-slate-800"> <span class="font-medium text-slate-800">
{{ vehicle.year_of_manufacture || vehicle.year || '—' }} {{ vehicle.year_of_manufacture || vehicle.year || '—' }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.nextService') }}</span> <span class="text-slate-500">{{ t('common.nextService') }}</span>
<span class="font-medium text-slate-800"> <span class="font-medium text-slate-800">
{{ vehicle.individual_equipment?.dates?.mot_expiry || '—' }} {{ vehicle.individual_equipment?.dates?.mot_expiry || '—' }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-slate-500">{{ t('garage.mileage') }}</span> <span class="text-slate-500">{{ t('common.mileage') }}</span>
<span class="font-medium text-slate-800"> <span class="font-medium text-slate-800">
{{ vehicle.current_mileage ? `${vehicle.current_mileage.toLocaleString()} km` : '—' }} {{ vehicle.current_mileage ? `${vehicle.current_mileage.toLocaleString()} km` : '—' }}
</span> </span>
@@ -113,22 +142,63 @@
</BaseCard> </BaseCard>
</div> </div>
</div> </div>
<!-- Vehicle Form Modal (Add) -->
<VehicleFormModal
:is-open="isAddFormOpen"
@close="isAddFormOpen = false"
@saved="onAddVehicleSaved"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle' import { useVehicleStore } from '../../stores/vehicle'
import BaseCard from '../../components/ui/BaseCard.vue' import BaseCard from '../../components/ui/BaseCard.vue'
import VehicleFormModal from '../../components/dashboard/VehicleFormModal.vue'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
const vehicleStore = useVehicleStore() const vehicleStore = useVehicleStore()
// ── Add Vehicle Modal State ──
const isAddFormOpen = ref(false)
// ── P0: Vehicle limit from subscription tier allowances ──
const maxVehicles = computed(() => {
return authStore.user?.max_vehicles ?? 1
})
const isVehicleLimitReached = computed(() => {
return vehicleStore.vehicles.length >= maxVehicles.value
})
/**
* Open the Add Vehicle modal.
* Checks vehicle limit before opening.
*/
function handleAddNew() {
if (isVehicleLimitReached.value) {
// Limit reached — button is disabled, but guard just in case
return
}
isAddFormOpen.value = true
}
/**
* Called when the VehicleFormModal emits 'saved'.
* Closes the modal and refreshes the vehicle list.
*/
function onAddVehicleSaved() {
isAddFormOpen.value = false
vehicleStore.fetchVehicles()
}
/** /**
* Navigate to the Vehicle Details page. * Navigate to the Vehicle Details page.
* Respects corporate vs private context. * Respects corporate vs private context.

View File

@@ -3,6 +3,9 @@
<!-- Garage background image --> <!-- Garage background image -->
<div class="fixed inset-0 z-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div> <div class="fixed inset-0 z-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
<!-- Dark overlay for readability -->
<div class="fixed inset-0 z-0 bg-slate-900/65 pointer-events-none"></div>
<!-- Content --> <!-- Content -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Back button --> <!-- Back button -->
@@ -35,15 +38,29 @@
<!-- Vehicle Loaded --> <!-- Vehicle Loaded -->
<template v-if="vehicle"> <template v-if="vehicle">
<!-- Vehicle header --> <!-- Vehicle header with Edit button -->
<div class="mb-6"> <div class="mb-6 flex items-start justify-between">
<h1 class="text-3xl font-bold text-white"> <div>
{{ vehicle.license_plate || t('vehicleDetail.title') }} <h1 class="text-3xl font-bold text-white">
</h1> {{ vehicle.license_plate || t('vehicleDetail.title') }}
<p class="mt-1 text-sm text-white/60"> </h1>
{{ vehicle.brand }} {{ vehicle.model }} <p class="mt-1 text-sm text-white/60">
<span v-if="vehicle.year_of_manufacture"> · {{ vehicle.year_of_manufacture }}</span> {{ vehicle.brand }} {{ vehicle.model }}
</p> <span v-if="vehicle.year_of_manufacture"> · {{ vehicle.year_of_manufacture }}</span>
</p>
</div>
<!-- Edit Vehicle Button -->
<button
@click="isEditModalOpen = true"
class="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-white transition-colors hover:bg-emerald-500"
>
<!-- Pencil/Edit icon -->
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{{ t('vehicle.edit_title') || t('profile.edit') || 'Edit' }}
</button>
</div> </div>
<!-- Tab Navigation --> <!-- Tab Navigation -->
@@ -72,6 +89,14 @@
</Transition> </Transition>
</template> </template>
</div> </div>
<!-- Vehicle Edit Modal -->
<VehicleFormModal
:is-open="isEditModalOpen"
:vehicle="vehicle"
@close="isEditModalOpen = false"
@saved="onVehicleSaved"
/>
</div> </div>
</template> </template>
@@ -89,6 +114,9 @@ import ServiceBookTab from '../../components/vehicles/tabs/ServiceBookTab.vue'
import FinancialsTab from '../../components/vehicles/tabs/FinancialsTab.vue' import FinancialsTab from '../../components/vehicles/tabs/FinancialsTab.vue'
import DocumentsTab from '../../components/vehicles/tabs/DocumentsTab.vue' import DocumentsTab from '../../components/vehicles/tabs/DocumentsTab.vue'
// ── Edit Modal ──
import VehicleFormModal from '../../components/dashboard/VehicleFormModal.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
@@ -97,7 +125,7 @@ const vehicleStore = useVehicleStore()
// ── Provide vehicle data to child tab components ── // ── Provide vehicle data to child tab components ──
const vehicle = computed(() => { const vehicle = computed(() => {
const id = route.params.id as string const id = (route.params.vehicle_id || route.params.id) as string
return vehicleStore.vehicles.find(v => v.id === id) || null return vehicleStore.vehicles.find(v => v.id === id) || null
}) })
provide('vehicle', vehicle) provide('vehicle', vehicle)
@@ -107,6 +135,22 @@ const isLoading = computed(() => {
return vehicleStore.isLoading || (vehicleStore.vehicles.length === 0 && !vehicle.value) return vehicleStore.isLoading || (vehicleStore.vehicles.length === 0 && !vehicle.value)
}) })
// ── Edit Modal State ──
const isEditModalOpen = ref(false)
/**
* Called when the VehicleFormModal emits 'saved'.
* Closes the modal and refreshes vehicle data.
*/
function onVehicleSaved() {
isEditModalOpen.value = false
// Refresh the vehicle from the backend to get updated data
const id = (route.params.vehicle_id || route.params.id) as string
if (id) {
vehicleStore.fetchVehicleById(id)
}
}
// ── Tab definitions (fully reactive via computed) ── // ── Tab definitions (fully reactive via computed) ──
interface TabDefinition { interface TabDefinition {
key: string key: string

View File

@@ -0,0 +1,264 @@
# 📐 Logic Spec — Vehicle Registration Documents
## Modul célja és Masterbook 2 illeszkedés
**Cél:** A forgalmi engedély száma, érvényességi ideje és a törzskönyv száma mezők hiányának pótlása a teljes adatfolyamban (adatbázis → API szint → Frontend UI).
**Masterbook 2 illeszkedés:** A Thick Asset Digital Twin filozófiába illeszkedik — minden adminisztratív adatot közvetlenül az Asset modellben tárolunk, nem JSONB-ben.
---
## Adatmodell
### Új mezők az Asset modellben
| Mező | Típus | SQL típus | Kötelező | Alapértelmezett |
|------|-------|-----------|----------|-----------------|
| `registration_certificate_number` | `Optional[str]` | `VARCHAR(50)` | Nem | `None` |
| `registration_certificate_validity` | `Optional[datetime]` | `DateTime(timezone=True)` | Nem | `None` |
| `vehicle_registration_document_number` | `Optional[str]` | `VARCHAR(50)` | Nem | `None` |
### Miért nem JSONB?
Ezek strukturált, kereshető admin adatok, amelyek:
- Külön lekérdezhetők (WHERE registration_certificate_number = '...')
- Indexelhetők
- Érvényesíthetők (pl. string hossz, dátum formátum)
- Nincsenek elrejtve a szabad formátumú JSONB-ben
---
## 1. Lépés: Adatbázis modell bővítése
**Fájl:** `backend/app/models/vehicle/asset.py`
### Módosítás
Az `Asset` osztályhoz (a warranty mezők után, a `notes` előtt) hozzáadandó:
```python
# === REGISTRATION DOCUMENTS ===
registration_certificate_number: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True, default=None,
comment="Forgalmi engedély száma"
)
registration_certificate_validity: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, default=None,
comment="Forgalmi engedély érvényességi ideje"
)
vehicle_registration_document_number: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True, default=None,
comment="Törzskönyv száma"
)
```
### Sync
```bash
docker exec -it sf_api python -m app.scripts.sync_engine
```
---
## 2. Lépés: Pydantic sémák bővítése
**Fájl:** `backend/app/schemas/asset.py`
### AssetResponse (32. sor után)
```python
# === REGISTRATION DOCUMENTS ===
registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma")
registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje")
vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma")
```
### AssetCreate (a warranty után, 233. sor környékén)
```python
# === REGISTRATION DOCUMENTS ===
registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma")
registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje")
vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma")
```
### AssetUpdate (a warranty után, 375. sor környékén)
```python
# === REGISTRATION DOCUMENTS ===
registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma")
registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje")
vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma")
```
---
## 3. Lépés: Asset Service bővítése
**Fájl:** `backend/app/services/asset_service.py`
### Módosítás a create_or_claim_vehicle() metódusban
A 219-es sor előtt (a `first_registration_date` után) az `asset_fields` dict-be:
```python
# Registration Documents
'registration_certificate_number': asset_data.registration_certificate_number,
'registration_certificate_validity': asset_data.registration_certificate_validity,
'vehicle_registration_document_number': asset_data.vehicle_registration_document_number,
```
---
## 4. Lépés: Frontend — Vehicle Store bővítése
**Fájl:** `frontend/src/stores/vehicle.ts`
### Módosítás a Vehicle interfészben (a 88-89. sor környékén)
```typescript
// Registration Documents
registration_certificate_number?: string | null
registration_certificate_validity?: string | null
vehicle_registration_document_number?: string | null
```
---
## 5. Lépés: Frontend — VehicleFormModal bővítése
**Fájl:** `frontend/src/components/dashboard/VehicleFormModal.vue`
### 5a. Form state bővítése (a 1779-es sor után)
```typescript
// Registration Documents
registration_certificate_number: '',
registration_certificate_validity: '',
vehicle_registration_document_number: '',
```
### 5b. Admin Tab UI bővítése (a 1006-os sor előtt, a warranty szekció után)
```html
<!-- ════════════════════════════════════════════════════════════ -->
<!-- REGISTRATION DOCUMENTS SECTION -->
<!-- ════════════════════════════════════════════════════════════ -->
<div class="border-t border-slate-200 pt-4">
<h4 class="text-sm font-semibold text-slate-700 mb-3">{{ t('vehicle.registration_documents_title') || 'Forgalmi engedély és Törzskönyv' }}</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_registration_certificate_number') || 'Forgalmi engedély száma' }}</label>
<input
v-model="form.registration_certificate_number"
type="text"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
:placeholder="t('vehicle.placeholder_registration_certificate_number') || 'Pl. AB-123456'"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_registration_certificate_validity') || 'Forgalmi engedély érvényessége' }}</label>
<input
v-model="form.registration_certificate_validity"
type="date"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
/>
</div>
</div>
<div class="mt-3">
<label class="mb-1.5 block text-sm font-medium text-slate-700">{{ t('vehicle.label_vehicle_registration_document_number') || 'Törzskönyv száma' }}</label>
<input
v-model="form.vehicle_registration_document_number"
type="text"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
:placeholder="t('vehicle.placeholder_vehicle_registration_document_number') || 'Pl. 1234567890'"
/>
</div>
</div>
```
### 5c. handleSave payload bővítése (a 2159-es sor után)
```typescript
// Registration Documents
registration_certificate_number: form.registration_certificate_number || null,
registration_certificate_validity: form.registration_certificate_validity || null,
vehicle_registration_document_number: form.vehicle_registration_document_number || null,
```
### 5d. populateForm bővítése (a 2065-ös sor után)
```typescript
// Registration Documents
form.registration_certificate_number = vehicle.registration_certificate_number || ''
form.registration_certificate_validity = vehicle.registration_certificate_validity || ''
form.vehicle_registration_document_number = vehicle.vehicle_registration_document_number || ''
```
### 5e. resetForm bővítése (kb. 1987-2026 sorok között)
```typescript
form.registration_certificate_number = ''
form.registration_certificate_validity = ''
form.vehicle_registration_document_number = ''
```
---
## 6. Lépés: Frontend i18n fordítások
**Fájl:** `frontend/src/i18n/hu.ts`
```typescript
label_registration_certificate_number: 'Forgalmi engedély száma',
label_registration_certificate_validity: 'Forgalmi engedély érvényessége',
label_vehicle_registration_document_number: 'Törzskönyv száma',
placeholder_registration_certificate_number: 'Pl. AB-123456',
placeholder_vehicle_registration_document_number: 'Pl. 1234567890',
registration_documents_title: 'Forgalmi engedély és Törzskönyv',
```
**Fájl:** `frontend/src/i18n/en.ts`
```typescript
label_registration_certificate_number: 'Registration Certificate Number',
label_registration_certificate_validity: 'Registration Certificate Validity',
label_vehicle_registration_document_number: 'Vehicle Registration Document Number',
placeholder_registration_certificate_number: 'e.g. AB-123456',
placeholder_vehicle_registration_document_number: 'e.g. 1234567890',
registration_documents_title: 'Registration Certificate & Vehicle Document',
```
---
## 7. Lépés: Backend i18n (opcionális)
**Fájl:** `backend/static/locales/hu.json`
```json
"REGISTRATION_DOCUMENTS": {
"CERTIFICATE_NUMBER": "Forgalmi engedély száma",
"CERTIFICATE_VALIDITY": "Forgalmi engedély érvényessége",
"DOCUMENT_NUMBER": "Törzskönyv száma"
}
```
**Fájl:** `backend/static/locales/en.json`
```json
"REGISTRATION_DOCUMENTS": {
"CERTIFICATE_NUMBER": "Registration Certificate Number",
"CERTIFICATE_VALIDITY": "Registration Certificate Validity",
"DOCUMENT_NUMBER": "Vehicle Registration Document Number"
}
```
---
## Tesztelési terv
1. **Adatbázis:** `docker exec -it sf_api python -m app.scripts.sync_engine` futtatása után ellenőrizni, hogy a 3 új oszlop létrejött a `vehicle.assets` táblában
2. **API:** POST /vehicles hívás új mezőkkel → 201 Created + az új mezők visszaküldése
3. **API:** PUT /vehicles/{id} hívás új mezőkkel → 200 OK + az új mezők frissítése
4. **API:** GET /vehicles/{id} → az új mezők megjelennek a válaszban
5. **Frontend:** VehicleFormModal → Admin tabon megjelennek az új mezők, mentéskor elküldődnek, szerkesztéskor visszatöltődnek

View File

@@ -0,0 +1,148 @@
# P0 Plan: Subscription Profile Menu & Modal
## 1. Overview
Add an "Előfizetés" (Subscription) menu item to the avatar dropdown in [`HeaderProfile.vue`](frontend/src/components/header/HeaderProfile.vue), which opens a modal showing **real subscription data** (package name, expiry, vehicle limits/usage, garage limits/usage) plus a "Renew" (Meghosszabbítás) button.
## 2. Files to Modify
| # | File | Action | Reason |
|---|------|--------|--------|
| 1 | [`frontend/src/components/header/HeaderProfile.vue`](frontend/src/components/header/HeaderProfile.vue) | **Modify** | Add "Előfizetés" menu item between Profile and Admin Center |
| 2 | [`frontend/src/components/subscription/SubscriptionInfoModal.vue`](frontend/src/components/subscription/SubscriptionInfoModal.vue) | **Create** | New modal component showing subscription details |
| 3 | [`frontend/src/i18n/en.ts`](frontend/src/i18n/en.ts) | **Modify** | Add i18n keys for the new modal |
| 4 | [`frontend/src/i18n/hu.ts`](frontend/src/i18n/hu.ts) | **Modify** | Add Hungarian translations |
## 3. Data Binding Paths
### 3.1 Current Plan Name
```
authStore.user?.subscription_plan (individual mode)
activeOrg?.subscription_plan (corporate mode)
```
### 3.2 Expiry Date
The backend `UserResponse` schema does NOT currently expose `subscription_expires_at`. However:
- The [`User` model](backend/app/models/identity/identity.py:142) has `subscription_expires_at`
- The [`Organization` model](backend/app/models/marketplace/organization.py:141-143) has `subscription_plan`, `base_asset_limit`, `purchased_extra_slots`, `subscription_tier_id`
- The [`OrganizationSubscription` model](backend/app/models/core_logic.py:25-47) has `valid_until`
**Current frontend approach** (from [`SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue:87-95)):
- For corporate mode: `activeOrg?.subscription_valid_until` (but this field is NOT in the backend `/organizations/my` response)
- For individual mode: `authStore.user?.subscription_valid_until` (but this field is NOT in the `UserResponse` schema)
**Gap:** The `subscription_expires_at` field exists on the backend `User` model but is not exposed in the `UserResponse` Pydantic schema. Similarly, `subscription_expires_at` exists on the `Organization` model but is not returned by the `/organizations/my` endpoint.
**Plan:** We will add `subscription_expires_at` to:
1. [`UserResponse`](backend/app/schemas/user.py:54-73) schema
2. The `/organizations/my` endpoint response dict (line 222 area)
### 3.3 Vehicle Limits & Usage
```
From SubscriptionTier.rules.allowances:
plan.rules.allowances.max_vehicles (max limit)
plan.rules.allowances.max_garages (max limit)
Current usage from:
vehicleStore.vehicles.length (current vehicles count)
authStore.myOrganizations.length (current garages count)
```
### 3.4 Resolving the Tier
The tier is resolved via `subscription_tier_id` on the org/user. We need a new API endpoint or we can use the existing [`/subscriptions/public`](frontend/src/views/SubscriptionPlansView.vue:309) endpoint to fetch all tiers and match by name.
**Better approach:** Add a new lightweight endpoint `GET /subscriptions/my` that returns the current user's/org's subscription details including:
- `tier_name` (the plan name)
- `tier_rules` (the full rules JSONB)
- `expires_at` (from `subscription_expires_at`)
- `current_vehicles` count
- `current_garages` count
This avoids multiple round-trips and complex client-side stitching.
### 3.5 Renewal Action
The "Meghosszabbítás" button should navigate to the existing [`SubscriptionPlansView`](frontend/src/views/SubscriptionPlansView.vue) which already shows plan cards with a "Details & Purchase" flow. The existing [`PlanDetailsModal`](frontend/src/components/subscription/PlanDetailsModal.vue) handles the purchase simulation.
## 4. Proposed Architecture
### 4.1 Backend Changes (Optional but Recommended)
Add `subscription_expires_at` to `UserResponse` schema and to the `/organizations/my` response.
### 4.2 New Component: `SubscriptionInfoModal.vue`
- Glassmorphism style matching the dropdown
- Shows:
- Package name (display_name from tier rules, or plan name)
- Expiry date with days-remaining countdown
- Progress bar (same as `SubscriptionStatusWidget`)
- Vehicle usage: "3 / 5 vehicles" with progress bar
- Garage usage: "1 / 2 garages" with progress bar
- "Meghosszabbítás" button → navigates to `/subscription-plans`
### 4.3 HeaderProfile.vue Changes
Add a new menu item between Profile and Admin Center:
```html
<button @click="openSubscriptionModal">
<svg>...</svg>
{{ t('header.subscription') }}
</button>
```
### 4.4 Data Flow
1. User clicks "Előfizetés" in dropdown
2. Modal opens, reads data from:
- `authStore.user.subscription_plan` / `authStore.user.subscription_expires_at`
- `adminPackagesStore.tiers` (already fetched) to find matching tier by name
- `vehicleStore.vehicles.length` for current vehicle count
- `authStore.myOrganizations.length` for current garage count
3. "Meghosszabbítás" button → `router.push('/subscription-plans')`
## 5. i18n Keys to Add
### English (`en.ts`)
```json
subscription: {
// ... existing keys ...
mySubscription: "My Subscription",
renew: "Renew",
expiresAt: "Expires at",
vehicleUsage: "Vehicle Usage",
garageUsage: "Garage Usage",
of: "of",
}
header: {
// ... existing keys ...
subscription: "Subscription",
}
```
### Hungarian (`hu.ts`)
```json
subscription: {
// ... existing keys ...
mySubscription: "Előfizetésem",
renew: "Meghosszabbítás",
expiresAt: "Lejárat",
vehicleUsage: "Járműhasználat",
garageUsage: "Garázshasználat",
of: "/",
}
header: {
// ... existing keys ...
subscription: "Előfizetés",
}
```
## 6. Risk Assessment
- **Data availability:** `subscription_expires_at` is NOT currently exposed in the frontend-accessible schemas. We need to either:
- (A) Add it to the backend schemas (recommended, clean)
- (B) Create a new `/subscriptions/my` endpoint
- (C) Use only the plan name and show "N/A" for expiry (fallback)
- **Tier rules resolution:** The frontend `adminPackagesStore` already fetches all tiers. We can match by `plan.name` to get the rules.
- **Vehicle/garage counts:** Already available in existing stores.
## 7. Implementation Order
1. Add `subscription_expires_at` to backend `UserResponse` schema
2. Add `subscription_expires_at` to `/organizations/my` response
3. Create `SubscriptionInfoModal.vue`
4. Add i18n keys
5. Modify `HeaderProfile.vue` to add menu item and wire up modal
6. Run sync_engine and verify

View File

@@ -0,0 +1,275 @@
# P0 Végrehajtási Terv: Vehicle Components & Modal Javítások
**Státusz:** Tervezés alatt
**Dátum:** 2026-06-21
**Architect:** Rendszer-Architect
---
## 1. Áttekintés
A P0 feladat a meglévő jármű komponensek 4 problémás területének javítása az Architect modul elemzése alapján. A terv 3 fájl módosítását írja elő, új komponensek létrehozása NÉLKÜL.
### Érintett fájlok:
| # | Fájl | Művelet | Hatás |
|---|------|---------|-------|
| 1 | `frontend/src/types/vehicle.ts` | `image_url` mező hozzáadása a `VehicleData` interfészhez | Type safety |
| 2 | `frontend/src/components/vehicle/VehicleCardStandard.vue` | 5 adatkötés javítás | UI adatok helyes megjelenítése |
| 3 | `frontend/src/components/vehicle/VehicleDetailModal.vue` | 5. tab (TechData) integráció + Pénzügyek tab bővítés | Teljes TCO láthatóság |
---
## 2. Részletes Módosítási Terv
### 2.1 `VehicleData` típus bővítése
**Fájl:** [`frontend/src/types/vehicle.ts`](frontend/src/types/vehicle.ts:10)
```typescript
export interface VehicleData {
// ... existing fields ...
image_url?: string | null; // ÚJ mező a kép URL tárolására
}
```
**Indoklás:** A `VehicleCardStandard.vue` `:src="vehicle.image_url"`-t használna, de a `VehicleData` interfészből hiányzik ez a mező. A backend már visszaadhat `image_url`-t az `AssetResponse`-ben (lásd [`assets.py:386`](backend/app/api/v1/endpoints/assets.py:386)).
---
### 2.2 `VehicleCardStandard.vue` - 5 adatkötés javítás
**Fájl:** [`frontend/src/components/vehicle/VehicleCardStandard.vue`](frontend/src/components/vehicle/VehicleCardStandard.vue)
| # | Probléma | Javítás | Sor |
|---|----------|---------|-----|
| 1 | `is_primary` csillag gomb (megvan, de nincs szöveges badge) | **Nem változtatunk** - a meglévő star toggle működik, a feladat csak a data bindings-okról szól | 119-131 |
| 2 | `color` mező: `vehicle.individual_equipment?.color \|\| vehicle.color \|\| '—'` | Lecserélés erre: `vehicle.individual_equipment?.color \|\| 'N/A'` | 103 |
| 3 | `engine` mező: `vehicle.engine \|\| vehicle.fuel_type \|\| '—'` | Lecserélés erre: `` `${vehicle.power_kw || '?'} kW / ${vehicle.engine_capacity || '?'} cm³` `` | 95 |
| 4 | `country_code` mező: `vehicle.countryCode \|\| vehicle.country_code` | **Hozzáadás** `registration_country` használatával | 62 |
| 5 | `image` mező: `vehicle.image` | Lecserélés `vehicle.image_url`-re, fallback SVG placeholder | 9-10 |
**Részletes módosítások:**
**2.2.a - Image (9-10. sor):**
```html
<img v-if="vehicle.image_url" :src="vehicle.image_url" alt="" class="..." />
<template v-else>
<svg ... ></svg> <!-- Meglévő placeholder SVG változatlan -->
</template>
```
**2.2.b - Country (62. sor):**
```html
<span class="...">{{ vehicle.registration_country || vehicle.countryCode || vehicle.country_code || '—' }}</span>
```
**2.2.c - Engine (95. sor):**
```html
<span class="...">{{ vehicle.power_kw ? vehicle.power_kw + ' kW' : '?' }} / {{ vehicle.engine_capacity ? vehicle.engine_capacity + ' cm³' : '?' }}</span>
```
**2.2.d - Color (103. sor):**
```html
<span class="...">{{ vehicle.individual_equipment?.color || 'N/A' }}</span>
```
---
### 2.3 `VehicleDetailModal.vue` - TechData Tab integráció
**Fájl:** [`frontend/src/components/vehicle/VehicleDetailModal.vue`](frontend/src/components/vehicle/VehicleDetailModal.vue)
#### Háttér
A [`TechDataTab.vue`](frontend/src/components/vehicles/tabs/TechDataTab.vue) LÉTEZIK (279 sor), de **NINCS integrálva** a modálba. Jelenleg 4 tab van (`basics`, `finance`, `alerts`, `servicebook`).
#### Megoldás: 5. tab hozzáadása
**a) Tab gomb hozzáadása (a meglévő tab gombok után, ~866-873. sor):**
```html
<button @click="activeTab = 'techdata'" :class="activeTab === 'techdata' ? '...' : '...'">
{{ $t('vehicle.techData') || 'Műszaki adatok' }}
</button>
```
**b) Tab content blokk hozzáadása (a servicebook tab után, ~736. sor után):**
```html
<div v-if="activeTab === 'techdata'" class="space-y-5">
<TechDataTab />
</div>
```
**c) Import hozzáadása (script section):**
```typescript
import TechDataTab from '@/components/vehicles/tabs/TechDataTab.vue';
```
**d) `provide/inject` kompatibilitás biztosítása:**
A [`TechDataTab.vue`](frontend/src/components/vehicles/tabs/TechDataTab.vue:107) `inject<computed<Vehicle | null>>('vehicle')`-t használ. A `VehicleDetailModal.vue`-ban a `vehicle` computed property már létezik. A provide-ot a modal szülőjének (vagy a modal-nak magának) kell biztosítania.
**Megvizsgálandó:** A `VehicleDetailModal.vue` jelenleg `props`-on keresztül kapja a vehicle adatokat. Ha a `TechDataTab.vue` `inject('vehicle')`-t vár, akkor vagy:
- Biztosítani kell a `provide('vehicle', computed(...))`-t a modalban, VAGY
- Át kell alakítani a TechDataTab-et, hogy elfogadjon egy prop-ot is.
**Ajánlás:** Mivel a [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue) is ugyanezt az `inject`-es mintát használja, a legegyszerűbb megoldás a `provide` hozzáadása a `VehicleDetailModal.vue`-hoz:
```typescript
// A script setup blokkban
import { provide, computed } from 'vue';
const vehicle = computed(() => props.vehicle || null);
provide('vehicle', vehicle);
```
---
### 2.4 `VehicleDetailModal.vue` - Pénzügyek Tab bővítés
#### Jelenlegi állapot
A `finance` tab (282-436. sor) csak `GET /assets/{id}/costs`-t hív (944-957. sor) és két részből áll: non-premium esetén egyszerű lista, premium esetén chart.
#### Bővítés: 3 új API hívás párhuzamosan
**Backend endpointok (már léteznek, VERIFIKÁLVA):**
| Endpoint | Visszatérési típus | Helye |
|----------|-------------------|-------|
| `GET /api/v1/assets/vehicles/{id}/financials` | `AssetFinancialsResponse` | [`assets.py:1245`](backend/app/api/v1/endpoints/assets.py:1245) |
| `GET /api/v1/assets/vehicles/{id}/insurance` | `List[VehicleInsurancePolicyResponse]` | [`assets.py:1314`](backend/app/api/v1/endpoints/assets.py:1314) |
| `GET /api/v1/assets/vehicles/{id}/tax` | `List[VehicleTaxObligationResponse]` | [`assets.py:1376`](backend/app/api/v1/endpoints/assets.py:1376) |
**Store metódusok (már léteznek, VERIFIKÁLVA):**
| Metódus | Param | Visszatérés |
|---------|-------|-------------|
| `fetchAssetFinancials(assetId)` | `string` | `Promise<AssetFinancials \| null>` | [`vehicle.ts:318`](frontend/src/stores/vehicle.ts:318) |
| `fetchInsurancePolicies(assetId)` | `string` | `Promise<VehicleInsurancePolicy[]>` | [`vehicle.ts:346`](frontend/src/stores/vehicle.ts:346) |
| `fetchTaxObligations(assetId)` | `string` | `Promise<VehicleTaxObligation[]>` | [`vehicle.ts:388`](frontend/src/stores/vehicle.ts:388) |
#### Tervezett módosítások a `VehicleDetailModal.vue`-ban:
**a) Új state változók (script setup):**
```typescript
const assetFinancials = ref<AssetFinancials | null>(null);
const insurancePolicies = ref<VehicleInsurancePolicy[]>([]);
const taxObligations = ref<VehicleTaxObligation[]>([]);
const financeLoading = ref(false);
```
**b) Párhuzamos API hívás a `loadFinanceData` függvényben (~944. sor környékén):**
```typescript
async function loadFinanceData() {
financeLoading.value = true;
try {
const vehicleId = props.vehicle?.id?.toString();
if (!vehicleId) return;
// Promise.all a 3 új híváshoz
const [financials, policies, taxes] = await Promise.all([
vehicleStore.fetchAssetFinancials(vehicleId),
vehicleStore.fetchInsurancePolicies(vehicleId),
vehicleStore.fetchTaxObligations(vehicleId),
]);
assetFinancials.value = financials;
insurancePolicies.value = policies;
taxObligations.value = taxes;
// Meglévő costs betöltés változatlan
await loadCosts();
} catch (err) {
console.error('Failed to load finance data:', err);
} finally {
financeLoading.value = false;
}
}
```
**c) Template bővítés - új szekciók a meglévő "Költségek" szekció ELÉ:**
```html
<div v-if="activeTab === 'finance'" class="space-y-5">
<!-- 1. ÚJ: Beszerzés & Lízing szekció -->
<div v-if="assetFinancials" class="...">
<h3>{{ $t('vehicle.financials.title') }}</h3>
<div class="grid grid-cols-2 gap-3">
<div>...purchase_price...</div>
<div>...leasing...residual_value...</div>
</div>
</div>
<!-- 2. ÚJ: Biztosítások szekció -->
<div v-if="insurancePolicies.length" class="...">
<h3>{{ $t('vehicle.insurance.title') }}</h3>
<div v-for="policy in insurancePolicies" :key="policy.id">
{{ policy.insurance_type }} - {{ policy.premium_amount }} - {{ policy.expiry_date }}
</div>
</div>
<!-- 3. ÚJ: Adók szekció -->
<div v-if="taxObligations.length" class="...">
<h3>{{ $t('vehicle.tax.title') }}</h3>
<div v-for="tax in taxObligations" :key="tax.id">
{{ tax.tax_type }} - {{ tax.amount }} - {{ tax.payment_status }}
</div>
</div>
<!-- 4. MEGLÉVŐ: Költségek szekció (változatlan) -->
<div v-if="!costsLoading">
<!-- ... existing costs content ... -->
</div>
</div>
```
**d) Importok hozzáadása:**
```typescript
import type { AssetFinancials, VehicleInsurancePolicy, VehicleTaxObligation } from '@/types/vehicle';
```
---
## 3. Függőségi Térkép
```mermaid
graph TD
A[vehicle.ts - VehicleData] -->|image_url mező| B[VehicleCardStandard.vue]
A -->|Vehicle típus| C[VehicleDetailModal.vue]
C -->|provide vehicle| D[TechDataTab.vue]
C -->|Promise.all| E[vehicleStore: fetchAssetFinancials]
C -->|Promise.all| F[vehicleStore: fetchInsurancePolicies]
C -->|Promise.all| G[vehicleStore: fetchTaxObligations]
E --> H[assets.py: GET /vehicles/{id}/financials]
F --> I[assets.py: GET /vehicles/{id}/insurance]
G --> J[assets.py: GET /vehicles/{id}/tax]
```
---
## 4. Végrehajtási Sorrend
| Lépés | Fájl | Művelet | Függőség |
|-------|------|---------|----------|
| 1 | `types/vehicle.ts` | `image_url` hozzáadása | Nincs |
| 2 | `VehicleCardStandard.vue` | 5 data binding javítás | 1. lépés (`image_url`) |
| 3 | `VehicleDetailModal.vue` | TechData tab + provide hozzáadás | Nincs |
| 4 | `VehicleDetailModal.vue` | Finance tab bővítés (Promise.all) | Nincs |
| 5 | - | Vite build futtatás | 1-4. lépés |
---
## 5. Kockázatok és Megjegyzések
1. **TechDataTab inject függőség:** Ha a `provide` nem működik a modalban (pl. scope issue), alternatív megoldás a `TechDataTab.vue` átírása prop-alapúra. Ez azonban többletkockázat, ezért először a `provide`-os megoldást próbáljuk.
2. **`FinancialsTab.vue` átvételének elvetése:** A `FinancialsTab.vue` (709 sor) már tartalmazza a 3 API hívást, de sötét témát (bg-slate-900) használ, ami nem kompatibilis a modal világos témájával. A kód lemásolása helyett a logikát közvetlenül a `VehicleDetailModal.vue`-ba építjük be.
3. **Nincs Compact variáns:** A keresés nem talált Compact változatot, így csak a Standard kártyát kell javítani.
4. **i18n kulcsok:** A `hu.ts` és `en.ts` fájlokba új fordítási kulcsokat kell felvenni (pl. `vehicle.financials.title`, `vehicle.insurance.title`, `vehicle.tax.title`, `vehicle.techData`).
---
## 6. Jóváhagyás
**Architect:** ✅ Terv elkészítve
**Fejlesztő:** ⏳ Jóváhagyásra vár
A terv végrehajtásához **Code módba** kell váltani.