frontend költség beállítások
This commit is contained in:
238
.roo/history.md
238
.roo/history.md
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
13
backend/app/models/fleet/__init__.py
Normal file
13
backend/app/models/fleet/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
146
backend/app/models/fleet/organization.py
Normal file
146
backend/app/models/fleet/organization.py
Normal 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}')"
|
||||||
|
)
|
||||||
25
backend/app/models/fleet_finance/__init__.py
Normal file
25
backend/app/models/fleet_finance/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
316
backend/app/models/fleet_finance/models.py
Normal file
316
backend/app/models/fleet_finance/models.py
Normal 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})"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
]
|
||||||
]
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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ű")
|
||||||
|
|||||||
@@ -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)
|
||||||
242
backend/app/schemas/fleet_finance.py
Normal file
242
backend/app/schemas/fleet_finance.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
245
backend/scripts/seed_finance_dictionaries.py
Normal file
245
backend/scripts/seed_finance_dictionaries.py
Normal 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())
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
214
docs/bmw123_vehicle_data_flow_bug_report_2026-06-20.md
Normal file
214
docs/bmw123_vehicle_data_flow_bug_report_2026-06-20.md
Normal 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
158
docs/cost_pipeline_audit.md
Normal 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
177
docs/cost_ui_audit.md
Normal 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
271
docs/dashboard_ui_audit.md
Normal 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.
|
||||||
227
docs/db_consistency_and_zombie_api_report.md
Normal file
227
docs/db_consistency_and_zombie_api_report.md
Normal 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
774
docs/db_schema_audit.md
Normal 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
|
||||||
278
docs/finance_api_gap_analysis.md
Normal file
278
docs/finance_api_gap_analysis.md
Normal 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*
|
||||||
1139
docs/full_db_schema_blueprint.md
Normal file
1139
docs/full_db_schema_blueprint.md
Normal file
File diff suppressed because it is too large
Load Diff
204
docs/p0_backend_api_audit_vehicle_finance_2026-06-21.md
Normal file
204
docs/p0_backend_api_audit_vehicle_finance_2026-06-21.md
Normal 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.*
|
||||||
189
docs/p0_financial_data_modeling_audit_2026-06-20.md
Normal file
189
docs/p0_financial_data_modeling_audit_2026-06-20.md
Normal 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.
|
||||||
199
docs/p0_vehicle_details_ui_ux_audit_2026-06-19.md
Normal file
199
docs/p0_vehicle_details_ui_ux_audit_2026-06-19.md
Normal 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
|
||||||
180
docs/vehicle_dataflow_audit_2026-06-21.md
Normal file
180
docs/vehicle_dataflow_audit_2026-06-21.md
Normal 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
|
||||||
142
docs/vehicle_registration_documents_audit_2026-06-19.md
Normal file
142
docs/vehicle_registration_documents_audit_2026-06-19.md
Normal 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.
|
||||||
7
frontend/admin/Dockerfile.dev
Normal file
7
frontend/admin/Dockerfile.dev
Normal 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
6
frontend/admin/app.vue
Normal 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>
|
||||||
3
frontend/admin/nuxt.config.ts
Normal file
3
frontend/admin/nuxt.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
devtools: { enabled: true }
|
||||||
|
})
|
||||||
16
frontend/admin/package.json
Normal file
16
frontend/admin/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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') }}
|
||||||
|
|||||||
834
frontend/src/components/cost/CostEntryWizard.vue
Normal file
834
frontend/src/components/cost/CostEntryWizard.vue
Normal 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>
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">📅 {{ 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">📅 {{ 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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) ──
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
278
frontend/src/components/subscription/SubscriptionInfoModal.vue
Normal file
278
frontend/src/components/subscription/SubscriptionInfoModal.vue
Normal 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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 '—'
|
||||||
|
|||||||
@@ -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>💸</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>🛡</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>📊</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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
149
frontend/src/stores/expense.ts
Normal file
149
frontend/src/stores/expense.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
566
frontend/src/views/costs/CostsView.vue
Normal file
566
frontend/src/views/costs/CostsView.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
264
plans/logic_spec_vehicle_registration_documents.md
Normal file
264
plans/logic_spec_vehicle_registration_documents.md
Normal 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
|
||||||
148
plans/p0_subscription_profile_menu_modal_plan.md
Normal file
148
plans/p0_subscription_profile_menu_modal_plan.md
Normal 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
|
||||||
275
plans/p0_vehicle_card_fix_plan.md
Normal file
275
plans/p0_vehicle_card_fix_plan.md
Normal 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.
|
||||||
Reference in New Issue
Block a user