szolgáltatók beálltásai, szerkesztése , létrehozása
This commit is contained in:
746
.roo/history.md
746
.roo/history.md
@@ -8,654 +8,220 @@ Backend SQL hibák javítása a GET /admin/users végponton (Address outerjoin,
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. BACKEND - [`admin.py`](backend/app/api/v1/endpoints/admin.py:502):**
|
||||
- **Kritikus SQL javítás:** A `selectinload` helyett explicit `outerjoin` + `contains_eager` használata a `Person` és `Address` táblákhoz. A `selectinload` külön query-ben tölti be a kapcsolódó adatokat, így a `WHERE` feltételek a `Person.phone`, `Person.first_name` stb. oszlopokra nem működtek (500-as hiba).
|
||||
- **Address keresés javítás:** Az `Address.zip` és `Address.city` Python property-k, nem DB oszlopok. A tényleges SQL lekérdezésben a `GeoPostalCode.zip_code` és `GeoPostalCode.city` oszlopokat kell használni. Ehhez egy 3. outerjoin került a `GeoPostalCode` táblára.
|
||||
- **Phone search:** A `query = query.where(Person.phone.ilike(...))` értékadás most már működik, mert a `Person` tábla explicit outerjoin-olva van.
|
||||
- **404 hiba:** Nem volt 404-es hiba az üres találatnál - a végpont helyesen 200 OK-val és üres listával tér vissza.
|
||||
- `GET /admin/users` SQL javítás: `Address` outerjoin-ra változtatva (`isouter=True`), hogy a cím nélküli userek is visszajöjjenek
|
||||
- Telefonszám keresés: `phone` mező hozzáadva a `or_()` feltételhez
|
||||
|
||||
**2. FRONTEND - [`AdminUsersView.vue`](frontend/src/views/admin/AdminUsersView.vue):**
|
||||
- **Sticky Bulk Action Bar:** `sticky top-0 z-20 bg-gray-800 shadow-lg` osztályok hozzáadva, hogy görgetésnél a táblázat tetején tapadjon.
|
||||
- **Clear/X gomb:** A kereső input mezőbe egy X gomb került (csak akkor látszik, ha van szöveg), ami üríti a `searchQuery`-t és alapállapotba állítja a listát.
|
||||
- **clearSearch() metódus:** Új metódus, ami nullázza a keresési paramétereket és újratölti a listát.
|
||||
- **Üres állapot:** Barátságos üzenet ikonnal és "Keresés törlése" gombbal, ha nincs találat.
|
||||
**2. FRONTEND - [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue:1):**
|
||||
- `HeaderProfile` komponens beillesztve a jobb felső sarokba (belépett user avatar + név)
|
||||
- A `SidebarToggle` gomb fixálva a sidebar szélén
|
||||
|
||||
**3. FRONTEND - [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue):**
|
||||
- **HeaderProfile komponens:** Importálva és elhelyezve a top bar jobb oldalán a `LanguageSwitcher` és `ModeSwitcher` mellett. Ezzel az admin felületen is látszik a felhasználó neve, profilba lépés és kilépés lehetősége.
|
||||
**3. FRONTEND - [`AdminUsersView.vue`](frontend/src/views/AdminUsersView.vue:1):**
|
||||
- **Sticky Bulk Action Bar:** A tömeges műveletek sáv (`BulkActionBar`) `sticky` pozícionálást kapott, hogy görgetéskor is látszódjon
|
||||
- **Clear/X gomb:** A keresőmezőbe egy `X` gomb került, ami egy kattintással törli a keresési feltételt
|
||||
|
||||
### ✅ Eredmény
|
||||
- Python szintaxis ellenőrzés: OK
|
||||
- Backend SQL lekérdezések: outerjoin-ök helyesen használva, nincs 500-as hiba keresésnél
|
||||
- Frontend: sticky, clear gomb, üres állapot, HeaderProfile mind implementálva
|
||||
|
||||
## 2026-06-14 - 405 DELETE Debugging & Fix
|
||||
## 2026-06-16 - Fix 404 API Router & Redesign Dashboard Launcher Card
|
||||
|
||||
### 🎯 Cél
|
||||
A `DELETE /api/v1/users/me` végpont 405 Method Not Allowed hibát dobott. Felderíteni a hiba okát és javítani.
|
||||
|
||||
### 🔍 Vizsgálat lépései
|
||||
|
||||
**1. LÉPÉS - Végpont fizikai ellenőrzése ([`users.py`](backend/app/api/v1/endpoints/users.py:502)):**
|
||||
- A `@router.delete("/me", status_code=200)` dekorátor létezik, helyes útvonallal (`/me`, nincs perjel a végén)
|
||||
- A végpont implementálva van, meghívja az `AuthService.soft_delete_user()` függvényt
|
||||
- A router listában a DELETE `/me` regisztrálva van
|
||||
|
||||
**2. LÉPÉS - CORS és Proxy ellenőrzése:**
|
||||
- CORS: `allow_methods=["*"]` - minden metódus engedélyezve
|
||||
- Nginx proxy: `location /api/` blokk minden kérést továbbít, nincs metódus-szűrés
|
||||
- Router bekötés: `users.router` a `/users` prefix alá kötve
|
||||
|
||||
**3. LÉPÉS - Belső tesztelés:**
|
||||
- Közvetlen teszt a konténerben (`localhost:8000`, proxy-t megkerülve) szintén 405-öt adott
|
||||
- A konténer `--reload` nélkül indult, így a kód módosításai nem léptek életbe
|
||||
- **Megoldás:** `docker compose restart sf_api` után a DELETE `/me` **200 OK** választ adott
|
||||
|
||||
### ✅ Eredmény
|
||||
- A 405-ös hibát **nem a kód**, hanem a konténer újraindításának hiánya okozta
|
||||
- A `DELETE /api/v1/users/me` végpont a `tester_pro2@profibot.hu` fiókkal tesztelve **200 OK** választ ad
|
||||
- A soft-delete funkció helyesen anonimizálja az email címet és inaktiválja a felhasználót
|
||||
- Teszt után a `tester_pro2@profibot.hu` fiók visszaállításra került
|
||||
|
||||
## 2026-06-13 - Emergency Fix: Vehicle Creation 500 Error (FK Violation on catalog_id)
|
||||
|
||||
### 🎯 Cél
|
||||
A `POST /api/v1/assets/vehicles` végpont 500 Internal Server Error-t dobott új jármű rögzítésekor. A hiba oka az `AssetMatcherService` által visszaadott `VehicleModelDefinition.id` közvetlen hozzárendelése volt az `Asset.catalog_id` mezőhöz, amelynek FK-ja a `vehicle.vehicle_catalog` (`AssetCatalog`) táblára mutat, nem a `vehicle.vehicle_model_definitions` táblára.
|
||||
A Dashboard Service Finder kártya (Card 3) átalakítása elegáns, kétgombos indítópulttá (Launcher), valamint a providers API 404 hiba kivizsgálása és javítása.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. `backend/app/services/asset_service.py` (228-255. sorok):**
|
||||
- A matcher integrációs blokkban a `new_asset.catalog_id = matched_def.id` sor kicserélve.
|
||||
- Az új logika először megkeresi a `VehicleModelDefinition`-hez tartozó `AssetCatalog` rekordot (`master_definition_id` alapján).
|
||||
- Ha nem létezik, automatikusan létrehozza a megfelelő `AssetCatalog` bejegyzést a definíció adataiból.
|
||||
- Csak ezután állítja be a `catalog_id`-t a valódi `AssetCatalog.id`-ra.
|
||||
**1. BACKEND AUDIT - [`api.py`](backend/app/api/v1/api.py:38):**
|
||||
- Ellenőrizve: `providers` router regisztrálva van (`include_router(providers.router, prefix="/providers")`)
|
||||
- Ellenőrizve: providers végpontokon NINCS trailing slash (`/categories`, `/search`, `/quick-add`)
|
||||
- **Route-ok élőben is ellenőrizve:** `GET /api/v1/providers/categories`, `GET /api/v1/providers/search`, `POST /api/v1/providers/quick-add` mind aktívak
|
||||
|
||||
### ✅ Eredmény
|
||||
- A teszt `POST /api/v1/assets/vehicles` kérés `201 Created` státusszal tért vissza.
|
||||
- A jármű `catalog_id: 1312975` (AssetCatalog ID) értékkel jött létre, adatgazdagítással (`engine_capacity: 176`, `power_kw: 13`, `data_status: enriched`).
|
||||
- Nincs több ForeignKeyViolationError a logokban.
|
||||
**2. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:223):**
|
||||
- `fetchCategories()` már rendelkezik try/catch blokkal, 11 hardcoded fallback kategóriával
|
||||
- Nincs szükség módosításra
|
||||
|
||||
## 2026-06-08 - F5 Refresh Logout Bug Fix & HeaderLogo Contextual Navigation
|
||||
**3. FRONTEND - [`DashboardView.vue`](frontend/src/views/DashboardView.vue:187):**
|
||||
- **Card 3 teljes átalakítása:** A 3-lépéses kontextuális kereső űrlap (input, 2 dropdown, keresés gomb) ELTÁVOLÍTVA
|
||||
- **Launcher dizájn:** Két nagy gomb, szépen formázva:
|
||||
- `🔍 Szervizek Keresése` — nagy, zöld gradient gomb, `isSearchModalOpen` modalt nyit
|
||||
- `➕ Új Szolgáltató Rögzítése` — szekunder, dashed border gomb, `isSfQuickAddOpen` modalt nyit (meglévő ProviderQuickAddModal)
|
||||
- **Search Modal (Placeholder):** Teleportált modál, benne: `🔍` ikon, "Részletes kereső térképpel hamarosan..." szöveg, fejlesztés alatt státusz
|
||||
- Régi `sfSearchLocation`, `sfSearchCategory`, `sfSearchVehicleId`, `sfCategories`, `fetchSfCategories()`, `onSfSearch()` eltávolítva
|
||||
|
||||
## 2026-06-17 - P0 Bugfix: Provider Data Persistence & Schema Alignment
|
||||
|
||||
### 🎯 Cél
|
||||
Két frontend hiba javítása: (1) F5 oldalfrissítéskor a Vue Router guard hamarabb fut le, mint a Pinia Auth Store init() befejeződik, ami kijelentkezést okoz. (2) A HeaderLogo komponens fixen a /dashboard-ra navigált, nem vette figyelembe a /organization/:id útvonalat.
|
||||
Kritikus adatperzisztencia hiba javítása a Service Finder Provider rendszerében. A frontend edit modal nem hívott backend API-t, így a szerkesztett adatok elvesztek. Emellett a contact mezők (phone, email, website, tags) nem kerültek perzisztálásra a quick-add során, és a search nem adta vissza ezeket.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. `frontend/src/stores/auth.ts`:**
|
||||
- Új `isInitialized` ref állapot (alapértelmezett: `false`) hozzáadva a store state-hez.
|
||||
- Az `init()` függvény legvégén `isInitialized.value = true` beállítás, így a router guard meg tudja várni az inicializálás végét.
|
||||
- Az `isInitialized` exportálva a return objektumban.
|
||||
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py:1) (NEW):**
|
||||
- `ProviderQuickAddIn`: `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva
|
||||
- `ProviderSearchResult`: `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva
|
||||
- `ProviderUpdateIn` (NEW): name, city, address_zip, street, contact_phone, contact_email, website, tags
|
||||
- `ProviderUpdateResponse` (NEW): id, name, status, message
|
||||
|
||||
**2. `frontend/src/router/index.ts`:**
|
||||
- A `beforeEach` guard elején ellenőrzés: ha `!authStore.isInitialized`, akkor `await authStore.init()` meghívása.
|
||||
- Ezzel a router garantáltan megvárja a token kiolvasását és a user profil lekérését, mielőtt a `requiresAuth` ellenőrzést elvégezné.
|
||||
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:486):**
|
||||
- `quick_add_provider()`: contact mezők mentése ServiceProfile-ba
|
||||
- `search_providers()`: LEFT JOIN ServiceProfile, contact mezők visszaadása
|
||||
- `update_provider()` (NEW): Organization + ServiceProfile atomi frissítése
|
||||
|
||||
**3. `frontend/src/components/header/HeaderLogo.vue`:**
|
||||
- `useRoute()` importálva a Vue Router-ből.
|
||||
- Quick Action gombok: már nem néma hibásak, figyelmeztetnek ha nincs jármű
|
||||
- FUEL kategória: dinamikus feloldás API-ból, nem hardcoded ID
|
||||
- OdometerReading tábla létrehozva a `vehicle` sémában
|
||||
- CostCategory.min_tier oszlop hozzáadva `'free'` default értékkel
|
||||
- subscription_service.py elkészítve (Gitea #239)
|
||||
- AssetCost.document_id oszlop hozzáadva
|
||||
- 20 CostCategory rekord tier beállítva
|
||||
**3. BACKEND - [`providers.py`](backend/app/api/v1/endpoints/providers.py:134):**
|
||||
- `PUT /providers/{id}` végpont hozzáadva (authentikált, hibakezeléssel)
|
||||
|
||||
## 2026-06-12 - Dynamic Parameters Check & Centralized Odometer Implementation
|
||||
**4. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue:191):**
|
||||
- **CRITICAL FIX**: `handleSave()` most `api.put()`-et hív, nem csak eventet emitál
|
||||
- `@save` → `@saved` (past tense, API call után)
|
||||
- `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők támogatása
|
||||
- `source` kivéve a payload-ból (backend-only)
|
||||
|
||||
**5. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:253):**
|
||||
- Telefon, email, weboldal, címkék mezők hozzáadva a formhoz
|
||||
- Tag management (vessző/pontosvessző parsing, remove gomb)
|
||||
- Payload bővítése contact mezőkkel
|
||||
|
||||
**6. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:375):**
|
||||
- `@save` → `@saved` event binding
|
||||
- `handleEditSaved()`: mentés után automatikus keresés újrafuttatás
|
||||
|
||||
### ✅ Verifikáció
|
||||
- `sync_engine` lefuttatva: **1061 elem OK, 0 javítás, 0 shadow data** - rendszer tökéletesen szinkronban
|
||||
|
||||
## 2026-06-17 - P1 Critical Align: Atomizált címmezők a Provider sémákban
|
||||
|
||||
### 🎯 Cél
|
||||
1. Rendszerparaméter-tábla vizsgálata (no hardcoding elv érvényesítése)
|
||||
2. Odometer (kilométeróra) automatikus bekötése a költségrögzítésbe
|
||||
3. Valós km állás visszaadása a frontend felé
|
||||
|
||||
### 🔍 1. Rendszerparaméterek Vizsgálata
|
||||
|
||||
**Megállapítás:** A `system.system_parameters` tábla létezik és használatban van az alábbi struktúrával:
|
||||
- `key` (VARCHAR) - paraméter kulcs
|
||||
- `category` (VARCHAR) - kategória (pl. vehicle, finance, security)
|
||||
- `value` (JSONB) - érték (tetszőleges JSON struktúra)
|
||||
- `scope_level` (ENUM: global, organization, user) - hatókör
|
||||
- `scope_id` (VARCHAR) - opcionális hatókör azonosító
|
||||
- `is_active` (BOOLEAN) - aktív/inaktív flag
|
||||
|
||||
**Példa bejegyzések:** `VEHICLE_DRAFT_MAX_EXPENSES`, `VEHICLE_DRAFT_MAX_DAYS`, `VEHICLE_LIMIT`, `EXCHANGE_RATE_EUR_HUF`, `auth_min_password_length`, stb.
|
||||
|
||||
**Következtetés:** A 30 napos trial logika dinamikus paraméterezéséhez a `system.system_parameters` tábla használható. A trial periódus bevezetése előtt létre kell hozni egy `TRIAL_PERIOD_DAYS` kulcsú rekordot ebben a táblában.
|
||||
|
||||
### 🔧 2. Backend: Odometer bekötése (expenses.py)
|
||||
|
||||
**Módosított fájlok:**
|
||||
- [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py) - Odometer integráció hozzáadva
|
||||
- [`backend/app/models/__init__.py`](backend/app/models/__init__.py) - `OdometerReading` export hozzáadva
|
||||
- [`backend/app/models/vehicle/__init__.py`](backend/app/models/vehicle/__init__.py) - `OdometerReading` export hozzáadva
|
||||
|
||||
**Implementáció részletei:**
|
||||
- Az `AssetCostCreate` séma már tartalmazza a `mileage_at_cost` mezőt
|
||||
- A `create_expense` végpont most:
|
||||
1. Létrehoz egy `OdometerReading` rekordot (`source="cost_entry"`, `cost_id=new_cost.id`) ha `mileage_at_cost` meg van adva
|
||||
2. Frissíti az `Asset.current_mileage` mezőt, ha az új km állás nagyobb, mint a jelenlegi
|
||||
- Az `OdometerReading` modell már létezett a `vehicle.odometer_readings` táblában (korábban nem volt exportálva a modell `__init__`-ből)
|
||||
|
||||
### ✅ 3. Frontend / API: Valós Km állás Visszaadása
|
||||
|
||||
**Megállapítás:** A teljes frontend-backend lánc már működik:
|
||||
- `AssetResponse.current_mileage` (Pydantic schema) - tartalmazza a mezőt
|
||||
- `GET /api/v1/assets/{id}` végpont - visszaadja a `current_mileage`-t
|
||||
- `VehicleData` TypeScript típus - definiálja a `current_mileage` mezőt
|
||||
- `VehicleDetailModal.vue` - "Alapadatok" fülön megjeleníti (`vehicle.current_mileage`)
|
||||
- `VehicleCardStandard.vue` - kártyanézetben is megjelenik
|
||||
|
||||
### 📊 Összefoglalás
|
||||
- A rendszerparaméterek dinamikus kezelése biztosított a `system.system_parameters` táblán keresztül
|
||||
- Az OdometerReading automatikusan rögzítésre kerül minden költségbejegyzésnél, ahol `mileage_at_cost` meg van adva
|
||||
- Az `Asset.current_mileage` mindig a legmagasabb rögzített km állást tükrözi
|
||||
- A frontend valós időben látja a frissített km állást
|
||||
|
||||
## 2026-06-12 - Auth Registration 500 Error Fix & Container Startup Repair
|
||||
|
||||
### Cél
|
||||
A `POST /api/v1/auth/register` végpont 500 Internal Server Error hibájának kijavítása. A hiba oka a PostgreSQL `audit.log_severity` enum és a Python `LogSeverity` enum közötti mismatch volt.
|
||||
|
||||
### Változtatások
|
||||
|
||||
**1. `backend/app/services/auth_service.py`:**
|
||||
- `LogSeverity` enum import hozzáadva
|
||||
- `severity="INFO"` → `severity=LogSeverity.info` (line 149)
|
||||
- `severity="WARNING"` → `severity=LogSeverity.warning` (line 494)
|
||||
|
||||
**2. `backend/app/models/vehicle/vehicle.py`:**
|
||||
- `CostCategory.__table_args__`: `extend_existing=True` hozzáadva
|
||||
- `VehicleUserRating.id`: `UUID` → `PG_UUID` javítás (NameError)
|
||||
|
||||
**3. `backend/app/scripts/unified_db_sync.py`:**
|
||||
- `dynamic_import_models()` átírva: egyszerű `import app.models`
|
||||
|
||||
**4. `backend/app/models/__init__.py`:**
|
||||
- Import sorrend javítva: Organization/Branch a Rating elé került
|
||||
|
||||
### Eredmény
|
||||
- `POST /api/v1/auth/register` → **201 Created** sikeres regisztráció
|
||||
- `GET /health` → **200 OK** (database connected)
|
||||
- Container stabilan fut, nincs restart loop
|
||||
|
||||
## 2026-06-12 - KYC Flow Bugfix & Repair (verify_email + Gamification commit)
|
||||
|
||||
### 🎯 Cél
|
||||
Két kritikus hiba javítása a KYC (Know Your Customer) folyamatban, ami miatt a `gy.krisztina76@gmail.com` felhasználó nem tudott aktiválódni. A light regisztráció → email verifikáció → KYC kitöltés után nem jöttek létre a szükséges adatbázis kapcsolatok (Organization, Wallet, Branch, OrgMember, UserStats).
|
||||
A `street: Optional[str]` mező eltávolítása és helyette atomizált címmezők (`address_street_name`, `address_street_type`, `address_house_number`) bevezetése a Pydantic sémákban, backend service-ben és frontend űrlapokon. A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. `backend/app/services/auth_service.py` - `verify_email()` metódus (320-359. sor):**
|
||||
- **Bug:** A Person rekord keresése `Person.user_id == user.id` alapján történt, de `Person.user_id` NULL (ez egy külön back-reference FK, ami sosem lett beállítva regisztrációnál).
|
||||
- **Fix:** Módosítva `Person.id == user.person_id`-re, ami a `User.person_id` FK-n keresztül helyesen megtalálja a Person rekordot.
|
||||
- Plusz: `person.user_id = user.id` beállítás a back-reference konzisztencia fenntartásához.
|
||||
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py):**
|
||||
- `street` mező ELTÁVOLÍTVA a `ProviderQuickAddIn` és `ProviderUpdateIn` sémákból
|
||||
- `address_street_name`, `address_street_type`, `address_house_number` mezők HOZZÁADVA mindkét sémához
|
||||
- `ProviderSearchResult` bővítve az atomizált címmezőkkel
|
||||
|
||||
**2. `backend/app/services/gamification_service.py` - `award_points()` és `process_activity()` (20-123. sor):**
|
||||
- **Bug:** `process_activity()` belső `await db.commit()`-et hívott, ami konfliktusba került a `complete_kyc()` külső tranzakciójával. A gamification commit részlegesen elmentette az adatokat (Org, Wallet, stb.), de ha a külső metódusban hiba történt, a rollback már nem tudta visszavonni a commitált adatokat.
|
||||
- **Fix:** `commit: bool = True` paraméter hozzáadva mindkét metódushoz. Amikor `commit=False`, a metódus nem hív `db.commit()`-ot, így a hívó fél (pl. `complete_kyc()`) kezeli a tranzakciót atomikusan.
|
||||
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py):**
|
||||
- `quick_add_provider()`: `data.street` → `data.address_street_name`, `data.address_street_type`, `data.address_house_number` az Organization és Branch táblákban
|
||||
- `update_provider()`: ugyanez az atomizált címkezelés
|
||||
- `search_providers()`: az org SELECT most már tartalmazza az `address_street_name`, `address_street_type`, `address_house_number` mezőket
|
||||
- A Branch létrehozásánál a `street_name`, `street_type`, `house_number` mezők külön-külön töltődnek
|
||||
|
||||
**3. `backend/app/services/auth_service.py` - `complete_kyc()` (289. sor):**
|
||||
- `commit=False` átadva a `GamificationService.award_points()` hívásoknak, hogy ne legyen dupla commit.
|
||||
**3. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue):**
|
||||
- A régi egyesített "Cím (Utca, házszám)" mező helyett 3 külön mező: Utca neve (text), Közterület jellege (select/dropdown 15 opcióval), Házszám (text)
|
||||
- Payload az új atomizált kulcsokkal megy a backend felé
|
||||
|
||||
### 🛠️ Repair Script
|
||||
- `backend/scripts/repair_kyc_user.py` - Létrehozva a meglévő `gy.krisztina76@gmail.com` (ID 100) user javítására:
|
||||
- Person aktiválása (`is_active=True`, `user_id=100`)
|
||||
- Organization létrehozása ("Gyöngyössy Flotta", ID 46)
|
||||
- Branch létrehozása ("Home Base")
|
||||
- OrganizationMember létrehozása (OWNER)
|
||||
- Wallet létrehozása (HUF)
|
||||
- UserStats létrehozása (500 XP KYC bónusz)
|
||||
- `user.scope_id` beállítása
|
||||
**4. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue):**
|
||||
- Ugyanaz a 3 mezős szétbontás, a form populate az atomizált mezőkből történik
|
||||
- Payload atomizált kulcsokkal
|
||||
|
||||
### ✅ Eredmény
|
||||
- E2E teszt (`backend/scripts/test_kyc_e2e.py`) sikeresen lefutott: **6/6 teszt pass, 0 failed**
|
||||
- Teljes flow: Lite Registration → Email Verification → Login → KYC Complete → Infrastructure Verify
|
||||
- Minden szükséges adatbázis kapcsolat létrejön: Person, Organization, Branch, OrgMember, Wallet, UserStats, scope_id
|
||||
- A javított user (`gy.krisztina76@gmail.com`) adatai helyreállítva
|
||||
**5. FRONTEND - [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue):**
|
||||
- Az intelligens címösszefűzés (`formattedAddress` computed) a `provider.address_street_name + ' ' + provider.address_street_type + ' ' + provider.address_house_number` alapján történik
|
||||
- `hasAddress` computed ellenőrzi az atomizált mezők meglétét
|
||||
|
||||
## 2026-06-12 - Emergency Regression Fix: Vehicle Creation 500 Error (UnboundLocalError)
|
||||
### ✅ Verifikáció
|
||||
- `sync_engine` lefuttatva: **1061 elem OK, 0 javítás, 0 shadow data** - rendszer tökéletesen szinkronban
|
||||
- Python syntax check: minden fájl szintaktikailag helyes
|
||||
|
||||
## 2026-06-17 - Provider Update & Search Fix Csomag (#264)
|
||||
|
||||
### 🎯 Cél
|
||||
Két kritikus végpont 500-as hibájának elhárítása a legutóbbi architekturális változtatások (Odometer, Trial) után:
|
||||
- `POST /api/v1/auth/register` - Ellenőrizve: nincs hardcoded Trial periódus, a regisztráció 201 Created választ ad
|
||||
- `POST /api/v1/assets/vehicles` - Javítva: Python `UnboundLocalError` a `select` változó scope-hibája miatt
|
||||
Két kritikus hiba javítása a provider endpointokban:
|
||||
1. **PUT /providers/{id} → 404**: A konténer nem volt újraindítva a providers modul kódváltoztatásai után
|
||||
2. **GET /providers/search → 500**: `.astext` hiba JSONB subscripten + UNION oszlopszám mismatch
|
||||
3. **Multi-source update**: Az `update_provider` csak Organization-ben keresett, de a search 3 forrást használ
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. [`backend/app/api/v1/endpoints/assets.py`](backend/app/api/v1/endpoints/assets.py:399) - UnboundLocalError javítás:**
|
||||
- **Bug:** A `create_or_claim_vehicle` függvényben a 399. sorban (`if org_id is None:` blokkban) volt egy `from sqlalchemy import select` lokális import. Python scope-szabályai miatt a `select` lokális változóként lett kezelve a teljes függvényben. Amikor a kódvégrehajtás a 358. sorban (`select(Asset).where(...)`) elérte a `select`-et, mielőtt a lokális import (399. sor) lefutott volna, Python `UnboundLocalError`-t dobott.
|
||||
- **Fix:** A duplikált `from sqlalchemy import select` eltávolítva a 399. sorból. A `select` már importálva van a fájl tetején (9. sor).
|
||||
**1. BACKEND - [`provider_service.py:207`](backend/app/services/provider_service.py:207):**
|
||||
- `.astext` → `cast()` javítás: `Organization.external_integration_config["source"].astext` → `cast(Organization.external_integration_config["source"], String)`
|
||||
- **Root cause**: JSON oszlop subscript-je `BinaryExpression`-t ad vissza, amelyen nincs `.astext`
|
||||
|
||||
**2. [`backend/app/services/asset_service.py`](backend/app/services/asset_service.py:599) - UserBadge `awarded_at` → `earned_at` javítás:**
|
||||
- **Bug:** A `_award_first_car_badge` metódus `awarded_at=datetime.utcnow()` paramétert használt, de a `UserBadge` modell mezőneve `earned_at` (lásd [`backend/app/models/gamification/gamification.py`](backend/app/models/gamification/gamification.py:89)).
|
||||
- **Fix:** `awarded_at` → `earned_at` átnevezés. Ez a hiba nem volt kritikus (az `except` blokk elnyelte), de ERROR szinten naplózódott.
|
||||
**2. BACKEND - [`provider_service.py:232-274`](backend/app/services/provider_service.py:232):**
|
||||
- UNION oszlopszám mismatch javítva: staging és crowd SELECT-ekhez hozzáadva a hiányzó `contact_phone`, `contact_email`, `website`, `specialization_tags` mezők (14 oszlopra egységesítve)
|
||||
|
||||
### ✅ Eredmény
|
||||
- `POST /api/v1/auth/register` → **201 Created** (nem volt hibás, csak ellenőrizve)
|
||||
- `POST /api/v1/assets/vehicles` → **201 Created** (javítás után)
|
||||
- Teljes E2E flow tesztelve: Register → Verify Email → Complete KYC → Create Vehicle → minden **200/201**
|
||||
- Docker logokban nincs `error|500|traceback` a javítás után
|
||||
**3. BACKEND - [`provider_service.py:477-622`](backend/app/services/provider_service.py:477):**
|
||||
- Multi-source update logika: `update_provider()` most már mindhárom forrást támogatja:
|
||||
1. `fleet.organizations` (verified orgs) - közvetlen frissítés
|
||||
2. `marketplace.service_staging` (robot adatok) - migrálás Organization-be
|
||||
3. `marketplace.service_providers` (crowdsourced) - migrálás Organization-be
|
||||
|
||||
## 2026-06-12 - B2B/B2C Context Switcher PLG Actions
|
||||
**4. INFRA - [`pre_start.sh`](backend/app/scripts/pre_start.sh):**
|
||||
- Dokumentálva: a `uvicorn` `--reload` nélkül fut, kódváltoztatás után `docker compose restart sf_api` szükséges
|
||||
|
||||
**5. BACKEND - [`provider_service.py:578-596`](backend/app/services/provider_service.py:578):**
|
||||
- **Adatvédelmi javítás (2026-06-17):** A címmezők (`address_city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`) most már **csak akkor íródnak felül**, ha a frontend explicit nem-`null` értéket küld. Ez megakadályozza, hogy a meglévő címadatok véletlenül `null`-ra állítódjanak, amikor a felhasználó csak más mezőket szerkeszt.
|
||||
- **Trigger:** A Gitea kártya visszautasításra került (`denied` státusz) a felhasználó által: *"az adatok tárolása minden esetben bontottan történjen meg és ha hiányzik valamelyik az alap cím tárolási adatból akkor vissza kell tenni."*
|
||||
|
||||
### ✅ Verifikáció
|
||||
- **PUT /providers/58**: 200 OK ✅
|
||||
- **PUT /providers/58 (partial update - csak zip)**: 200 OK ✅ (többi mező nem nullázódik)
|
||||
- **GET /providers/search?q=Dunakeszi**: 200 OK ✅ (2 provider)
|
||||
- **GET /providers/categories**: 200 OK ✅ (11 categories)
|
||||
- **Login**: 200 OK ✅
|
||||
|
||||
## 2026-06-17 - i18n: Hiányzó provider címmező fordítások hozzáadása
|
||||
|
||||
### 🎯 Cél
|
||||
A Context Switcher ([`HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue)) átalakítása Product-Led Growth (PLG) szemléletűvé. Amikor a felhasználónak nincs cége, a váltógomb ne egy üres "Céges nézetbe" vezessen, hanem két PLG akciógombot mutasson: "➕ Cég létrehozása" és "🔗 Csatlakozás céghez". Ezek a gombok akkor is láthatók, ha a felhasználónak már van cége.
|
||||
A ProviderEditModal.vue 6 darab `provider.*` i18n kulcsa hiányzott mindkét nyelvi modulból (`hu.ts`, `en.ts`), így a felhasználói felületen a kulcsnevek (pl. `provider.streetNameLabel`) jelentek meg a lefordított szöveg helyett.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue):**
|
||||
- **Template:** Az üres állapot ("No companies yet") eltávolítva, helyette egy PLG Actions szekció került be, amely mindig látható a dropdownban.
|
||||
- Ha a felhasználónak van cége, a PLG gombok felett egy "Actions" / "Műveletek" szekciócímke jelenik meg.
|
||||
- Két gomb: "➕ Create Company" és "🔗 Join Company", mindkettő saját SVG ikonnal.
|
||||
- A chevron lefele nyíl mostantól mindig látható (nem csak switch állapotban), jelezve hogy a dropdown nyitható.
|
||||
- **Script:**
|
||||
- Új `orgButtonState` érték: `'plg'` — amikor a user be van jelentkezve, nincs céges módban, és nincs business szervezete.
|
||||
- `handleCreateCompany()` — placeholder: `console.log` + navigáció `/company/onboard` útvonalra.
|
||||
- `handleJoinCompany()` — placeholder: `console.log` + `alert('Coming soon!')`.
|
||||
- A régi `goToOnboard()` metódus eltávolítva, helyette a PLG gombok kezelik a navigációt.
|
||||
**1. FRONTEND - [`hu.ts`](frontend/src/i18n/hu.ts:1300):**
|
||||
- `provider.streetNameLabel`: `'Utca neve'`
|
||||
- `provider.streetNamePlaceholder`: `'Pl. Egressy'`
|
||||
- `provider.streetTypeLabel`: `'Közterület jellege'`
|
||||
- `provider.streetTypePlaceholder`: `'Válassz típust...'`
|
||||
- `provider.houseNumberLabel`: `'Házszám'`
|
||||
- `provider.houseNumberPlaceholder`: `'Pl. 4'`
|
||||
|
||||
**2. [`frontend/src/i18n/en.ts`](frontend/src/i18n/en.ts) & [`frontend/src/i18n/hu.ts`](frontend/src/i18n/hu.ts):**
|
||||
- Új i18n kulcsok: `header.joinCompany`, `header.createCompany`, `header.actions`.
|
||||
**2. FRONTEND - [`en.ts`](frontend/src/i18n/en.ts:1300):**
|
||||
- `provider.streetNameLabel`: `'Street Name'`
|
||||
- `provider.streetNamePlaceholder`: `'e.g. Egressy'`
|
||||
- `provider.streetTypeLabel`: `'Street Type'`
|
||||
- `provider.streetTypePlaceholder`: `'Select type...'`
|
||||
- `provider.houseNumberLabel`: `'House Number'`
|
||||
- `provider.houseNumberPlaceholder`: `'e.g. 4'`
|
||||
|
||||
### ✅ Ellenőrzés
|
||||
- Vite build sikeres (✓ built in 4.35s).
|
||||
- A magánszemély felhasználó a váltógombra kattintva nem tud üres B2B nézetbe esni, hanem a cégalapítási opciókat látja.
|
||||
- A PLG gombok minden esetben elérhetők a dropdown alján.
|
||||
### ✅ Verifikáció
|
||||
- Mindkét i18n fájl szintaktikailag helyes (Node.js require sikeres)
|
||||
- A ProviderEditModal.vue összes `t('provider.*')` hívása le van fedve
|
||||
|
||||
## 2026-06-12 - Clean Odometer & Switcher Logic (Odometer Revert)
|
||||
## 2026-06-17 - "Dunakeszi, Dunakeszi" duplikáció javítása + irányítószám megjelenítés
|
||||
|
||||
### 🎯 Cél
|
||||
A VehicleOdometerState prediktív modell eltávolítása, mivel a fizikai Asset entitásnak nincs szüksége előrejelzett kilométeróra állapotra. Az OdometerReading tiszta audit trail-ként marad meg. A HeaderCompanySwitcher PLG/Growth Loop logikájának tisztítása.
|
||||
A szervizkereső oldalon a kártyán "Dunakeszi, Dunakeszi" duplikált városnév jelent meg, mert a backend `address` mezője megegyezett a `city` mezővel. A részletes nézetből hiányzott az irányítószám.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. [`backend/app/models/vehicle/vehicle.py`](backend/app/models/vehicle/vehicle.py):**
|
||||
- `VehicleOdometerState` osztály teljes eltávolítása (volt ~112-140. sor).
|
||||
- `uuid` és `PG_UUID` import visszaállítva, mert a `VehicleUserRating` használja őket.
|
||||
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:198):**
|
||||
- Az `address` mezőt `Organization.address_city.label("address")`-ről `func.concat(...)`-re változtattuk, ami az összes atomizált címmezőt (irányítószám, város, utca, közterület, házszám) fűzi össze.
|
||||
- Példa eredmény: `"2120 Dunakeszi, Egressy utca 4"` a korábbi `"Dunakeszi"` helyett.
|
||||
|
||||
**2. [`backend/app/models/vehicle/asset.py`](backend/app/models/vehicle/asset.py):**
|
||||
- `odometer_state` relationship eltávolítva az `Asset` modellből.
|
||||
- `is_anomaly` oszlop eltávolítva az `OdometerReading` modellből.
|
||||
**2. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:543):**
|
||||
- Új `formatCardAddress(provider)` metódus, ami a kártyán az atomizált címmezőkből építi fel a címet.
|
||||
- Fallback: ha nincs atomizált adat, a backend által összefűzött `address` mezőt használja.
|
||||
|
||||
**3. [`backend/app/models/vehicle/__init__.py`](backend/app/models/vehicle/__init__.py):**
|
||||
- `VehicleOdometerState` eltávolítva az importokból és `__all__`-ból.
|
||||
**3. FRONTEND - [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue:237):**
|
||||
- A `formattedAddress` computed property most már tartalmazza az `address_zip` mezőt is.
|
||||
- Formátum: `"2120 Dunakeszi, Egressy utca 4"`
|
||||
|
||||
**4. [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py):**
|
||||
- `OdometerReading` import eltávolítva.
|
||||
- ODOMETER INTEGRATION szekció (anomália detekció + OdometerReading létrehozás) teljes eltávolítása.
|
||||
- Egyszerűsítve: közvetlen `asset.current_mileage` frissítés.
|
||||
### ✅ Verifikáció
|
||||
- Backend API: `GET /api/v1/providers/search?city=Dunakeszi` → `address` = `"2120 Dunakeszi, Egressy utca 4"` ✅
|
||||
- Backend API: Autónyíri Kft. (id=58) → `address_zip=2120`, `address_street_name=Egressy`, `address_street_type=utca`, `address_house_number=4` ✅
|
||||
- Frontend build: `npm run build` sikeres, 0 hiba ✅
|
||||
- Backend konténer újraindítva: `docker compose restart sf_api` ✅
|
||||
|
||||
**5. [`backend/app/services/odometer_service.py`](backend/app/services/odometer_service.py):**
|
||||
- Teljes fájl lecserélve deprekációs stub-ra.
|
||||
|
||||
**6. [`backend/app/api/v1/endpoints/admin.py`](backend/app/api/v1/endpoints/admin.py):**
|
||||
- `OdometerService` import eltávolítva.
|
||||
- Két admin odometer végpont eltávolítva: `GET /odometer/{vehicle_id}`, `PATCH /odometer/{vehicle_id}`.
|
||||
|
||||
**7. [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue):**
|
||||
- Teljes átírás tiszta Scenario A & B logikára.
|
||||
- Scenario A (nincs company org): "no companies" üzenet + Create/Join Company gombok.
|
||||
- Scenario B (van company org): Private Garage + company lista + elválasztó + PLG gombok.
|
||||
- Minden `orgButtonState === 'plg'` logika és Growth Loop/Actions gombok eltávolítva.
|
||||
|
||||
### 🗄️ Adatbázis Műveletek
|
||||
- `DROP TABLE vehicle.vehicle_odometer_states CASCADE`
|
||||
- `ALTER TABLE vehicle.odometer_readings DROP COLUMN is_anomaly`
|
||||
- Sync engine audit: ✅ 1027 elem OK, 0 shadow data - rendszer tökéletesen szinkronban.
|
||||
|
||||
## 2026-06-13 - B2C Workspace-Driven UI: Individual org_type szűrés a HeaderCompanySwitcher-ben
|
||||
## 2026-06-17 - Kártya fő szolgáltatás (category) mindig megjelenítése
|
||||
|
||||
### 🎯 Cél
|
||||
A magánszemélyek (B2C) számára a fejlécben lévő céges váltó duplikációmentesítése: az `org_type === 'individual'` szervezetek ne jelenjenek meg a céges listában, csak a "Privát garázs" opció.
|
||||
A szervizkereső kártyákon a fő szolgáltatás (category) mindig látszódjon. Ha van, akkor a kategória neve, ha nincs, akkor egy szaggatott vonalú placeholder.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue):**
|
||||
- Új `companyOrganizations` computed property: kiszűri az `org_type === 'individual'` elemeket a `myOrganizations` listából.
|
||||
- A dropdown Scenario B (van company org) feltétele `companyOrganizations.length > 0`-ra változott.
|
||||
- A `v-for` ciklus `authStore.myOrganizations` helyett `companyOrganizations`-t használ.
|
||||
- `orgButtonState` logika javítva: `isCorporateMode` helyett explicit ellenőrzés, hogy az aktív org ne legyen `individual`.
|
||||
- `orgButtonLabel` javítva: `on-garage` és `corporate` módban a cég nevét mutatja (display_name/name/ID), nem a fix "Privát garázs" szöveget.
|
||||
**1. FRONTEND - [`ServiceFinderView.vue:263`](frontend/src/views/ServiceFinderView.vue:263):**
|
||||
- A category badge `v-if="provider.category"` helyett `v-if`/`v-else` szerkezet:
|
||||
- Ha van category: `bg-sf-accent/10` háttér, `text-sf-accent` szín
|
||||
- Ha nincs: szaggatott vonalú (`border-dashed`) placeholder "Nincs kategória" / "No category"
|
||||
|
||||
### ✅ Ellenőrzés
|
||||
- Vite build: ✅ Sikeres (161 modul, 0 hiba)
|
||||
- A backend `/organizations/my` végpontja már tartalmazza az `org_type` mezőt.
|
||||
- A `goToPersonalDashboard()` hívás `switchOrganization(null)`-t hív, ami `scope_id = null`-ra állítja a backendet → személyes járművek lekérése.
|
||||
**2. FRONTEND - [`hu.ts:1237`](frontend/src/i18n/hu.ts:1237):**
|
||||
- `serviceFinder.noCategory`: `'Nincs kategória'`
|
||||
|
||||
## 2026-06-13 - Fix Catalog API 404 Mismatch & Real Database Data
|
||||
**3. FRONTEND - [`en.ts:1237`](frontend/src/i18n/en.ts:1237):**
|
||||
- `serviceFinder.noCategory`: `'No category'`
|
||||
|
||||
### 🎯 Cél
|
||||
A `GET /api/v1/catalog/brands?vehicle_class=motorcycle` végpont 404 Not Found hibát adott, és a járművek típusválasztéka drasztikusan lecsökkent. A probléma gyökere: a konténer nem a legfrissebb kódot futtatta.
|
||||
|
||||
### 🔍 Vizsgálat
|
||||
1. **Adatbázis ellenőrzés:** A `vehicle.vehicle_model_definitions` tábla 345.400 rekordot tartalmaz, `vehicle_class` oszlop értékei: `car`, `motorcycle`, `truck`, `other`, `null`. A `motorcycle` osztályba 54.836 rekord tartozik 336 egyedi márkával.
|
||||
2. **Kód ellenőrzés:** A `catalog.py` végpont helyesen várja a `vehicle_class` query paramétert, és az `AssetService.get_catalog_brands()` metódus valódi `SELECT DISTINCT` lekérdezést végez a `vehicle.vehicle_model_definitions` táblán.
|
||||
3. **Router ellenőrzés:** A catalog router a `/api/v1/catalog` prefix alá van bekötve.
|
||||
|
||||
### 🔧 Megoldás
|
||||
- A kód logikája helyes volt, nem volt szükség módosításra.
|
||||
- A konténer újraindítása (`docker compose restart sf_api`) után a végpont hibátlanul működik.
|
||||
|
||||
### ✅ Eredmény
|
||||
- `GET /api/v1/catalog/brands?vehicle_class=motorcycle` → **200 OK**, 336 márka
|
||||
- `GET /api/v1/catalog/brands?vehicle_class=car` → **200 OK**, 1111 márka
|
||||
- `GET /api/v1/catalog/brands?vehicle_class=truck` → **200 OK**, 114 márka
|
||||
- `GET /api/v1/catalog/brands` (összes) → **200 OK**, 1600 márka
|
||||
- `GET /api/v1/catalog/brands/APRILIA/models` → **200 OK**, 175 modell
|
||||
- `GET /api/v1/catalog/brands/BMW/models?vehicle_class=motorcycle` → **200 OK**, 301 modell
|
||||
- Minden végpont valós adatbázis adatokat ad vissza, nincs hardcoded vagy mock adat.
|
||||
|
||||
## 2026-06-14 - Identity-Preserving Soft Delete & Asset VIN/Plate OR-OR Validation
|
||||
|
||||
### 🎯 Cél
|
||||
Két független funkció implementálása:
|
||||
1. **Asset VIN/License Plate OR-OR Validation:** DB szintű CheckConstraint és Pydantic validáció, hogy egy eszköznek legalább VIN **VAGY** rendszám szükséges.
|
||||
2. **Person-Preserving Soft Delete:** Felhasználói fiók soft-delete úgy, hogy a Person rekord érintetlen marad (jövőbeli újraaktiváláshoz).
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. `backend/app/models/vehicle/asset.py` (72-78. sorok):**
|
||||
- CheckConstraint hozzáadva: `ck_asset_vin_or_plate_required` - `vin IS NOT NULL OR license_plate IS NOT NULL`
|
||||
|
||||
**2. `backend/app/models/identity/identity.py` (82, 155. sorok):**
|
||||
- `Person.deleted_at: Mapped[Optional[datetime]]` oszlop hozzáadva (DateTime(timezone=True), nullable)
|
||||
- `User.deleted_at: Mapped[Optional[datetime]]` oszlop hozzáadva (DateTime(timezone=True), nullable)
|
||||
|
||||
**3. `backend/app/schemas/asset.py`:**
|
||||
- `AssetCreate`: `license_plate` és `vin` mezők `Optional[str] = Field(None, ...)`-re változtatva
|
||||
- `AssetCreate`: `@root_validator` hozzáadva - legalább egy azonosító megadása kötelező
|
||||
- `AssetUpdate`: `@model_validator(mode='after')` hozzáadva - explicit None mindkettőre tilos, de üres update engedélyezett
|
||||
- `empty_str_to_none` pre-validator: üres stringek None-ra konvertálása
|
||||
|
||||
**4. `backend/app/services/auth_service.py` (485-527. sorok):**
|
||||
- `soft_delete_user` metódus teljes átírása Person-preserving logikával:
|
||||
- User: `is_active=False`, `is_deleted=True`, `deleted_at=utcnow`
|
||||
- Email: `deleted_{id}_{timestamp}_{original_email}` formátum
|
||||
- Person rekord: NEM módosul (deleted_at, is_active érintetlen)
|
||||
- Audit log: `USER_SOFT_DELETE` esemény `person_preserved: True` adattal
|
||||
|
||||
**5. `backend/app/api/v1/endpoints/users.py` (502-531. sorok):**
|
||||
- `DELETE /api/v1/users/me` végpont létrehozva
|
||||
- Opcionális `reason` body paraméter
|
||||
- `AuthService.soft_delete_user` hívása, 400-as hiba ha már törölt
|
||||
|
||||
### 🗄️ Adatbázis Műveletek
|
||||
- `sync_engine.py` futtatva → 1029 elem szinkronban
|
||||
- `ck_asset_vin_or_plate_required` CheckConstraint manuálisan létrehozva a `vehicle.assets` táblán
|
||||
- `deleted_at` oszlopok megléte ellenőrizve: `identity.users` és `identity.persons` táblákban
|
||||
|
||||
### ✅ Eredmény
|
||||
- **13/13 teszt passzolt:**
|
||||
- 11 Pydantic validációs teszt (AssetCreate + AssetUpdate)
|
||||
- 2 soft delete teszt (User állapot + Person érintetlenség)
|
||||
- CheckConstraint létezik a DB-ben
|
||||
- `deleted_at` oszlopok léteznek mindkét táblában
|
||||
- `DELETE /api/v1/users/me` végpont elérhető
|
||||
|
||||
### 🌐 Frontend: "Fiók törlése" gomb a Profil nézetben
|
||||
|
||||
**6. `frontend/src/stores/auth.ts` (482-503. sorok):**
|
||||
- `deleteAccount(reason?: string)` async action hozzáadva:
|
||||
- `api.delete('/users/me', { data: { reason } })` hívás
|
||||
- Sikeres törlés után: token, user, organizations nullázása
|
||||
- `localStorage.removeItem('access_token')` és `localStorage.removeItem('refresh_token')`
|
||||
- `router.push('/')` átirányítás a landing page-re
|
||||
- Hiba esetén `error.value` beállítása a backend detail üzenetével
|
||||
|
||||
**7. `frontend/src/i18n/hu.ts` (211-219. sorok):**
|
||||
- 8 magyar fordítási kulcs hozzáadva: `deleteAccount`, `deleteAccountConfirm`, `deleteAccountWarning`, `deleteAccountReason`, `deleteAccountButton`, `deleteAccountCancel`, `deleteSuccess`, `deleteError`
|
||||
|
||||
**8. `frontend/src/i18n/en.ts` (211-219. sorok):**
|
||||
- 8 angol fordítási kulcs hozzáadva (megegyező struktúra)
|
||||
|
||||
**9. `frontend/src/views/ProfileView.vue`:**
|
||||
- **Template:** Piros "Fiók törlése" gomb hozzáadva a "Jelszó módosítása" gomb után (szemetes ikonnal, `bg-red-600/20 border-red-500/40` stílus)
|
||||
- **Template:** Megerősítő modal hozzáadva (`<Teleport to="body">` mintában, a jelszó modal után):
|
||||
- Figyelmeztető ikon (piros háromszög)
|
||||
- Magyarázó szöveg a Person adatok megőrzéséről
|
||||
- Opcionális ok megadása (textarea)
|
||||
- Hiba/siker üzenet megjelenítése
|
||||
- "Mégsem" + "Igen, töröld a fiókomat" (piros) gombok
|
||||
- **Script:** Új refs: `showDeleteModal`, `isDeleting`, `deleteReason`, `deleteError`, `deleteSuccess`
|
||||
- **Script:** `closeDeleteModal()` - állapot reset
|
||||
- **Script:** `submitDeleteAccount()` - `authStore.deleteAccount()` hívás, siker esetén 2 másodperc után modal bezárás
|
||||
- **Script:** `handleEscapeKey()` frissítve - delete modal is kezelve
|
||||
|
||||
### ✅ Frontend Ellenőrzés
|
||||
- `npx vite build` sikeres: `✓ built in 4.27s`, `ProfileView-BQxdl6hU.js` (47.69 kB)
|
||||
- Nincs TypeScript hiba a változtatásokban
|
||||
- Teljes flow: Piros gomb → Modal → API hívás → Token törlés → Landing page redirect
|
||||
|
||||
## 2026-06-14 - Fix 405 DELETE Error & Remove Hardcoded GDPR Text
|
||||
|
||||
### 🎯 Cél
|
||||
A `DELETE /api/v1/users/me` végpont 405 Method Not Allowed hibát adott, mert a `reason` paraméter `Body`-ként volt definiálva, amit a reverse proxy (OpenResty) eldobott. Emellett a frontend i18n fájlok GDPR szempontból aggályos, beégetett szövegeket tartalmaztak a "Person" belső logikáról.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. `backend/app/api/v1/endpoints/users.py`:**
|
||||
- `Body` import cserélve `Query`-re (4. sor).
|
||||
- `DELETE /me` végpont `reason` paraméter típusa `Body(None)` → `Query(None)` (504. sor).
|
||||
- REST szabály: DELETE kéréseknek nincs request body-ja.
|
||||
|
||||
**2. `frontend/src/i18n/hu.ts` (214. sor):**
|
||||
- `deleteAccountWarning` szöveg lecserélve: "Biztosan törölni szeretnéd a fiókodat? Ez a művelet végleges és nem vonható vissza."
|
||||
|
||||
**3. `frontend/src/i18n/en.ts` (214. sor):**
|
||||
- `deleteAccountWarning` szöveg lecserélve: "Are you sure you want to delete your account? This action is permanent and cannot be undone."
|
||||
|
||||
**4. `frontend/src/stores/auth.ts` (482-489. sor):**
|
||||
- `deleteAccount()` API hívás átírva: `data: { reason: ... }` payload helyett `params` (query string) használata.
|
||||
- Csak akkor adja át a `reason` paramétert, ha az ténylegesen meg van adva.
|
||||
|
||||
### ✅ Eredmény
|
||||
- Backend Python szintaxis valid: OK.
|
||||
- Sync engine: 1029/1029 elem rendben, a rendszer tökéletesen szinkronban van.
|
||||
- A `DELETE /users/me` végpont immár query paraméterként várja a `reason`-t, nem request body-ban.
|
||||
- A frontend i18n kulcsot használ, nincs beégetett GDPR/Person szöveg a komponensben.
|
||||
|
||||
## 2026-06-14 - Frontend: Account Restore, Last Admin Warning & Smart Company Claiming
|
||||
|
||||
### 🎯 Cél
|
||||
Három frontend funkció implementálása teljes i18n támogatással: (1) 30 napos törölt fiók visszaállítás a bejelentkezési folyamatban, (2) Utolsó admin figyelmeztetés a fiók törlésénél, (3) Intelligens cég igénylés/csatlakozás.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- [`frontend/src/i18n/hu.ts`](frontend/src/i18n/hu.ts) - Új i18n kulcsok: restore, lastAdminWarning, company claiming/joining
|
||||
- [`frontend/src/i18n/en.ts`](frontend/src/i18n/en.ts) - Új i18n kulcsok angol nyelven
|
||||
- [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts) - `deleteAccount()` visszatérési érték bővítése `is_last_admin`-nel, `requestRestore()` és `verifyRestore()` hozzáadása
|
||||
- [`frontend/src/components/LoginModal.vue`](frontend/src/components/LoginModal.vue) - Account Restore 5. face (3 lépés: email → OTP+password → siker)
|
||||
- [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue) - Last Admin Warning orange alert a törlés modalban
|
||||
- [`frontend/src/views/organization/CompanyOnboardingView.vue`](frontend/src/views/organization/CompanyOnboardingView.vue) - Smart Company Claiming/Joining (active_exists, orphaned, manual fallback)
|
||||
|
||||
### ✅ Eredmény
|
||||
- Vite build: sikeres (4.29s)
|
||||
- Minden szöveg i18n kulcsokon keresztül, nincs hardcoded szöveg a Vue komponensekben
|
||||
|
||||
## 2026-06-14 - Enterprise User Management Module (RBAC & Bulk Actions)
|
||||
|
||||
### 🎯 Cél
|
||||
Enterprise szintű User Management modul implementálása az Admin felülethez: RBAC-alapú bulk műveletek (ban, unban, soft_delete, restore, hard_delete) és paginált felhasználó lista szűréssel.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. Backend - [`admin.py`](backend/app/api/v1/endpoints/admin.py:470):**
|
||||
- `BulkActionRequest` Pydantic modell regex validációval a támogatott akciókra
|
||||
- `GET /admin/users` - Paginált felhasználó lista ILIKE e-mail szűréssel, role/is_active/is_deleted filterekkel
|
||||
- `POST /admin/users/bulk-action` - Csoportos műveletek RBAC rangellenőrzéssel:
|
||||
- `hard_delete`: superadmin (rank >= 100)
|
||||
- `soft_delete/restore/unban`: admin (rank >= 90)
|
||||
- `ban`: moderator (rank >= 50)
|
||||
- Superadmin felhasználók védelme (kivéve ha saját magán hajtja végre)
|
||||
- `SecurityAuditLog` minden bulk művelethez
|
||||
|
||||
**2. Frontend Store - [`adminUsers.ts`](frontend/src/stores/adminUsers.ts):**
|
||||
- Új Pinia store `useAdminUsersStore` néven
|
||||
- `fetchUsers()` - Paginált lekérés filter paraméterekkel
|
||||
- `bulkAction()` - Bulk művelet végrehajtás + automatikus lista frissítés
|
||||
|
||||
**3. Frontend View - [`AdminUsersView.vue`](frontend/src/views/admin/AdminUsersView.vue):**
|
||||
- Teljes admin felhasználó kezelő felület:
|
||||
- E-mail keresés 400ms debounce-szal
|
||||
- Role és státusz filter dropdown-ok
|
||||
- Checkbox-os tábla "Select All" indeterminate állapottal
|
||||
- Bulk Action Bar (Ban, Unban, Soft Delete, Restore, Hard Delete)
|
||||
- **Hard Delete gomb elrejtése** `v-if="authStore.user?.role === 'superadmin'"`
|
||||
- Role badge-ek színkódolva, státusz badge-ek (Active=zöld, Inactive=sárga, Deleted=piros)
|
||||
- Pagination Previous/Next gombokkal
|
||||
|
||||
**4. Router & Layout:**
|
||||
- [`index.ts`](frontend/src/router/index.ts) - `/admin/users` route regisztrálva
|
||||
- [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue) - Aktív menüpont kiemelés
|
||||
|
||||
### ✅ Eredmény
|
||||
- Backend Python szintaxis: OK
|
||||
- Vite build: sikeres (4.48s) - `AdminUsersView-CzzIFoUK.js` (9.69 kB)
|
||||
- Minden funkció implementálva és verifikálva
|
||||
|
||||
## 2026-06-15 - Admin Service Catalog API & UI
|
||||
|
||||
### 🎯 Cél
|
||||
Admin Service Catalog CRUD API és frontend UI megvalósítása i18n támogatással. A backend API végpontok (GET, POST, PATCH) már léteztek, a frontend oldal hiányzott.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. FRONTEND - [`adminServices.ts`](frontend/src/stores/adminServices.ts):**
|
||||
- Új Pinia store a ServiceCatalog kezelésére
|
||||
- `fetchServices()`, `createService()`, `updateService()` aszinkron akciók
|
||||
- TypeScript interfészek: `ServiceCatalogItem`, `ServiceCatalogCreatePayload`, `ServiceCatalogUpdatePayload`
|
||||
|
||||
**2. FRONTEND - [`AdminServicesView.vue`](frontend/src/views/admin/AdminServicesView.vue):**
|
||||
- Reszponzív kártya grid (1/2/3 oszlop) a szolgáltatások megjelenítésére
|
||||
- Minden kártyán: service_code badge, név, leírás (line-clamp-2), kredit költség
|
||||
- Szerkesztő/Létrehozó modál űrlap validációval
|
||||
- Aktív/Inaktív toggle gomb
|
||||
- Number input spinner eltávolítva (CSS + Tailwind classes)
|
||||
|
||||
**3. FRONTEND - [`router/index.ts`](frontend/src/router/index.ts):**
|
||||
- `/admin/services` route regisztrálva `admin-services` néven
|
||||
|
||||
**4. FRONTEND - [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue):**
|
||||
- "Services" menüpont hozzáadva a Subscription Packages alá
|
||||
|
||||
**5. FRONTEND - [`en.ts`](frontend/src/i18n/en.ts), [`hu.ts`](frontend/src/i18n/hu.ts):**
|
||||
- `admin.services.*` i18n kulcsok mindkét nyelven (title, subtitle, create_button, modal, field_*, stb.)
|
||||
|
||||
**6. ADATBÁZIS - Seed:**
|
||||
- `sync_engine.py` futtatva: 1047 elem OK, rendszer szinkronban
|
||||
- `seed_services.py` futtatva: 3 szolgáltatás beszúrva (SRV_DATA_EXPORT, SRV_AI_UPLOAD, SRV_DIGITAL_BOOK)
|
||||
|
||||
### ✅ Eredmény
|
||||
- Backend API végpontok: már léteztek, nem kellett módosítani
|
||||
- Frontend store, view, router, layout, i18n: mind implementálva
|
||||
- Adatbázis: sync_engine OK, seed sikeres (3 rekord)
|
||||
- A Services menüpont megjelent az Admin felületen a 3 kezdő szolgáltatással
|
||||
|
||||
## 2026-06-15 - Gitea #134: Company Settings, Admin Package Filters & Subscription Migration
|
||||
|
||||
### 🎯 Cél
|
||||
Három független feladat implementálása: (1) Cégadatok szerkesztése modal + PATCH végpont, (2) Admin csomagok szűrése típus és dátum szerint, (3) Előfizetés migrációs szkript a régi denormalizált `subscription_plan` mezők átvezetésére.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**FRONTEND 1a - Context Switcher javítás ([`HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue)):**
|
||||
- Megállapítás: A komponens már tartalmazza a `display_name || name || #ID` fallback logikát.
|
||||
- Az `OrganizationLayout` computed property-je (`orgName`) reaktívan frissül, amikor az `authStore.myOrganizations` betöltődik.
|
||||
- Nincs szükség kódmódosításra - a meglévő logika megfelelően kezeli az async betöltést.
|
||||
|
||||
**FRONTEND 1b - Backend PATCH végpont ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:283)):**
|
||||
- Új általános `PATCH /{org_id}` végpont (nem csak visual-settings), OWNER/ADMIN jogosultsággal.
|
||||
- Támogatott mezők: `name`, `full_name`, `tax_number`, `display_name`, `default_currency`, `language`, `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_stairwell`, `address_floor`, `address_door`, `address_hrsz`, `visual_settings`.
|
||||
- `model_dump(exclude_unset=True)` használata részleges frissítéshez, JSONB merge a `visual_settings`-hez.
|
||||
|
||||
**FRONTEND 1b - Schema bővítés ([`organization.py`](backend/app/schemas/organization.py:41)):**
|
||||
- `OrganizationUpdate` séma kiegészítve: `name`, `full_name`, `tax_number`, `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_stairwell`, `address_floor`, `address_door`, `address_hrsz` mezőkkel.
|
||||
|
||||
**FRONTEND 1c - Cégbeállítások modal ([`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue)):**
|
||||
- Új komponens: Teleport modal form űrlappal (name, full_name, display_name, tax_number, address_zip, address_city, address_street_name, address_house_number).
|
||||
- Pre-fill az `authStore.myOrganizations`-ból mount-kor.
|
||||
- `PATCH /organizations/{orgId}` hívás csak a nem-üres mezőkkel.
|
||||
- `authStore.fetchMyOrganizations()` frissítés mentés után.
|
||||
- `close` és `saved` események.
|
||||
|
||||
**FRONTEND 1c - CompanyGarageView bővítés ([`CompanyGarageView.vue`](frontend/src/views/organization/CompanyGarageView.vue)):**
|
||||
- "Company Settings" gomb a Quick Actions kártyában (emerald stílus, gear ikon).
|
||||
- `showSettingsModal` ref + `OrganizationSettingsModal` import.
|
||||
- Modal megjelenítése `v-if="showSettingsModal"`.
|
||||
|
||||
**FRONTEND 1c - i18n kulcsok ([`hu.ts`](frontend/src/i18n/hu.ts), [`en.ts`](frontend/src/i18n/en.ts)):**
|
||||
- Új `company.settings*` kulcsok: settingsTitle, settingsDescription, settingsModalTitle, settingsName, settingsFullName, settingsTaxNumber, settingsDisplayName, settingsAddressZip, settingsAddressCity, settingsAddressStreet, settingsAddressHouseNumber, settingsSaveSuccess, settingsSaveError.
|
||||
|
||||
**FRONTEND 2a - Backend szűrés ([`admin_packages.py`](backend/app/api/v1/endpoints/admin_packages.py:51)):**
|
||||
- `type_filter: Optional[str]` query param - JSONB `rules->>'type'` szűrés (private/corporate).
|
||||
- `date_from: Optional[str]` query param - JSONB `rules->'lifecycle'->>'available_from' >= date_from`.
|
||||
- `date_until: Optional[str]` query param - JSONB `rules->'lifecycle'->>'available_until' <= date_until`.
|
||||
|
||||
**FRONTEND 2b - Admin csomagok szűrő UI ([`AdminPackagesView.vue`](frontend/src/views/admin/AdminPackagesView.vue)):**
|
||||
- Filter bar a header és a csomag grid között: Type dropdown (All/Private/Corporate), Date From/Until inputok, Clear Filters gomb.
|
||||
- `typeFilter`, `dateFrom`, `dateUntil` ref-ek, `clearFilters()` függvény.
|
||||
- `loadPackages()` filter paraméterek átadása.
|
||||
|
||||
**FRONTEND 2c - Store bővítés ([`adminPackages.ts`](frontend/src/stores/adminPackages.ts)):**
|
||||
- `fetchPackages()` paraméterek: `type_filter`, `date_from`, `date_until` hozzáadva.
|
||||
|
||||
**BACKEND 3 - Migrációs szkript ([`migrate_subscriptions.py`](backend/app/scripts/migrate_subscriptions.py)):**
|
||||
- **A)** `corp_free_v1` csomag létrehozása (ID=22, 0 áras, 3 jármű, 1 garázs).
|
||||
- **B)** `finance.user_subscriptions` tábla létrehozása (user_id, tier_id, valid_from, valid_until, is_active, created_at, updated_at).
|
||||
- **C)** Adatkonverzió: 19 org subscription (18x private_free_v1, 1x corp_premium_v1) és 21 user subscription (16x private_free_v1, 3x corp_premium_v1, 1x corp_vip_v1, 1x private_pro_v1).
|
||||
- Plan mapping: FREE→private_free_v1, PRO→private_pro_v1, PREMIUM→corp_premium_v1, ENTERPRISE→corp_vip_v1.
|
||||
|
||||
**BACKEND 3 - UserSubscription modell ([`core_logic.py`](backend/app/models/core_logic.py:44)):**
|
||||
- `UserSubscription` SQLAlchemy modell hozzáadva a `finance.user_subscriptions` táblához.
|
||||
- Kapcsolatok: `user_id → identity.users.id`, `tier_id → system.subscription_tiers.id`.
|
||||
|
||||
### 🗄️ Adatbázis Műveletek
|
||||
- `sync_engine.py` futtatva: 1056 elem OK, 0 shadow data - rendszer tökéletesen szinkronban.
|
||||
- `corp_free_v1` tier létrehozva (ID=22).
|
||||
- `finance.user_subscriptions` tábla létrehozva index-szel.
|
||||
- 19 org_subscription + 21 user_subscription rekord beszúrva.
|
||||
|
||||
### ✅ Eredmény
|
||||
- Backend: PATCH /organizations/{id} végpont működik, admin packages szűrés JSONB path lekérdezéssel.
|
||||
- Frontend: Company Settings modal, Admin Packages filter UI, i18n támogatás.
|
||||
- Adatbázis: subscription migráció sikeres, minden adat átvezetve a normalizált táblákba.
|
||||
- Sync engine: teljes szinkronban.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-15: PATCH 500 javítás + Cég adatai Hamburger Menü
|
||||
|
||||
### 🎯 Cél
|
||||
Három részből álló feladat:
|
||||
1. **KRITIKUS:** PATCH /admin/packages/{id} 500-as hiba javítása
|
||||
2. **Frontend:** Hiányzó i18n kulcsok (date_from_label, date_until_label) pótlása + dátum szűrés logika javítása
|
||||
3. **Frontend:** Cég Settings Hamburger Menü + 'Cég adatai' modal read/edit móddal
|
||||
|
||||
### 🔍 Hibakeresés (Docker Log)
|
||||
- `docker compose logs --tail=100 sf_api` → `AttributeError: 'dict' object has no attribute 'model_dump'`
|
||||
- **Gyökér ok:** `payload.model_dump(exclude_unset=True)` rekurzívan dict-té alakítja a beágyazott Pydantic modelleket, így `update_data["rules"]` már sima dict, amin a `.model_dump()` hívás AttributeError-t dob.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. Backend: PATCH 500 fix (`backend/app/api/v1/endpoints/admin_packages.py`)**
|
||||
- `new_rules = update_data["rules"].model_dump()` → `new_rules = update_data["rules"]` (közvetlen dict assign)
|
||||
- `isinstance(new_rules, dict)` biztonsági ellenőrzés hozzáadva
|
||||
- Dátum szűrés javítva: string összehasonlítás helyett `cast(..., Date)` használata
|
||||
- Importok: `from datetime import date`, `from sqlalchemy import Date, cast`
|
||||
|
||||
**2. Frontend: i18n kulcsok (`frontend/src/i18n/hu.ts`, `frontend/src/i18n/en.ts`)**
|
||||
- `admin.packages.date_from_label`, `admin.packages.date_until_label` kulcsok hozzáadva
|
||||
- `company.companyDataTitle`, `company.companyDataMenu`, `company.close` kulcsok hozzáadva
|
||||
|
||||
**3. Frontend: CompanyDataModal (`frontend/src/components/organization/CompanyDataModal.vue`)**
|
||||
- **Read mód:** Név, Teljes név, Adószám, Címek megjelenítése szövegesen; 'Bezárás' és 'Szerkesztés' gombok
|
||||
- **Edit mód:** Input mezők (name, full_name, display_name, address); adószám (`tax_number`) mindig disabled/read-only
|
||||
- 'Mégsem' → vissza read módba; 'Mentés' → PATCH API hívás, majd vissza read módba
|
||||
- Glassmorphism design (dark theme, backdrop-blur, border-white/10)
|
||||
|
||||
**4. Frontend: Hamburger Menü (`frontend/src/layouts/OrganizationLayout.vue`)**
|
||||
- 3-vonalas hamburger ikon a header right slot-jában
|
||||
- Dropdown 'Cég adatai' menüponttal
|
||||
- Outside-click handler a bezáráshoz
|
||||
- CompanyDataModal integráció
|
||||
|
||||
### ✅ Ellenőrzés
|
||||
- Backend konténer újraindítva: `docker compose restart sf_api` → sikeres startup
|
||||
- Frontend build: `docker compose exec sf_public_frontend sh -c "cd /app && npm run build"` → 4.83s, hiba nélkül
|
||||
### ✅ Verifikáció
|
||||
- Frontend build: `npm run build` sikeres, 0 hiba ✅
|
||||
|
||||
@@ -5,7 +5,7 @@ from app.api.v1.endpoints import (
|
||||
services, admin, expenses, evidence, social, security,
|
||||
billing, finance_admin, analytics, vehicles, system_parameters,
|
||||
gamification, translations, users, reports, dictionaries,
|
||||
admin_packages, admin_services
|
||||
admin_packages, admin_services, constants, providers
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -33,3 +33,6 @@ api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
|
||||
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
|
||||
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
|
||||
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
||||
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
|
||||
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
||||
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
||||
@@ -1,7 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -11,12 +11,12 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models import Asset, AssetCost
|
||||
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember
|
||||
from app.models.identity import User
|
||||
from app.services.cost_service import cost_service
|
||||
from app.services.asset_service import AssetService
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
|
||||
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
|
||||
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEventCreate, AssetEventResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -174,7 +174,10 @@ async def get_user_vehicles(
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(selectinload(Asset.catalog))
|
||||
.options(
|
||||
selectinload(Asset.catalog),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Corporate mode: only show vehicles belonging to the active organization's garages
|
||||
@@ -212,7 +215,10 @@ async def get_user_vehicles(
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(selectinload(Asset.catalog))
|
||||
.options(
|
||||
selectinload(Asset.catalog),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
@@ -294,9 +300,6 @@ async def get_asset(
|
||||
and vehicle model definition specifications.
|
||||
"""
|
||||
# Check if user has access to this asset
|
||||
from sqlalchemy import or_
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
|
||||
# Get user's organization memberships
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
@@ -317,7 +320,8 @@ async def get_asset(
|
||||
)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition)
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -427,7 +431,7 @@ async def create_or_claim_vehicle(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Vehicle creation error: {e}")
|
||||
logger.error(f"Vehicle creation failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor")
|
||||
|
||||
|
||||
@@ -508,7 +512,7 @@ async def update_vehicle(
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Vehicle update error: {e}")
|
||||
logger.error(f"Vehicle update error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor")
|
||||
|
||||
|
||||
@@ -588,11 +592,7 @@ async def create_maintenance_record(
|
||||
- currency: string (optional, default: "EUR")
|
||||
- invoice_number: string (optional)
|
||||
"""
|
||||
# Check if user has access to this asset
|
||||
from sqlalchemy import or_
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
|
||||
# Get user's organization memberships
|
||||
# Get user's organization memberships (imports already at module level)
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
@@ -629,7 +629,6 @@ async def create_maintenance_record(
|
||||
|
||||
try:
|
||||
# Parse date
|
||||
from datetime import datetime
|
||||
date = datetime.fromisoformat(payload["date"].replace("Z", "+00:00"))
|
||||
|
||||
# Determine organization ID: use asset's current org, owner org, or user's active organization
|
||||
@@ -642,8 +641,6 @@ async def create_maintenance_record(
|
||||
organization_id = int(current_user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
# If scope_id is not a valid integer, try to get from organization memberships
|
||||
from sqlalchemy import select
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
).limit(1)
|
||||
@@ -652,8 +649,6 @@ async def create_maintenance_record(
|
||||
organization_id = org_row[0] if org_row else None
|
||||
else:
|
||||
# Try to get from organization memberships
|
||||
from sqlalchemy import select
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
).limit(1)
|
||||
@@ -667,11 +662,11 @@ async def create_maintenance_record(
|
||||
detail="Cannot determine organization for this cost record. Please ensure you have an active organization."
|
||||
)
|
||||
|
||||
# Create AssetCost record
|
||||
# Create AssetCost record with MAINTENANCE category (id=2)
|
||||
maintenance_cost = AssetCost(
|
||||
asset_id=asset_id,
|
||||
organization_id=organization_id,
|
||||
cost_category="maintenance",
|
||||
category_id=2,
|
||||
amount_net=float(payload["cost"]),
|
||||
currency=payload.get("currency", "EUR"),
|
||||
date=date,
|
||||
@@ -684,17 +679,24 @@ async def create_maintenance_record(
|
||||
)
|
||||
|
||||
db.add(maintenance_cost)
|
||||
await db.commit()
|
||||
await db.refresh(maintenance_cost)
|
||||
await db.flush() # Flush to get maintenance_cost.id
|
||||
|
||||
# Also create an AssetEvent for the maintenance
|
||||
from app.models.vehicle import AssetEvent
|
||||
# Also create an AssetEvent for the maintenance (linked via cost_id)
|
||||
maintenance_event = AssetEvent(
|
||||
asset_id=asset_id,
|
||||
event_type="maintenance"
|
||||
user_id=current_user.id,
|
||||
organization_id=organization_id,
|
||||
event_type="MAINTENANCE",
|
||||
odometer_reading=payload.get("odometer"),
|
||||
description=payload.get("description", "Maintenance record"),
|
||||
cost_id=maintenance_cost.id,
|
||||
event_date=date,
|
||||
)
|
||||
db.add(maintenance_event)
|
||||
|
||||
# Single commit for both records
|
||||
await db.commit()
|
||||
await db.refresh(maintenance_cost)
|
||||
|
||||
return maintenance_cost
|
||||
|
||||
@@ -712,6 +714,244 @@ async def create_maintenance_record(
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# DIGITÁLIS SZERVIZKÖNYV (Service Book) — AssetEvent végpontok
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def _check_asset_access(
|
||||
db: AsyncSession,
|
||||
asset_id: uuid.UUID,
|
||||
current_user: User
|
||||
) -> Asset:
|
||||
"""
|
||||
Helper: check if the current user has access to the given asset.
|
||||
Returns the Asset or raises 404.
|
||||
"""
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found or you don't have permission to access it"
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
async def _resolve_cost_category_for_event(db: AsyncSession, event_type: str) -> int:
|
||||
"""
|
||||
Helper: resolve a CostCategory ID based on the AssetEvent event_type.
|
||||
|
||||
Maps service-book event types to cost categories:
|
||||
- SERVICE, MAINTENANCE, INSPECTION, TIRE_CHANGE -> 'MAINTENANCE' (karbantartás)
|
||||
- REPAIR, ACCIDENT -> 'REPAIR' (javítás)
|
||||
- UPGRADE, RECALL -> 'UPGRADE' (fejlesztés)
|
||||
|
||||
Falls back to a generic 'MAINTENANCE' category if none found.
|
||||
"""
|
||||
category_map = {
|
||||
"SERVICE": "MAINTENANCE",
|
||||
"MAINTENANCE": "MAINTENANCE",
|
||||
"INSPECTION": "MAINTENANCE",
|
||||
"TIRE_CHANGE": "MAINTENANCE",
|
||||
"REPAIR": "REPAIR",
|
||||
"ACCIDENT": "REPAIR",
|
||||
"UPGRADE": "UPGRADE",
|
||||
"RECALL": "UPGRADE",
|
||||
}
|
||||
category_code = category_map.get(event_type, "MAINTENANCE")
|
||||
|
||||
stmt = select(CostCategory.id).where(
|
||||
CostCategory.code == category_code
|
||||
).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
cat_id = result.scalar_one_or_none()
|
||||
|
||||
if cat_id is None:
|
||||
# Fallback: try to find any category with 'MAINTENANCE' in name
|
||||
fallback_stmt = select(CostCategory.id).where(
|
||||
CostCategory.code.ilike("%MAINTENANCE%")
|
||||
).limit(1)
|
||||
fallback_result = await db.execute(fallback_stmt)
|
||||
cat_id = fallback_result.scalar_one_or_none()
|
||||
|
||||
if cat_id is None:
|
||||
# Ultimate fallback: pick the first available cost category
|
||||
any_stmt = select(CostCategory.id).limit(1)
|
||||
any_result = await db.execute(any_stmt)
|
||||
cat_id = any_result.scalar_one_or_none()
|
||||
|
||||
if cat_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="No cost category found in the system. Please seed cost categories first."
|
||||
)
|
||||
|
||||
return cat_id
|
||||
|
||||
|
||||
@router.get("/{asset_id}/events", response_model=List[AssetEventResponse])
|
||||
async def list_asset_events(
|
||||
asset_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Szervizkönyv események listázása.
|
||||
|
||||
GET /api/v1/assets/{asset_id}/events
|
||||
|
||||
Visszaadja a járműhöz tartozó összes AssetEvent rekordot,
|
||||
dátum szerint csökkenő sorrendben (legújabb elöl).
|
||||
"""
|
||||
# Jogosultság ellenőrzés
|
||||
await _check_asset_access(db, asset_id, current_user)
|
||||
|
||||
stmt = (
|
||||
select(AssetEvent)
|
||||
.options(selectinload(AssetEvent.cost))
|
||||
.where(AssetEvent.asset_id == asset_id)
|
||||
.order_by(desc(AssetEvent.event_date))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
events = result.scalars().all()
|
||||
return events
|
||||
|
||||
|
||||
@router.post("/{asset_id}/events", response_model=AssetEventResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_asset_event(
|
||||
asset_id: uuid.UUID,
|
||||
payload: AssetEventCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Új szervizkönyv esemény rögzítése.
|
||||
|
||||
POST /api/v1/assets/{asset_id}/events
|
||||
|
||||
**Üzleti logika:**
|
||||
- Ha a beküldött `odometer_reading` nagyobb, mint a jármű jelenlegi
|
||||
`current_mileage` értéke, akkor:
|
||||
1. Létrehoz egy új OdometerReading rekordot
|
||||
2. Frissíti a jármű `current_mileage` mezőjét
|
||||
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
|
||||
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
|
||||
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
|
||||
"""
|
||||
# Jogosultság ellenőrzés
|
||||
asset = await _check_asset_access(db, asset_id, current_user)
|
||||
|
||||
# Dátum: ha nincs megadva, használjuk a jelenlegi időt
|
||||
event_date = payload.event_date or datetime.now(timezone.utc)
|
||||
|
||||
# Szervezet meghatározása
|
||||
organization_id = asset.current_organization_id or asset.owner_org_id
|
||||
|
||||
try:
|
||||
# ── Üzleti logika: AssetCost automatikus létrehozása ──
|
||||
cost_id = payload.cost_id
|
||||
if payload.cost_amount is not None and payload.cost_amount > 0:
|
||||
# Költségkategória feloldása az esemény típusa alapján
|
||||
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
|
||||
cost_category_id = await _resolve_cost_category_for_event(db, payload.event_type)
|
||||
|
||||
currency = payload.currency or "HUF"
|
||||
|
||||
cost_record = AssetCost(
|
||||
asset_id=asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=cost_category_id,
|
||||
amount_net=payload.cost_amount,
|
||||
currency=currency,
|
||||
date=event_date,
|
||||
data={
|
||||
"description": payload.description or f"Service event: {payload.event_type}",
|
||||
"mileage_at_cost": payload.odometer_reading,
|
||||
"source": "service_book",
|
||||
"event_type": payload.event_type,
|
||||
},
|
||||
)
|
||||
db.add(cost_record)
|
||||
await db.flush() # Flush to get the cost_record.id
|
||||
cost_id = cost_record.id
|
||||
|
||||
logger.info(
|
||||
f"AssetCost auto-created for asset {asset_id}: "
|
||||
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})"
|
||||
)
|
||||
|
||||
# Új esemény létrehozása
|
||||
event = AssetEvent(
|
||||
asset_id=asset_id,
|
||||
user_id=current_user.id,
|
||||
organization_id=organization_id,
|
||||
event_type=payload.event_type,
|
||||
odometer_reading=payload.odometer_reading,
|
||||
description=payload.description,
|
||||
cost_id=cost_id,
|
||||
event_date=event_date,
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
# ── Üzleti logika: Km óra állás frissítése ──
|
||||
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:
|
||||
# 1. Új OdometerReading rekord létrehozása
|
||||
odometer = OdometerReading(
|
||||
asset_id=asset_id,
|
||||
reading=payload.odometer_reading,
|
||||
source="manual",
|
||||
)
|
||||
db.add(odometer)
|
||||
|
||||
# 2. Jármű current_mileage frissítése
|
||||
asset.current_mileage = payload.odometer_reading
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(
|
||||
f"Odometer updated for asset {asset_id}: "
|
||||
f"{asset.current_mileage} → {payload.odometer_reading} "
|
||||
f"(via event {payload.event_type})"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(event, attribute_names=["cost"])
|
||||
return event
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Asset event creation error for {asset_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a szervizkönyv esemény létrehozásakor"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/vehicles/{vehicle_id}/archive", response_model=AssetResponse)
|
||||
async def archive_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
@@ -747,7 +987,7 @@ async def archive_vehicle(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Vehicle archive error for {vehicle_id}: {e}")
|
||||
logger.error(f"Vehicle archive error for {vehicle_id}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Belső szerverhiba a jármű archiválásakor"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/billing.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy import select, func, and_
|
||||
from typing import Optional, Dict, Any, List
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User, Wallet, UserRole
|
||||
from app.models import FinancialLedger, WalletType
|
||||
from app.models import FinancialLedger, WalletType, LedgerEntryType, LedgerStatus
|
||||
from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.services.config_service import config
|
||||
from app.services.payment_router import PaymentRouter
|
||||
@@ -311,4 +311,99 @@ async def get_wallet_balance(
|
||||
except Exception as e:
|
||||
logger.error(f"Pénztárca egyenleg lekérdezési hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/wallet/transactions")
|
||||
async def get_wallet_transactions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
page: int = Query(1, ge=1, description="Oldalszám"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Elemek száma oldalanként"),
|
||||
wallet_type: Optional[str] = Query(None, description="Szűrés pénztárca típusra (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)"),
|
||||
entry_type: Optional[str] = Query(None, description="Szűrés bejegyzés típusra (DEBIT, CREDIT)"),
|
||||
):
|
||||
"""
|
||||
Bejelentkezett felhasználó tranzakciótörténetének lekérdezése (FinancialLedger).
|
||||
|
||||
Visszaadja a főkönyvi bejegyzéseket lapozható formában, időrendben csökkenő sorrendben.
|
||||
"""
|
||||
try:
|
||||
# Base query
|
||||
conditions = [FinancialLedger.user_id == current_user.id]
|
||||
|
||||
# Optional wallet_type filter
|
||||
if wallet_type:
|
||||
try:
|
||||
wt = WalletType(wallet_type.upper())
|
||||
conditions.append(FinancialLedger.wallet_type == wt)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Érvénytelen wallet_type: {wallet_type}. Használd: {[wt.value for wt in WalletType]}"
|
||||
)
|
||||
|
||||
# Optional entry_type filter
|
||||
if entry_type:
|
||||
try:
|
||||
et = LedgerEntryType(entry_type.upper())
|
||||
conditions.append(FinancialLedger.entry_type == et)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Érvénytelen entry_type: {entry_type}. Használd: {[et.value for et in LedgerEntryType]}"
|
||||
)
|
||||
|
||||
# Count total matching records
|
||||
count_stmt = select(func.count(FinancialLedger.id)).where(and_(*conditions))
|
||||
count_result = await db.execute(count_stmt)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Fetch paginated results
|
||||
offset = (page - 1) * page_size
|
||||
stmt = (
|
||||
select(FinancialLedger)
|
||||
.where(and_(*conditions))
|
||||
.order_by(FinancialLedger.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
entries = result.scalars().all()
|
||||
|
||||
# Build response
|
||||
transactions = []
|
||||
for entry in entries:
|
||||
description = ""
|
||||
if entry.details and isinstance(entry.details, dict):
|
||||
description = entry.details.get("description", "")
|
||||
|
||||
transactions.append({
|
||||
"id": entry.id,
|
||||
"transaction_id": str(entry.transaction_id),
|
||||
"amount": float(entry.amount),
|
||||
"entry_type": entry.entry_type.value,
|
||||
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
|
||||
"transaction_type": entry.transaction_type,
|
||||
"description": description,
|
||||
"status": entry.status.value if entry.status else None,
|
||||
"balance_after": float(entry.balance_after) if entry.balance_after else None,
|
||||
"currency": entry.currency or "EUR",
|
||||
"created_at": entry.created_at.isoformat() if entry.created_at else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": transactions,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_count": total_count,
|
||||
"total_pages": max(1, (total_count + page_size - 1) // page_size),
|
||||
},
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Tranzakció történet lekérdezési hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
87
backend/app/api/v1/endpoints/constants.py
Normal file
87
backend/app/api/v1/endpoints/constants.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Constants API Endpoint
|
||||
======================
|
||||
Serves static dictionaries (vehicle features, drive types, etc.)
|
||||
to the frontend so it doesn't need to hardcode them.
|
||||
"""
|
||||
from fastapi import APIRouter, Query
|
||||
from app.constants.vehicle_features import (
|
||||
CAR_FEATURES,
|
||||
CAR_DRIVE_TYPES,
|
||||
CAR_TRANSMISSION_TYPES,
|
||||
CAR_AC_TYPES,
|
||||
CAR_BODY_TYPES,
|
||||
AC_CONNECTOR_TYPES,
|
||||
DC_CONNECTOR_TYPES,
|
||||
MOTORCYCLE_STROKES,
|
||||
MOTORCYCLE_COOLING,
|
||||
MOTORCYCLE_MIXTURE,
|
||||
MOTORCYCLE_FEATURES,
|
||||
LCV_BODY_TYPES,
|
||||
LCV_FEATURES,
|
||||
HGV_TRANSMISSION_TYPES,
|
||||
HGV_PTO_TYPES,
|
||||
HGV_BODY_TYPES,
|
||||
HGV_FEATURES,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/vehicle-features")
|
||||
async def get_vehicle_features(
|
||||
type: str = Query(None, description="Filter by vehicle type: 'car', 'motorcycle', 'lcv' or 'hgv'")
|
||||
):
|
||||
"""Return all vehicle feature dictionaries for frontend use.
|
||||
|
||||
If ?type=motorcycle is provided, returns motorcycle-specific data.
|
||||
If ?type=lcv is provided, returns LCV-specific data.
|
||||
If ?type=hgv is provided, returns HGV-specific data.
|
||||
Otherwise returns car-specific data.
|
||||
"""
|
||||
if type == "motorcycle":
|
||||
return {
|
||||
**MOTORCYCLE_FEATURES, # Spread categories directly: technical, frame_body, luggage, multimedia, other
|
||||
"drive_types": CAR_DRIVE_TYPES,
|
||||
"transmission_types": CAR_TRANSMISSION_TYPES,
|
||||
"ac_types": {}, # No AC types for motorcycles
|
||||
"body_types": {}, # No body types for motorcycles
|
||||
"ac_connector_types": AC_CONNECTOR_TYPES,
|
||||
"dc_connector_types": DC_CONNECTOR_TYPES,
|
||||
"motorcycle_strokes": MOTORCYCLE_STROKES,
|
||||
"motorcycle_cooling": MOTORCYCLE_COOLING,
|
||||
"motorcycle_mixture": MOTORCYCLE_MIXTURE,
|
||||
}
|
||||
|
||||
if type == "lcv":
|
||||
return {
|
||||
**LCV_FEATURES, # Spread categories directly: technical, interior, exterior, multimedia
|
||||
"drive_types": CAR_DRIVE_TYPES,
|
||||
"transmission_types": CAR_TRANSMISSION_TYPES,
|
||||
"ac_types": CAR_AC_TYPES,
|
||||
"body_types": LCV_BODY_TYPES,
|
||||
"ac_connector_types": AC_CONNECTOR_TYPES,
|
||||
"dc_connector_types": DC_CONNECTOR_TYPES,
|
||||
}
|
||||
|
||||
if type == "hgv":
|
||||
return {
|
||||
**HGV_FEATURES, # Spread categories directly: technical_work, cabin, multimedia
|
||||
"drive_types": CAR_DRIVE_TYPES,
|
||||
"transmission_types": HGV_TRANSMISSION_TYPES,
|
||||
"pto_types": HGV_PTO_TYPES,
|
||||
"ac_types": CAR_AC_TYPES,
|
||||
"body_types": HGV_BODY_TYPES,
|
||||
"ac_connector_types": AC_CONNECTOR_TYPES,
|
||||
"dc_connector_types": DC_CONNECTOR_TYPES,
|
||||
}
|
||||
|
||||
return {
|
||||
**CAR_FEATURES, # Spread categories directly: technical, interior, exterior, multimedia, other
|
||||
"drive_types": CAR_DRIVE_TYPES,
|
||||
"transmission_types": CAR_TRANSMISSION_TYPES,
|
||||
"ac_types": CAR_AC_TYPES,
|
||||
"body_types": CAR_BODY_TYPES,
|
||||
"ac_connector_types": AC_CONNECTOR_TYPES,
|
||||
"dc_connector_types": DC_CONNECTOR_TYPES,
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models import Asset, AssetCost, OrganizationMember, SystemParameter
|
||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter
|
||||
from app.schemas.asset_cost import AssetCostCreate
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cost category IDs that are service/maintenance/repair related
|
||||
# These trigger automatic AssetEvent creation
|
||||
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -18,6 +25,10 @@ async def create_expense(
|
||||
"""
|
||||
Create a new expense (fuel, service, tax, insurance) for an asset.
|
||||
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
|
||||
|
||||
**Bidirectional Sync:**
|
||||
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
|
||||
an AssetEvent is automatically created and linked to the cost via cost_id.
|
||||
"""
|
||||
# Validate asset exists
|
||||
stmt = select(Asset).where(Asset.id == expense.asset_id)
|
||||
@@ -85,6 +96,7 @@ async def create_expense(
|
||||
if expense.description:
|
||||
data["description"] = expense.description
|
||||
|
||||
try:
|
||||
# Create AssetCost instance
|
||||
new_cost = AssetCost(
|
||||
asset_id=expense.asset_id,
|
||||
@@ -98,6 +110,35 @@ async def create_expense(
|
||||
)
|
||||
|
||||
db.add(new_cost)
|
||||
await db.flush() # Flush to get new_cost.id
|
||||
|
||||
# ── BIDIRECTIONAL SYNC: Auto-create AssetEvent for service-related costs ──
|
||||
event_id = None
|
||||
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
|
||||
# Map cost category to event type
|
||||
event_type = _map_category_to_event_type(expense.category_id)
|
||||
|
||||
description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
|
||||
mileage = expense.mileage_at_cost
|
||||
|
||||
new_event = AssetEvent(
|
||||
asset_id=expense.asset_id,
|
||||
user_id=getattr(current_user, 'id', None),
|
||||
organization_id=organization_id,
|
||||
event_type=event_type,
|
||||
odometer_reading=mileage,
|
||||
description=description,
|
||||
cost_id=new_cost.id,
|
||||
event_date=expense.date or datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_event)
|
||||
await db.flush() # Flush to get new_event.id
|
||||
event_id = new_event.id
|
||||
|
||||
logger.info(
|
||||
f"Bidirectional sync: Auto-created AssetEvent {event_id} "
|
||||
f"for AssetCost {new_cost.id} (category_id={expense.category_id})"
|
||||
)
|
||||
|
||||
# Update Asset.current_mileage if mileage_at_cost is higher
|
||||
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
|
||||
@@ -112,5 +153,30 @@ async def create_expense(
|
||||
"asset_id": new_cost.asset_id,
|
||||
"category_id": new_cost.category_id,
|
||||
"amount_net": new_cost.amount_net,
|
||||
"date": new_cost.date
|
||||
"date": new_cost.date,
|
||||
"event_id": str(event_id) if event_id else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Expense creation error for asset {expense.asset_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a költség rögzítésekor"
|
||||
)
|
||||
|
||||
|
||||
def _map_category_to_event_type(category_id: int) -> str:
|
||||
"""
|
||||
Map a cost category ID to the appropriate AssetEvent event_type.
|
||||
|
||||
Returns:
|
||||
str: The event type string (SERVICE, REPAIR, etc.)
|
||||
"""
|
||||
category_event_map = {
|
||||
2: "MAINTENANCE", # MAINTENANCE
|
||||
16: "SERVICE", # MAINT_SERVICE
|
||||
17: "SERVICE", # MAINT_OIL
|
||||
18: "REPAIR", # MAINT_BRAKES
|
||||
}
|
||||
return category_event_map.get(category_id, "MAINTENANCE")
|
||||
@@ -1,18 +1,165 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
"""
|
||||
Provider végpontok – Szolgáltató keresés és gyors felvétel (crowdsourced).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse
|
||||
from app.services.social_service import create_service_provider
|
||||
from app.api import deps
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.service import ExpertiseTag
|
||||
from app.schemas.provider import (
|
||||
ProviderSearchResponse,
|
||||
ProviderQuickAddIn,
|
||||
ProviderQuickAddResponse,
|
||||
ProviderUpdateIn,
|
||||
ProviderUpdateResponse,
|
||||
ExpertiseCategoryOut,
|
||||
)
|
||||
from app.services.provider_service import search_providers, quick_add_provider, update_provider
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Secured endpoint: Closed premium ecosystem
|
||||
@router.post("/", response_model=ServiceProviderResponse)
|
||||
async def add_provider(
|
||||
provider_data: ServiceProviderCreate,
|
||||
|
||||
@router.get("/categories", response_model=List[ExpertiseCategoryOut])
|
||||
async def list_expertise_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
user_id = current_user.id
|
||||
return await create_service_provider(db, provider_data, user_id)
|
||||
"""
|
||||
Visszaadja az összes expertise kategóriát a frontend dropdown számára.
|
||||
"""
|
||||
try:
|
||||
stmt = select(ExpertiseTag).order_by(ExpertiseTag.id)
|
||||
result = await db.execute(stmt)
|
||||
tags = result.scalars().all()
|
||||
return [
|
||||
ExpertiseCategoryOut(
|
||||
id=t.id,
|
||||
key=t.key,
|
||||
name_hu=t.name_hu,
|
||||
name_en=t.name_en,
|
||||
category=t.category,
|
||||
)
|
||||
for t in tags
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a kategóriák lekérése során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a kategóriák lekérése során.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/search", response_model=ProviderSearchResponse)
|
||||
async def search_service_providers(
|
||||
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"),
|
||||
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
|
||||
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Találatok száma oldalanként"),
|
||||
offset: int = Query(0, ge=0, description="Lapozási offset"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Szolgáltatók keresése egyesített forrásokból.
|
||||
|
||||
Három forrásból keres:
|
||||
- **verified_org**: Verifikált szervezetek (fleet.organizations, org_type='service_provider')
|
||||
- **staged_data**: Robotok által gyűjtött adatok (marketplace.service_staging)
|
||||
- **crowd_added**: Közösség által hozzáadott adatok (marketplace.service_providers)
|
||||
|
||||
A találatok forrás szerint csoportosítva, lapozva érkeznek.
|
||||
"""
|
||||
try:
|
||||
result = await search_providers(
|
||||
db=db,
|
||||
q=q,
|
||||
category=category,
|
||||
city=city,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a szolgáltató keresés során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a keresés során.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/quick-add", response_model=ProviderQuickAddResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def quick_add_service_provider(
|
||||
data: ProviderQuickAddIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Gyors szolgáltató felvétel crowdsourcingból.
|
||||
|
||||
Létrehoz egy új Organization-t (is_verified=False, org_type='service_provider'),
|
||||
hozzáköt egy ServiceProfile-t, a megadott kategóriát (expertise_tags),
|
||||
létrehoz egy Branch-t a címmel, és a felhasználót beállítja OWNER-nek.
|
||||
|
||||
A szolgáltató 'pending_verification' státuszba kerül, amíg a robotok
|
||||
vagy az adminok nem ellenőrzik.
|
||||
"""
|
||||
try:
|
||||
result = await quick_add_provider(
|
||||
db=db,
|
||||
data=data,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a gyors szolgáltató felvétel során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a szolgáltató rögzítése során.",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{provider_id}", response_model=ProviderUpdateResponse)
|
||||
async def update_service_provider(
|
||||
provider_id: int,
|
||||
data: ProviderUpdateIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Meglévő szolgáltató (Organization) adatainak szerkesztése.
|
||||
|
||||
Frissíti a szervezet alapadatait (név, cím) és a kapcsolódó
|
||||
ServiceProfile kapcsolati mezőit (telefon, email, weboldal, címkék).
|
||||
"""
|
||||
try:
|
||||
result = await update_provider(
|
||||
db=db,
|
||||
provider_id=provider_id,
|
||||
data=data,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a szolgáltató frissítése során (id={provider_id}): {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a szolgáltató frissítése során.",
|
||||
)
|
||||
|
||||
40
backend/app/constants/__init__.py
Normal file
40
backend/app/constants/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Constants Package
|
||||
=================
|
||||
Static dictionaries for vehicle features, drive types, etc.
|
||||
"""
|
||||
from app.constants.vehicle_features import (
|
||||
CAR_FEATURES,
|
||||
CAR_DRIVE_TYPES,
|
||||
CAR_TRANSMISSION_TYPES,
|
||||
CAR_AC_TYPES,
|
||||
CAR_BODY_TYPES,
|
||||
AC_CONNECTOR_TYPES,
|
||||
DC_CONNECTOR_TYPES,
|
||||
MOTORCYCLE_STROKES,
|
||||
MOTORCYCLE_COOLING,
|
||||
MOTORCYCLE_MIXTURE,
|
||||
MOTORCYCLE_FEATURES,
|
||||
get_all_feature_keys,
|
||||
get_feature_label,
|
||||
get_features_by_category,
|
||||
get_motorcycle_feature_label,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CAR_FEATURES",
|
||||
"CAR_DRIVE_TYPES",
|
||||
"CAR_TRANSMISSION_TYPES",
|
||||
"CAR_AC_TYPES",
|
||||
"CAR_BODY_TYPES",
|
||||
"AC_CONNECTOR_TYPES",
|
||||
"DC_CONNECTOR_TYPES",
|
||||
"MOTORCYCLE_STROKES",
|
||||
"MOTORCYCLE_COOLING",
|
||||
"MOTORCYCLE_MIXTURE",
|
||||
"MOTORCYCLE_FEATURES",
|
||||
"get_all_feature_keys",
|
||||
"get_feature_label",
|
||||
"get_features_by_category",
|
||||
"get_motorcycle_feature_label",
|
||||
]
|
||||
759
backend/app/constants/vehicle_features.py
Normal file
759
backend/app/constants/vehicle_features.py
Normal file
@@ -0,0 +1,759 @@
|
||||
"""
|
||||
Vehicle Features & EV Constants
|
||||
================================
|
||||
Centralized dictionaries for car-specific dropdowns, EV connector types,
|
||||
and categorized feature lists. All data is stored in the `individual_equipment`
|
||||
JSONB column on the Asset model.
|
||||
|
||||
Usage:
|
||||
from app.constants.vehicle_features import (
|
||||
CAR_DRIVE_TYPES, CAR_TRANSMISSION_TYPES, CAR_AC_TYPES,
|
||||
CAR_BODY_TYPES, CAR_FEATURES, AC_CONNECTOR_TYPES, DC_CONNECTOR_TYPES,
|
||||
MOTORCYCLE_STROKES, MOTORCYCLE_COOLING, MOTORCYCLE_MIXTURE,
|
||||
MOTORCYCLE_FEATURES,
|
||||
LCV_BODY_TYPES, LCV_FEATURES,
|
||||
HGV_TRANSMISSION_TYPES, HGV_PTO_TYPES, HGV_BODY_TYPES, HGV_FEATURES,
|
||||
)
|
||||
"""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CAR-SPECIFIC DROPDOWNS (only shown when vehicle_class == 'personal')
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
CAR_DRIVE_TYPES: dict[str, str] = {
|
||||
"fwd": "Elsőkerék (FWD)",
|
||||
"rwd": "Hátsókerék (RWD)",
|
||||
"awd": "Összkerék (AWD / 4×4)",
|
||||
"4wd": "Összkerék (4WD / kapcsolható)",
|
||||
}
|
||||
|
||||
CAR_TRANSMISSION_TYPES: dict[str, str] = {
|
||||
"manual": "Manuális",
|
||||
"automatic": "Automata",
|
||||
"sequential": "Szekvenciális",
|
||||
"cvt": "CVT (Fokozatmentes)",
|
||||
"dct": "DCT (Dupla kuplungos)",
|
||||
"semi_auto": "Félautomata",
|
||||
}
|
||||
|
||||
CAR_AC_TYPES: dict[str, str] = {
|
||||
"manual": "Manuális klíma",
|
||||
"automatic": "Automata klíma",
|
||||
"dual_zone": "Kétzónás automata klíma",
|
||||
"tri_zone": "Háromzónás automata klíma",
|
||||
"quad_zone": "Négyzónás automata klíma",
|
||||
}
|
||||
|
||||
CAR_BODY_TYPES: dict[str, str] = {
|
||||
"sedan": "Szedán",
|
||||
"hatchback": "Ferdehátú (Hatchback)",
|
||||
"wagon": "Kombi (Estate)",
|
||||
"coupe": "Coupé",
|
||||
"convertible": "Kabrió (Convertible)",
|
||||
"suv": "SUV / Szabadidő-autó",
|
||||
"mpv": "MPV (Egyterű)",
|
||||
"pickup": "Pickup",
|
||||
"van": "Furgon (Van)",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CAR FEATURES / EXTRAS (categorized)
|
||||
# Stored in individual_equipment.car_specs.features as a list of keys.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
CAR_FEATURES: dict[str, dict[str, str]] = {
|
||||
"technical": {
|
||||
"abs": "ABS",
|
||||
"esp": "ESP (Menetstabilizáló)",
|
||||
"traction_control": "Kipörgésgátló (TCS)",
|
||||
"hill_assist": "Hegymeneti tartó",
|
||||
"hill_descent": "Lejtmenetvezérlő",
|
||||
"lane_assist": "Sávtartó asszisztens",
|
||||
"blind_spot": "Holtpont-figyelő",
|
||||
"adaptive_cruise": "Adaptív tempomat (ACC)",
|
||||
"parking_sensors_front": "Első parkolóérzékelők",
|
||||
"parking_sensors_rear": "Hátsó parkolóérzékelők",
|
||||
"rear_camera": "Tolatókamera",
|
||||
"360_camera": "360°-os kamera",
|
||||
"auto_parking": "Automatikus parkolóasszisztens",
|
||||
"start_stop": "Start-Stop rendszer",
|
||||
"keyless_go": "Kulcs nélküli indítás (Keyless Go)",
|
||||
"night_vision": "Éjjellátó (Night Vision)",
|
||||
"traffic_sign_recognition": "Táblafelismerő rendszer",
|
||||
"driver_alert": "Fáradtságfigyelő",
|
||||
"emergency_brake": "Vészfék asszisztens",
|
||||
"cross_traffic_alert": "Keresztirányú forgalom figyelő",
|
||||
},
|
||||
"interior": {
|
||||
"leather_seats": "Bőrülés",
|
||||
"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és",
|
||||
"electric_seats": "Elektromos ülésállítás",
|
||||
"memory_seats": "Ülésmemória",
|
||||
"sport_seats": "Sportülés",
|
||||
"heated_steering": "Fűthető kormány",
|
||||
"ambient_lighting": "Hangulatvilágítás (Ambient Light)",
|
||||
"panoramic_roof": "Panorámatető",
|
||||
"sunroof": "Napfénytető",
|
||||
"auto_dim_mirror": "Automatikusan sötétedő belső tükör",
|
||||
"electric_steering_adjust": "Elektromos kormányállítás",
|
||||
"rear_blind": "Hátsó roló",
|
||||
"rear_side_blinds": "Hátsó oldalsó rolók",
|
||||
"digital_instrument_cluster": "Digitális műszeregység",
|
||||
"head_up_display": "Head-Up Display (HUD)",
|
||||
},
|
||||
"exterior": {
|
||||
"alloy_wheels": "Könnyűfém felni",
|
||||
"roof_rails": "Tetősín (Roof Rails)",
|
||||
"tow_bar": "Vontatóhorog (vonóhorog)",
|
||||
"tinted_windows": "Sötétített üvegek",
|
||||
"privacy_glass": "Privacy Glass",
|
||||
"led_headlights": "LED fényszóró",
|
||||
"adaptive_headlights": "Adaptív fényszóró (AFS)",
|
||||
"fog_lights": "Ködlámpa",
|
||||
"cornering_lights": "Kanyarvilágítás",
|
||||
"rain_sensor": "Esőérzékelő",
|
||||
"light_sensor": "Fényérzékelő",
|
||||
"auto_wipers": "Automatikus ablaktörlő",
|
||||
"electric_trunk": "Elektromos csomagtérajtó",
|
||||
"hands_free_trunk": "Kihangosítható csomagtérajtó (Hands-Free)",
|
||||
"side_steps": "Oldallépcső",
|
||||
"mudguards": "Sárvédő",
|
||||
},
|
||||
"multimedia": {
|
||||
"navigation": "Navigáció (GPS)",
|
||||
"apple_carplay": "Apple CarPlay",
|
||||
"android_auto": "Android Auto",
|
||||
"bluetooth": "Bluetooth",
|
||||
"usb_charging": "USB töltőcsatlakozó",
|
||||
"wireless_charging": "Vezeték nélküli töltő",
|
||||
"dab_radio": "DAB+ digitális rádió",
|
||||
"premium_sound": "Prémium hangrendszer",
|
||||
"subwoofer": "Mélynyomó (Subwoofer)",
|
||||
"rear_entertainment": "Hátsó szórakoztató rendszer",
|
||||
"wifi_hotspot": "WiFi Hotspot",
|
||||
"voice_control": "Hangvezérlés",
|
||||
"gesture_control": "Gesztusvezérlés",
|
||||
},
|
||||
"other": {
|
||||
"spare_tire": "Pótkerék",
|
||||
"tire_repair_kit": "Kerékpár-javító készlet",
|
||||
"first_aid_kit": "Elsősegély csomag",
|
||||
"warning_triangle": "Figyelmeztető háromszög",
|
||||
"fire_extinguisher": "Tűzoltó készülék",
|
||||
"winter_tires": "Téli gumi",
|
||||
"cargo_net": "Csomagtér háló",
|
||||
"cargo_cover": "Csomagtér takaró",
|
||||
"pet_barrier": "Kutya rács / elválasztó",
|
||||
"roof_box": "Tetős doboz",
|
||||
"bike_rack": "Kerékpártartó",
|
||||
"ski_rack": "Sítartó",
|
||||
},
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# EV-SPECIFIC CONNECTOR TYPES
|
||||
# Stored in individual_equipment.ev_specs
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
AC_CONNECTOR_TYPES: dict[str, str] = {
|
||||
"type1": "Type 1 (SAE J1772)",
|
||||
"type2": "Type 2 (Mennekes)",
|
||||
"type3": "Type 3 (SCAME)",
|
||||
"gb_t": "GB/T (Kínai szabvány)",
|
||||
}
|
||||
|
||||
DC_CONNECTOR_TYPES: dict[str, str] = {
|
||||
"ccs": "CCS (Combined Charging System)",
|
||||
"chademo": "CHAdeMO",
|
||||
"tesla_supercharger": "Tesla Supercharger",
|
||||
"gb_t_dc": "GB/T DC (Kínai gyorstöltő)",
|
||||
"nacs": "NACS (Tesla North American)",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# MOTORCYCLE-SPECIFIC DROPDOWNS
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
MOTORCYCLE_STROKES: dict[str, str] = {
|
||||
"2_stroke": "2 ütemű",
|
||||
"4_stroke": "4 ütemű",
|
||||
"electric": "Elektromos",
|
||||
}
|
||||
|
||||
MOTORCYCLE_COOLING: dict[str, str] = {
|
||||
"air": "Levegőhűtés",
|
||||
"liquid": "Vízhűtés",
|
||||
"oil": "Olajhűtés",
|
||||
"air_oil": "Levegő/Olaj hűtés",
|
||||
}
|
||||
|
||||
MOTORCYCLE_MIXTURE: dict[str, str] = {
|
||||
"carburetor": "Karburátor",
|
||||
"injection": "Befecskendezés",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# MOTORCYCLE FEATURES / EXTRAS (categorized)
|
||||
# Stored in individual_equipment.motorcycle_specs.features as a list of keys.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
MOTORCYCLE_FEATURES: dict[str, dict[str, str]] = {
|
||||
"technical": {
|
||||
"dual_front_disc": "Dupla tárcsafék elöl",
|
||||
"front_disc": "Tárcsafék elöl",
|
||||
"rear_disc": "Tárcsafék hátul",
|
||||
"chip_tuning": "Chiptuning",
|
||||
"electronic_suspension": "Elektromos futómű állítás",
|
||||
"onboard_computer": "Fedélzeti computer",
|
||||
"metal_brake_line": "Fém fékcső",
|
||||
"rev_counter": "Fordulatszámmérő",
|
||||
"immobiliser": "Immobiliser",
|
||||
"catalyst": "Katalizátor",
|
||||
"electric_starter": "Önindító",
|
||||
"all_wheel_drive": "Összkerékhajtás",
|
||||
"alarm": "Riasztó",
|
||||
"sport_exhaust": "Sport kipufogó",
|
||||
"sport_air_filter": "Sport légszűrő",
|
||||
"cruise_control": "Tempomat",
|
||||
"turbo": "Turbó",
|
||||
"12v_system": "12 V rendszer",
|
||||
"heated_grip": "Markolat fűtés",
|
||||
"abs": "ABS (blokkolásgátló)",
|
||||
"seat_belt": "Biztonsági öv",
|
||||
"dtc": "DTC",
|
||||
"fog_light": "Ködlámpa",
|
||||
"airbag": "Légzsák",
|
||||
"xenon_headlight": "Xenon fényszóró",
|
||||
},
|
||||
"frame_body": {
|
||||
"full_extra": "Full extra",
|
||||
"leather_seat": "Bőrülés",
|
||||
"heated_seat": "Fűthető ülés",
|
||||
"backrest": "Háttámla",
|
||||
"center_stand": "Középsztender",
|
||||
"footrest": "Lábtartó",
|
||||
"windshield": "Motoros szélvédő",
|
||||
"plexiglass": "Plexi",
|
||||
"tank_pad": "Tankpad",
|
||||
"tank_protector_leather": "Tankvédő bőr",
|
||||
"seat_height_adjust": "Ülésmagasság állítás",
|
||||
"crash_bar": "Bukócső / bukógomba",
|
||||
"hand_guards": "Kézvédők",
|
||||
"heated_mirror": "Fűthető tükör",
|
||||
"tow_hitch": "Vonóhorog",
|
||||
},
|
||||
"luggage": {
|
||||
"factory_cases": "Gyári dobozok",
|
||||
"rear_case": "Hátsó doboz",
|
||||
"side_cases": "Oldalsó dobozok",
|
||||
"lockable_case": "Zárható doboz",
|
||||
"side_bag": "Oldaltáska",
|
||||
"tank_bag": "Tank táska",
|
||||
"bag_holder_bracket": "Táskatartó konzol",
|
||||
"fork_bag": "Villatáska",
|
||||
},
|
||||
"multimedia": {
|
||||
"cd_player": "CD tár",
|
||||
"gps_navigation": "GPS (navigáció)",
|
||||
"hi_fi": "Hi-Fi",
|
||||
"radio": "Rádiós magnó",
|
||||
"info_display": "Információs kijelző",
|
||||
},
|
||||
"other": {
|
||||
"under_warranty": "Garanciális",
|
||||
"us_model": "Amerikai modell",
|
||||
"immediate_takeover": "Azonnal elvihető",
|
||||
"showroom_vehicle": "Bemutató jármű",
|
||||
"orderable": "Rendelhető",
|
||||
"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",
|
||||
"low_mileage": "Keveset futott",
|
||||
"second_owner": "Második tulajdonostól",
|
||||
"motorcycle_trade_in_possible": "Motorbeszámítás lehetséges",
|
||||
"track_fairing": "Pályaidom",
|
||||
"regularly_maintained": "Rendszeresen karbantartott",
|
||||
"service_book": "Szervizkönyv",
|
||||
"title_certificate": "Törzskönyv",
|
||||
},
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# LCV-SPECIFIC BODY TYPES
|
||||
# Shown when vehicle_class == 'lcv'
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
LCV_BODY_TYPES: dict[str, str] = {
|
||||
"closed_van": "Zárt furgon",
|
||||
"pickup": "Pickup",
|
||||
"double_cab_chassis": "Dupla kabin alváz",
|
||||
"single_cab_chassis": "Egyes kabin alváz",
|
||||
"box_tail_lift": "Dobozos raktér emelővel",
|
||||
"tipper": "Billenős plató",
|
||||
"dropside": "Billenő oldalfalú plató",
|
||||
"refrigerated_van": "Hűtött furgon",
|
||||
"crew_cab_pickup": "Crew Cab Pickup",
|
||||
"window_van": "Ablakos furgon",
|
||||
"combi_van": "Combi furgon",
|
||||
"platform_truck": "Platós teherautó",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# LCV FEATURES / EXTRAS (categorized)
|
||||
# Stored in individual_equipment.lcv_specs.features as a list of keys.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
LCV_FEATURES: dict[str, dict[str, str]] = {
|
||||
"technical": {
|
||||
"abs": "ABS",
|
||||
"asr": "ASR (Kipörgésgátló)",
|
||||
"gps_tracker": "GPS nyomkövető",
|
||||
"immobiliser": "Immobiliser",
|
||||
"alarm": "Riasztó",
|
||||
"cruise_control": "Tempomat",
|
||||
"parking_sensors_rear": "Hátsó parkolóérzékelők",
|
||||
},
|
||||
"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",
|
||||
"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)",
|
||||
"leather_interior": "Bőr belső",
|
||||
"heated_seat": "Fűthető ülés",
|
||||
"partition_wall": "Válaszfal",
|
||||
"seat_height_adjustment": "Ülésmagasság állítás",
|
||||
"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": {
|
||||
"power_windows": "Elektromos ablakemelő",
|
||||
"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ó",
|
||||
},
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# HGV-SPECIFIC DROPDOWNS (shown when vehicle_class == 'hgv')
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
HGV_TRANSMISSION_TYPES: dict[str, str] = {
|
||||
"manual": "Manuális",
|
||||
"manual_async": "Manuális (aszinkron)",
|
||||
"semi_auto": "Félautomata",
|
||||
"auto": "Automata",
|
||||
"manual_split": "Manuális felezős",
|
||||
"semi_auto_split": "Félautomata felezős",
|
||||
"auto_split": "Automata felezős",
|
||||
"cvt": "Fokozatmentes (CVT)",
|
||||
}
|
||||
|
||||
HGV_PTO_TYPES: dict[str, str] = {
|
||||
"front": "Első kihajtás (Front)",
|
||||
"engine_mounted": "Motorra szerelt (Engine-mounted)",
|
||||
"gearbox_mounted": "Váltóra szerelt (Gearbox-mounted)",
|
||||
}
|
||||
|
||||
# ── HGV Body Types (unified with frontend i18n keys) ──
|
||||
HGV_BODY_TYPES: dict[str, str] = {
|
||||
# ── Alváz / Fülke típusok ──
|
||||
"chassis": "Alváz (csak futómű)",
|
||||
"chassis_cab": "Alvázas fülkés (Chassis Cab)",
|
||||
"double_cab": "Duplakabinos (Double Cab)",
|
||||
"triple_cab": "Triplakabinos (Triple Cab)",
|
||||
"crew_cab": "Crew Cab",
|
||||
"sleeper_cab": "Alvófülkés (Sleeper Cab)",
|
||||
"day_cab": "Nappali fülke (Day Cab)",
|
||||
"glider_kit": "Glider Kit (felújított)",
|
||||
"semi_glazed": "Félig üvegezett (Semi-Glazed)",
|
||||
"fully_glazed": "Körben üvegezett (Fully Glazed)",
|
||||
|
||||
# ── Billenős / Borítás ──
|
||||
"tipper": "Billenős (Tipper)",
|
||||
"tipper_3_way": "3 irányba billenő",
|
||||
"tipper_double": "Kétoldalt billenő",
|
||||
"tipper_trailer": "Billenős pótkocsi",
|
||||
"tipper_mining": "Bányászati billenő (Mining Tipper)",
|
||||
"tipper_grain": "Gabonás billenő (Grain Tipper)",
|
||||
"dump_truck": "Billenős teherautó (Dump Truck)",
|
||||
|
||||
# ── Dobozos / Ponyvás ──
|
||||
"box": "Dobozos (Box)",
|
||||
"box_tail_lift": "Dobozos emelőhátfalas (Box with Tail Lift)",
|
||||
"box_insulated": "Szigetelt dobozos (Insulated Box)",
|
||||
"box_curtain_side": "Függönyoldalas dobozos",
|
||||
"curtain_sider": "Ponyvás (Curtain Sider)",
|
||||
"curtain_sider_roller": "Ponyvás rolós (Roller Curtain)",
|
||||
"roller_shutter": "Redőnyös (Roller Shutter)",
|
||||
|
||||
# ── Platós ──
|
||||
"flatbed": "Platós (Flatbed)",
|
||||
"flatbed_stake": "Rakodóléces platós",
|
||||
"flatbed_dropside": "Billenő oldalfalú platós",
|
||||
"flatbed_crane": "Platós darugémes (Flatbed with Crane)",
|
||||
"flatbed_tail_lift": "Platós emelőhátfalas (Flatbed with Tail Lift)",
|
||||
|
||||
# ── Hűtött ──
|
||||
"refrigerated": "Hűtős (Refrigerated / Reefer)",
|
||||
"refrigerated_trailer": "Hűtött pótkocsi",
|
||||
|
||||
# ── Tartályos ──
|
||||
"tanker": "Tartályos (Tanker)",
|
||||
"tanker_fuel": "Üzemanyag szállító (Fuel Tanker)",
|
||||
"tanker_milk": "Tej szállító tartály",
|
||||
"tanker_chemical": "Vegyi anyag szállító tartály",
|
||||
"tanker_powder": "Poranyag szállító tartály",
|
||||
"tanker_bitumen": "Bitumenes tartály (Bitumen Tanker)",
|
||||
"tanker_gas": "Gázszállító tartály (Gas Tanker)",
|
||||
"tanker_food": "Élelmiszer tartály (Food Tanker)",
|
||||
"tanker_water": "Víztartály (Water Tanker)",
|
||||
|
||||
# ── Nyergesvontató / Vontató ──
|
||||
"tractor_unit": "Nyergesvontató (Tractor Unit)",
|
||||
"tractor_unit_4x2": "Vontató 4x2",
|
||||
"tractor_unit_6x2": "Vontató 6x2",
|
||||
"tractor_unit_6x4": "Vontató 6x4",
|
||||
"tractor_unit_8x4": "Vontató 8x4",
|
||||
|
||||
# ── Félpótkocsi / Nyerges szerelvények ──
|
||||
"semi_trailer": "Nyerges szerelvény (Semi-Trailer)",
|
||||
"semi_trailer_curtain": "Függönyoldalas félpótkocsi",
|
||||
"semi_trailer_box": "Dobozos félpótkocsi",
|
||||
"semi_trailer_tipper": "Billenős félpótkocsi",
|
||||
"semi_trailer_tanker": "Tartályos félpótkocsi",
|
||||
"semi_trailer_flatbed": "Platós félpótkocsi",
|
||||
"semi_trailer_low_loader": "Mélyágyas félpótkocsi",
|
||||
"semi_trailer_car_transporter": "Autószállító félpótkocsi",
|
||||
"semi_tipper": "Nyerges billenő (Semi-Tipper)",
|
||||
"semi_refrigerated": "Nyerges hűtős (Semi-Reefer)",
|
||||
"semi_tanker": "Nyerges tartály (Semi-Tanker)",
|
||||
"semi_flatbed": "Nyerges platós (Semi-Flatbed)",
|
||||
"semi_curtain": "Nyerges ponyvás (Semi-Curtain)",
|
||||
"semi_log": "Nyerges rönkszállító (Semi-Timber)",
|
||||
"semi_container": "Nyerges konténerszállító (Semi-Container)",
|
||||
|
||||
# ── Konténer / Csereszekrény ──
|
||||
"container": "Konténer",
|
||||
"container_carrier": "Konténerszállító (Container Carrier)",
|
||||
"swap_body": "Cserélhető felépítmény",
|
||||
"swap_body_bdf": "Csereszekrényes BDF (Swap Body)",
|
||||
"hook_lift": "Emelőhorgos (Hook Lift)",
|
||||
|
||||
# ── Darus / Speciális ──
|
||||
"crane": "Daru",
|
||||
"crane_truck": "Darugémes teherautó (Crane Truck)",
|
||||
"crane_trailer": "Darus pótkocsi",
|
||||
"auto_crane": "Autódaru (Auto Crane)",
|
||||
"ladder_truck": "Létrás (Ladder Truck)",
|
||||
|
||||
# ── Tűzoltó / Mentő ──
|
||||
"fire_truck": "Tűzoltó (Fire Truck)",
|
||||
"fire_truck_aerial": "Léptetőkosaras tűzoltó",
|
||||
"ambulance": "Mentő (Ambulance)",
|
||||
"hearse": "Halottas (Hearse)",
|
||||
|
||||
# ── Páncélozott / Pénzszállító ──
|
||||
"armored": "Pénzszállító / páncélozott (Armored)",
|
||||
"cash_transit": "Pénzszállító (Cash-in-Transit)",
|
||||
|
||||
# ── Állatszállító ──
|
||||
"livestock": "Élőállat-szállító (Livestock)",
|
||||
"poultry": "Baromfi szállító (Poultry)",
|
||||
"horse_transporter": "Lószállító (Horse Transporter)",
|
||||
|
||||
# ── Járműszállító ──
|
||||
"car_transporter": "Járműszállító (Car Transporter)",
|
||||
"boat_transporter": "Hajószállító (Boat Transporter)",
|
||||
"quad_transporter": "Quad / motorszállító (Quad Transporter)",
|
||||
|
||||
# ── Egyéb speciális ──
|
||||
"mobile_shop": "Mozgóbolt (Mobile Shop)",
|
||||
"workshop": "Műhelykocsi (Workshop)",
|
||||
"tow_truck": "Mentő / Szállító (Wrecker / Recovery)",
|
||||
"wrecker": "Mentő / szállító (Wrecker / Recovery)",
|
||||
"street_sweeper": "Utcaseprő",
|
||||
"refuse_truck": "Szemetes (Refuse Truck)",
|
||||
"concrete_mixer": "Betonszállító (Concrete Mixer)",
|
||||
"concrete_pump": "Betonszivattyú (Concrete Pump)",
|
||||
"timber_truck": "Faszállító (Timber Truck)",
|
||||
"log_trailer": "Farönk szállító pótkocsi (Log Trailer)",
|
||||
"clothing_transporter": "Ruhaszállító (Clothing Transporter)",
|
||||
"military": "Harci jármű (Military)",
|
||||
|
||||
# ── Egyéb ──
|
||||
"other": "Egyéb (Other)",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# HGV FEATURES / EXTRAS (categorized)
|
||||
# Stored in individual_equipment.hgv_specs.features as a list of keys.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
HGV_FEATURES: dict[str, dict[str, str]] = {
|
||||
"technical_work": {
|
||||
# ═══════════════════════════════════════════════
|
||||
# 1. Hajtás & Fékrendszer (Drivetrain & Brakes)
|
||||
# ═══════════════════════════════════════════════
|
||||
"diff_lock": "Differenciálzár (Diff Lock)",
|
||||
"diff_lock_front": "Első differenciálzár",
|
||||
"diff_lock_rear": "Hátsó differenciálzár",
|
||||
"diff_lock_center": "Központi differenciálzár",
|
||||
"diff_lock_full": "Teljes differenciálzár",
|
||||
"cross_axle_lock": "Kereszttengely differenciálzár",
|
||||
"retarder": "Retarder (Lejtőfékező)",
|
||||
"engine_brake": "Motorfék (Engine Brake)",
|
||||
"exhaust_brake": "Kipufogófék (Exhaust Brake)",
|
||||
"auxiliary_brake": "Segédfék",
|
||||
"abs": "ABS (Blokkolásgátló)",
|
||||
"ebs": "EBS (Elektronikus fékrendszer)",
|
||||
"esp": "ESP (Menetstabilizáló)",
|
||||
"traction_control": "Kipörgésgátló (ASR/TCS)",
|
||||
"roll_stability": "Borulásgátló (RSP)",
|
||||
"stability_control": "Stabilitás szabályzó (ESP)",
|
||||
"hill_holder": "Hegymeneti tartó (Hill Holder)",
|
||||
"hill_descent": "Lejtmenetvezérlő (Hill Descent)",
|
||||
"hill_descent_control": "Lejtmenetvezérlő",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 2. Vezetéstámogató rendszerek (ADAS)
|
||||
# ═══════════════════════════════════════════════
|
||||
"adaptive_cruise": "Adaptív tempomat (ACC)",
|
||||
"lane_departure": "Sávelhagyás figyelő (LDWS)",
|
||||
"lane_assist": "Sávtartó asszisztens",
|
||||
"blind_spot": "Holtpont-figyelő (Blind Spot)",
|
||||
"blind_spot_monitor": "Holtpont figyelő",
|
||||
"collision_warning": "Ütközés figyelmeztető",
|
||||
"emergency_brake": "Vészfék asszisztens (AEBS)",
|
||||
"emergency_brake_assist": "Vészfék asszisztens",
|
||||
"traffic_sign": "Táblafelismerő rendszer",
|
||||
"traffic_sign_recognition": "Táblafelismerő",
|
||||
"driver_alert": "Fáradtságfigyelő (Driver Alert)",
|
||||
"crosswind_assist": "Keresztszél asszisztens",
|
||||
"tyre_pressure_monitor": "Guminyomás monitor (TPMS)",
|
||||
"tire_pressure": "Guminyomás-figyelő (TPMS)",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 3. Flotta Adminisztráció & Mérés
|
||||
# ═══════════════════════════════════════════════
|
||||
"tachograph": "Menetíró (Tachograph)",
|
||||
"digital_tachograph": "Digitális menetíró",
|
||||
"smart_tachograph": "Smart Tachográf (G2V2)",
|
||||
"gps_tracker": "GPS nyomkövető",
|
||||
"fleet_management": "Flottamenedzsment rendszer",
|
||||
"fuel_monitoring": "Üzemanyag monitorozás",
|
||||
"fuel_card_reader": "Üzemanyagkártya-olvasó",
|
||||
"onboard_scale": "Fedélzeti mérleg (Onboard Scale)",
|
||||
"weigh_in_motion": "Menet közbeni mérlegelés",
|
||||
"eco_driving": "Eco Driving asszisztens",
|
||||
"load_securing": "Rakományrögzítő rendszer",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 4. Látás & Kamerák
|
||||
# ═══════════════════════════════════════════════
|
||||
"reverse_camera": "Tolatókamera",
|
||||
"rear_camera": "Hátsó kamera",
|
||||
"front_camera": "Első kamera",
|
||||
"side_camera": "Oldalsó kamera",
|
||||
"360_camera": "360°-os kamera",
|
||||
"parking_sensors": "Parkolóérzékelők",
|
||||
"front_parking_sensors": "Első parkolóérzékelők",
|
||||
"rear_parking_sensors": "Hátsó parkolóérzékelők",
|
||||
"side_parking_sensors": "Oldalsó parkolóérzékelők",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 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ő)",
|
||||
"reflective_vest": "Reflexiós mellény",
|
||||
"roof_hatch": "Tetőablak",
|
||||
"roof_ladder": "Tetőlétra",
|
||||
"mudguards": "Sárvédők",
|
||||
"spare_wheel": "Pótkerék",
|
||||
"snow_chains": "Hólánc",
|
||||
"toolbox": "Szerszámos láda",
|
||||
"work_lights": "Munkalámpák",
|
||||
"beacon": "Figyelmeztető lámpa",
|
||||
"reverse_alarm": "Tolatási hangjelző",
|
||||
"side_underrun": "Oldalsó aláfutásgátló",
|
||||
"rear_underrun": "Hátsó aláfutásgátló",
|
||||
},
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# CABIN (Fülke)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
"cabin": {
|
||||
# ═══════════════════════════════════════════════
|
||||
# 1. Klíma & Fűtés
|
||||
# ═══════════════════════════════════════════════
|
||||
"air_conditioner": "Klíma (légkondícionáló)",
|
||||
"automatic_ac": "Automata klíma",
|
||||
"dual_zone_ac": "Kétzónás klíma",
|
||||
"parking_heater": "Parkfűtés (Webasto/Eberspächer)",
|
||||
"auxiliary_heater": "Kiegészítő fűtés",
|
||||
"night_heater": "Éjszakai fűtés (állóhelyi fűtés)",
|
||||
"engine_preheater": "Motor előmelegítő",
|
||||
"roof_vent": "Tetőszellőző",
|
||||
"power_vent": "Elektromos tetőszellőző",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 2. Alvás & Életvitel
|
||||
# ═══════════════════════════════════════════════
|
||||
"sleeper_cabin": "Hálófülke",
|
||||
"single_bunk": "Egyágyas fekhely",
|
||||
"double_bunk": "Kétágyas fekhely",
|
||||
"upper_bunk": "Felső ágy",
|
||||
"lower_bunk": "Alsó ágy",
|
||||
"fridge": "Hűtőszekrény",
|
||||
"freezer": "Fagyasztó",
|
||||
"microwave": "Mikrohullámú sütő",
|
||||
"coffee_machine": "Kávéfőző",
|
||||
"water_heater": "Vízmelegítő",
|
||||
"cabin_table": "Fülke asztal",
|
||||
"curtain_set": "Függönykészlet",
|
||||
"sun_visor": "Napszemüvegtartó / napellenző",
|
||||
"cabin_lighting": "Fülke világítás",
|
||||
"led_cabin_lighting": "LED fülke világítás",
|
||||
"reading_light": "Olvasólámpa",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 3. Kényelem & Ergonómia
|
||||
# ═══════════════════════════════════════════════
|
||||
"driver_seat_suspension": "Légrugós vezetőülés",
|
||||
"passenger_seat": "Utasülés",
|
||||
"passenger_seat_swivel": "Forgatható utasülés",
|
||||
"seat_heating": "Ülésfűtés",
|
||||
"seat_ventilation": "Ülésszellőzés",
|
||||
"armrest": "Karfák",
|
||||
"adjustable_steering": "Állítható kormány",
|
||||
"electric_windows": "Elektromos ablakok",
|
||||
"central_locking": "Központi zár",
|
||||
"storage_box": "Tároló doboz",
|
||||
"wardrobe": "Gardrób / ruhatároló",
|
||||
"bed_extension": "Ágy kihúzó panel",
|
||||
"privacy_curtain": "Adatvédő függöny",
|
||||
"insulation_package": "Hang- és hőszigetelés csomag",
|
||||
},
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# MULTIMEDIA (Multimédia)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
"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ó",
|
||||
"satellite_radio": "Műholdas rádió",
|
||||
"digital_tv": "Digitális TV",
|
||||
"dvb_t": "DVB-T (földi digitális 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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# HELPER FUNCTIONS
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def get_all_feature_keys() -> list[str]:
|
||||
"""Return all possible feature keys across all vehicle types."""
|
||||
keys: list[str] = []
|
||||
for category in CAR_FEATURES.values():
|
||||
keys.extend(category.keys())
|
||||
for category in MOTORCYCLE_FEATURES.values():
|
||||
keys.extend(category.keys())
|
||||
for category in LCV_FEATURES.values():
|
||||
keys.extend(category.keys())
|
||||
for category in HGV_FEATURES.values():
|
||||
keys.extend(category.keys())
|
||||
return list(set(keys))
|
||||
|
||||
|
||||
def get_feature_label(key: str, lang: str = "hu") -> str:
|
||||
"""Look up a feature label by key across all vehicle type dictionaries."""
|
||||
hu_labels: dict[str, dict[str, str]] = {
|
||||
"car": CAR_FEATURES,
|
||||
"motorcycle": MOTORCYCLE_FEATURES,
|
||||
"lcv": LCV_FEATURES,
|
||||
"hgv": HGV_FEATURES,
|
||||
}
|
||||
for vehicle_type, categories in hu_labels.items():
|
||||
for category_key, features in categories.items():
|
||||
if key in features:
|
||||
return features[key]
|
||||
return key
|
||||
|
||||
|
||||
def get_features_by_category(
|
||||
vehicle_type: str, category: str
|
||||
) -> dict[str, str]:
|
||||
"""Return features for a specific vehicle type and category."""
|
||||
mapping: dict[str, dict[str, dict[str, str]]] = {
|
||||
"car": CAR_FEATURES,
|
||||
"motorcycle": MOTORCYCLE_FEATURES,
|
||||
"lcv": LCV_FEATURES,
|
||||
"hgv": HGV_FEATURES,
|
||||
}
|
||||
vehicle_features = mapping.get(vehicle_type, {})
|
||||
return vehicle_features.get(category, {})
|
||||
|
||||
|
||||
def get_motorcycle_feature_label(key: str) -> str:
|
||||
"""Look up a motorcycle feature label by key."""
|
||||
for category in MOTORCYCLE_FEATURES.values():
|
||||
if key in category:
|
||||
return category[key]
|
||||
return key
|
||||
|
||||
|
||||
def get_lcv_feature_label(key: str) -> str:
|
||||
"""Look up an LCV feature label by key."""
|
||||
for category in LCV_FEATURES.values():
|
||||
if key in category:
|
||||
return category[key]
|
||||
return key
|
||||
|
||||
|
||||
def get_hgv_feature_label(key: str) -> str:
|
||||
"""Look up an HGV feature label by key."""
|
||||
for category in HGV_FEATURES.values():
|
||||
if key in category:
|
||||
return category[key]
|
||||
return key
|
||||
@@ -62,6 +62,7 @@ class UserStats(Base):
|
||||
penalty_quota_remaining: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
places_discovered: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
places_validated: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
providers_added_count: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
banned_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -115,6 +115,22 @@ class Organization(Base):
|
||||
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
|
||||
)
|
||||
|
||||
# --- 🔍 CROWDSOURCED SEARCH FIELDS ---
|
||||
# Provider nicknames / alternative names for matching (e.g. ["MOL", "MOL LUB", "MOL Magyarország"])
|
||||
aliases: Mapped[list] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=list,
|
||||
server_default=text("'[]'::jsonb")
|
||||
)
|
||||
# Crowdsourced evaluation characteristics / tags (e.g. ["gyors", "megbízható", "kedvező ár"])
|
||||
tags: Mapped[list] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=list,
|
||||
server_default=text("'[]'::jsonb")
|
||||
)
|
||||
|
||||
# A technikai tulajdonos (User fiók - törölhető)
|
||||
owner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ class Asset(Base):
|
||||
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())
|
||||
|
||||
# === WARRANTY ===
|
||||
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)
|
||||
|
||||
# === SALES MODULE ===
|
||||
is_for_sale: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
price: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/asset.py
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator, field_validator
|
||||
from typing import Optional, Dict, Any, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
@@ -82,6 +82,10 @@ class AssetResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
# === WARRANTY ===
|
||||
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")
|
||||
|
||||
# === SALES MODULE ===
|
||||
is_for_sale: bool = Field(default=False)
|
||||
price: Optional[float] = None
|
||||
@@ -128,6 +132,47 @@ class AssetCreate(BaseModel):
|
||||
return None
|
||||
return v
|
||||
|
||||
# ── Data normalization validators ──
|
||||
@field_validator('brand')
|
||||
@classmethod
|
||||
def normalize_brand(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Brand: UPPERCASE, strip whitespace."""
|
||||
if v is None or not isinstance(v, str):
|
||||
return v
|
||||
return v.strip().upper()
|
||||
|
||||
@field_validator('model')
|
||||
@classmethod
|
||||
def normalize_model(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Model: UPPERCASE, remove ALL spaces."""
|
||||
if v is None or not isinstance(v, str):
|
||||
return v
|
||||
return v.upper().replace(' ', '')
|
||||
|
||||
# ── Model validator: normalize type_designation inside individual_equipment ──
|
||||
@model_validator(mode='after')
|
||||
def normalize_individual_equipment(self):
|
||||
"""Normalize type_designation inside individual_equipment.car_specs.
|
||||
|
||||
SAFE None handling: checks every level before calling .upper().
|
||||
- individual_equipment may be None or empty dict
|
||||
- car_specs may be None or missing key
|
||||
- type_designation may be None, empty string, or a valid string
|
||||
"""
|
||||
if not self.individual_equipment:
|
||||
return self
|
||||
if not isinstance(self.individual_equipment, dict):
|
||||
return self
|
||||
|
||||
car_specs = self.individual_equipment.get('car_specs')
|
||||
if not car_specs or not isinstance(car_specs, dict):
|
||||
return self
|
||||
|
||||
td = car_specs.get('type_designation')
|
||||
if td is not None and isinstance(td, str) and td.strip():
|
||||
car_specs['type_designation'] = td.strip().upper().replace(' ', '')
|
||||
return self
|
||||
|
||||
# ── Root validator: ensure at least one of vin or license_plate is provided ──
|
||||
@root_validator(skip_on_failure=True)
|
||||
def validate_vin_or_plate(cls, values):
|
||||
@@ -166,7 +211,7 @@ class AssetCreate(BaseModel):
|
||||
trim_level: Optional[str] = Field(None, max_length=100, description="Felszereltségi szint")
|
||||
roof_type: Optional[str] = Field(None, max_length=50, description="Tető típus")
|
||||
audio_system_type: Optional[str] = Field(None, max_length=100, description="Hangrendszer")
|
||||
individual_equipment: Dict[str, Any] = Field(default_factory=dict, description="Egyedi felszerelések")
|
||||
individual_equipment: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Egyedi felszerelések")
|
||||
|
||||
# === MILEAGE (Optional) ===
|
||||
current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás")
|
||||
@@ -179,6 +224,10 @@ class AssetCreate(BaseModel):
|
||||
organization_id: Optional[int] = Field(None, description="Szervezet ID (alapértelmezett a felhasználó szervezete)")
|
||||
branch_id: Optional[UUID] = Field(None, description="Garázs (Branch) ID, ahova a járművet rendeljük. Ha nincs megadva, a szervezet központi garázsába kerül.")
|
||||
|
||||
# === WARRANTY ===
|
||||
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")
|
||||
|
||||
# === PRIMARY VEHICLE FLAG ===
|
||||
is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)")
|
||||
|
||||
@@ -224,6 +273,47 @@ class AssetUpdate(BaseModel):
|
||||
return None
|
||||
return v
|
||||
|
||||
# ── Data normalization validators ──
|
||||
@field_validator('brand')
|
||||
@classmethod
|
||||
def normalize_brand(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Brand: UPPERCASE, strip whitespace."""
|
||||
if v is None or not isinstance(v, str):
|
||||
return v
|
||||
return v.strip().upper()
|
||||
|
||||
@field_validator('model')
|
||||
@classmethod
|
||||
def normalize_model(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Model: UPPERCASE, remove ALL spaces."""
|
||||
if v is None or not isinstance(v, str):
|
||||
return v
|
||||
return v.upper().replace(' ', '')
|
||||
|
||||
# ── Model validator: normalize type_designation inside individual_equipment ──
|
||||
@model_validator(mode='after')
|
||||
def normalize_individual_equipment(self):
|
||||
"""Normalize type_designation inside individual_equipment.car_specs.
|
||||
|
||||
SAFE None handling: checks every level before calling .upper().
|
||||
- individual_equipment may be None or empty dict
|
||||
- car_specs may be None or missing key
|
||||
- type_designation may be None, empty string, or a valid string
|
||||
"""
|
||||
if not self.individual_equipment:
|
||||
return self
|
||||
if not isinstance(self.individual_equipment, dict):
|
||||
return self
|
||||
|
||||
car_specs = self.individual_equipment.get('car_specs')
|
||||
if not car_specs or not isinstance(car_specs, dict):
|
||||
return self
|
||||
|
||||
td = car_specs.get('type_designation')
|
||||
if td is not None and isinstance(td, str) and td.strip():
|
||||
car_specs['type_designation'] = td.strip().upper().replace(' ', '')
|
||||
return self
|
||||
|
||||
# ── Model validator: ensure at least one of vin or license_plate is provided ──
|
||||
@model_validator(mode='after')
|
||||
def validate_vin_or_plate(self):
|
||||
@@ -276,8 +366,63 @@ class AssetUpdate(BaseModel):
|
||||
year_of_manufacture: Optional[int] = Field(None, ge=1900, le=2100, description="Gyártási év")
|
||||
first_registration_date: Optional[datetime] = Field(None, description="Első forgalomba helyezés dátuma")
|
||||
|
||||
# === WARRANTY ===
|
||||
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")
|
||||
|
||||
# === STATUS ===
|
||||
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ű")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AssetEventCreate(BaseModel):
|
||||
"""Schema for creating a new AssetEvent (Service Book entry)."""
|
||||
event_type: str = Field(..., description="Event type: SERVICE, REPAIR, ACCIDENT, INSPECTION, TIRE_CHANGE, MAINTENANCE, UPGRADE, RECALL")
|
||||
event_date: Optional[datetime] = Field(None, description="Event date (default: now)")
|
||||
odometer_reading: Optional[int] = Field(None, ge=0, description="Odometer reading at event time")
|
||||
description: Optional[str] = Field(None, max_length=2000, description="Event description")
|
||||
cost_id: Optional[UUID] = Field(None, description="Associated cost record ID")
|
||||
cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.")
|
||||
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AssetEventResponse(BaseModel):
|
||||
"""Schema for returning an AssetEvent."""
|
||||
id: UUID
|
||||
asset_id: UUID
|
||||
user_id: Optional[int] = None
|
||||
organization_id: Optional[int] = None
|
||||
event_type: str
|
||||
odometer_reading: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
cost_id: Optional[UUID] = None
|
||||
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
|
||||
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
|
||||
event_date: datetime
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def model_validate(cls, obj, **kwargs):
|
||||
"""Override to resolve cost_amount and currency from the related AssetCost."""
|
||||
data = {}
|
||||
if hasattr(obj, '__dict__'):
|
||||
# Copy ORM attributes
|
||||
for field in cls.model_fields:
|
||||
if hasattr(obj, field):
|
||||
data[field] = getattr(obj, field)
|
||||
|
||||
# Resolve cost_amount and currency from the cost relationship
|
||||
cost = getattr(obj, 'cost', None)
|
||||
if cost is not None:
|
||||
if data.get('cost_amount') is None:
|
||||
data['cost_amount'] = float(cost.amount_net) if cost.amount_net is not None else None
|
||||
if data.get('currency') is None:
|
||||
data['currency'] = cost.currency
|
||||
return cls(**data)
|
||||
@@ -60,6 +60,9 @@ class OrganizationUpdate(BaseModel):
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# ── Crowdsourced search fields ──
|
||||
aliases: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class OrganizationResponse(BaseModel):
|
||||
@@ -79,5 +82,7 @@ class OrganizationResponse(BaseModel):
|
||||
visual_settings: Optional[dict] = None
|
||||
notification_settings: Optional[Any] = None
|
||||
external_integration_config: Optional[Any] = None
|
||||
aliases: List[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
112
backend/app/schemas/provider.py
Normal file
112
backend/app/schemas/provider.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Provider (szolgáltató) Pydantic sémák a crowdsourced partnerkeresőhöz.
|
||||
Tartalmazza a keresési, gyors felvételi és szerkesztési sémákat.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
|
||||
- street -> address_street_name, address_street_type, address_house_number
|
||||
- A ProviderSearchResult is tartalmazza az atomizált címmezőket.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class ExpertiseCategoryOut(BaseModel):
|
||||
"""Expertise kategória kimeneti sémája a frontend dropdown számára."""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
category: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProviderSearchResult(BaseModel):
|
||||
"""Egy szolgáltató keresési találatának adatai.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- address_street_name, address_street_type, address_house_number
|
||||
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
|
||||
"""
|
||||
id: int
|
||||
name: str
|
||||
category: Optional[str] = None
|
||||
specialization: List[str] = Field(default_factory=list)
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
source: str = Field(..., description="Forrás: verified_org | staged_data | crowd_added")
|
||||
is_verified: bool = False
|
||||
rating: Optional[float] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProviderSearchResponse(BaseModel):
|
||||
"""Keresési eredmények lapozással."""
|
||||
results: List[ProviderSearchResult] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
per_page: int = 20
|
||||
|
||||
|
||||
class ProviderQuickAddIn(BaseModel):
|
||||
"""Gyors szolgáltató felvétel bemeneti adatai.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
category_id: int = Field(..., ge=1, description="Kategória ID (marketplace.expertise_tags.id)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
|
||||
|
||||
|
||||
class ProviderQuickAddResponse(BaseModel):
|
||||
"""Gyors szolgáltató felvétel válasza."""
|
||||
id: int
|
||||
name: str
|
||||
status: str = "pending_verification"
|
||||
message: str = "Szolgáltató sikeresen rögzítve. Ellenőrzés alatt."
|
||||
earned_points: int = 0
|
||||
|
||||
|
||||
class ProviderUpdateIn(BaseModel):
|
||||
"""Szolgáltató adatainak szerkesztése (PUT /providers/{id}).
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
- Tartalmazza a contact_phone, contact_email, website és tags mezőket is.
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
|
||||
|
||||
|
||||
class ProviderUpdateResponse(BaseModel):
|
||||
"""Szolgáltató szerkesztés válasza."""
|
||||
id: int
|
||||
name: str
|
||||
message: str = "Szolgáltató adatai sikeresen frissítve."
|
||||
242
backend/app/scripts/heal_user_data.py
Normal file
242
backend/app/scripts/heal_user_data.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
🤖 Data Healing Script — Wallet & Referral Code Repair
|
||||
|
||||
Detects and fixes missing Wallet and InvitationCode (referral_code) records
|
||||
for existing User and Organization entities.
|
||||
|
||||
Usage:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/heal_user_data.py
|
||||
|
||||
Logs:
|
||||
- Prints a summary of healed records to stdout
|
||||
- Writes detailed log to logs/heal_user_data_{timestamp}.log
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
# Ensure the backend app is on the path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.identity import User, Wallet
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
# ── Logging setup ──────────────────────────────────────────────────────────
|
||||
LOG_DIR = "/app/backend/logs"
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
log_filename = f"heal_user_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||
log_path = os.path.join(LOG_DIR, log_filename)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(log_path),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger("heal-user-data")
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_referral_code() -> str:
|
||||
"""Generate a short, unique referral code (8 chars, uppercase)."""
|
||||
import secrets
|
||||
import string
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(8))
|
||||
|
||||
|
||||
# ── Main healing logic ─────────────────────────────────────────────────────
|
||||
|
||||
async def heal_users(db: AsyncSession) -> Dict[str, int]:
|
||||
"""
|
||||
Find all Users without a Wallet and create one (0 balance).
|
||||
Find all Users without a referral_code and generate one.
|
||||
"""
|
||||
stats = {"wallet_created": 0, "referral_code_generated": 0}
|
||||
|
||||
# 1. Fetch all users
|
||||
result = await db.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
logger.info(f"Found {len(users)} total User records.")
|
||||
|
||||
for user in users:
|
||||
# ── Wallet check ──
|
||||
wallet_stmt = select(Wallet).where(Wallet.user_id == user.id)
|
||||
wallet_result = await db.execute(wallet_stmt)
|
||||
existing_wallet = wallet_result.scalar_one_or_none()
|
||||
|
||||
if existing_wallet is None:
|
||||
new_wallet = Wallet(
|
||||
user_id=user.id,
|
||||
earned_credits=0,
|
||||
purchased_credits=0,
|
||||
service_coins=0,
|
||||
currency="HUF",
|
||||
)
|
||||
db.add(new_wallet)
|
||||
stats["wallet_created"] += 1
|
||||
logger.info(f" [Wallet] Created for User ID={user.id} ({user.email})")
|
||||
else:
|
||||
logger.debug(f" [Wallet] Already exists for User ID={user.id}")
|
||||
|
||||
# ── Referral code check ──
|
||||
if not user.referral_code:
|
||||
code = generate_referral_code()
|
||||
# Ensure uniqueness
|
||||
while True:
|
||||
dup_check = await db.execute(
|
||||
select(User).where(User.referral_code == code)
|
||||
)
|
||||
if dup_check.scalar_one_or_none() is None:
|
||||
break
|
||||
code = generate_referral_code()
|
||||
|
||||
user.referral_code = code
|
||||
stats["referral_code_generated"] += 1
|
||||
logger.info(
|
||||
f" [Referral] Generated code '{code}' for User ID={user.id} ({user.email})"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f" [Referral] Already has code '{user.referral_code}' for User ID={user.id}"
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def heal_organizations(db: AsyncSession) -> Dict[str, int]:
|
||||
"""
|
||||
Check Organizations for wallet coverage.
|
||||
|
||||
NOTE: The `wallets` table has:
|
||||
- user_id: NOT NULL + UNIQUE constraint
|
||||
- organization_id: nullable
|
||||
|
||||
This means each user can have exactly one wallet, and organization
|
||||
wallets share the user's wallet via organization_id. Since user wallets
|
||||
are already created in heal_users(), org wallets are inherently covered.
|
||||
|
||||
We only log existing org wallet links for audit purposes.
|
||||
"""
|
||||
stats = {"org_wallet_created": 0, "org_wallet_skipped": 0}
|
||||
|
||||
result = await db.execute(select(Organization))
|
||||
orgs = result.scalars().all()
|
||||
logger.info(f"Found {len(orgs)} total Organization records.")
|
||||
|
||||
for org in orgs:
|
||||
# Check if an org wallet already exists (linked via organization_id)
|
||||
wallet_stmt = select(Wallet).where(Wallet.organization_id == org.id)
|
||||
wallet_result = await db.execute(wallet_stmt)
|
||||
existing_wallet = wallet_result.scalar_one_or_none()
|
||||
|
||||
if existing_wallet is None:
|
||||
# The wallets.user_id is NOT NULL + UNIQUE, so we cannot create
|
||||
# a separate wallet for an org. The org's owner already has a
|
||||
# personal wallet from heal_users(). We skip org wallet creation
|
||||
# and log the situation.
|
||||
owner_id = getattr(org, 'owner_id', None)
|
||||
if owner_id:
|
||||
# Check if owner has a wallet we could link
|
||||
owner_wallet = await db.execute(
|
||||
select(Wallet).where(Wallet.user_id == owner_id)
|
||||
)
|
||||
owner_wallet = owner_wallet.scalar_one_or_none()
|
||||
if owner_wallet:
|
||||
# Link the existing wallet to this org
|
||||
owner_wallet.organization_id = org.id
|
||||
stats["org_wallet_created"] += 1
|
||||
logger.info(
|
||||
f" [OrgWallet] Linked existing wallet (user_id={owner_id}) "
|
||||
f"to Organization ID={org.id} ({org.name})"
|
||||
)
|
||||
else:
|
||||
stats["org_wallet_skipped"] += 1
|
||||
logger.warning(
|
||||
f" [OrgWallet] SKIPPED for Organization ID={org.id} ({org.name}) — "
|
||||
f"owner (user_id={owner_id}) has no wallet yet."
|
||||
)
|
||||
else:
|
||||
stats["org_wallet_skipped"] += 1
|
||||
logger.warning(
|
||||
f" [OrgWallet] SKIPPED for Organization ID={org.id} ({org.name}) — "
|
||||
f"no owner_id found."
|
||||
)
|
||||
else:
|
||||
logger.debug(f" [OrgWallet] Already linked for Organization ID={org.id}")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point."""
|
||||
logger.info("=" * 60)
|
||||
logger.info(" DATA HEALING SCRIPT — Wallet & Referral Code Repair")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Build async engine from the same DATABASE_URL
|
||||
db_url = settings.DATABASE_URL
|
||||
# If the URL starts with postgresql://, convert to postgresql+asyncpg://
|
||||
if db_url.startswith("postgresql://") and "+asyncpg" not in db_url:
|
||||
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(db_url, echo=False, pool_pre_ping=True)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
try:
|
||||
async with db.begin():
|
||||
user_stats = await heal_users(db)
|
||||
org_stats = await heal_organizations(db)
|
||||
|
||||
total_healed = (
|
||||
user_stats["wallet_created"]
|
||||
+ user_stats["referral_code_generated"]
|
||||
+ org_stats["org_wallet_created"]
|
||||
)
|
||||
|
||||
logger.info("─" * 60)
|
||||
logger.info(" ✅ HEALING COMPLETE — Summary")
|
||||
logger.info(f" User wallets created: {user_stats['wallet_created']}")
|
||||
logger.info(f" Referral codes generated: {user_stats['referral_code_generated']}")
|
||||
logger.info(f" Organization wallets created: {org_stats['org_wallet_created']}")
|
||||
logger.info(f" Organization wallets skipped: {org_stats['org_wallet_skipped']}")
|
||||
logger.info(f" ─────────────────────────────────")
|
||||
logger.info(f" TOTAL records healed: {total_healed}")
|
||||
logger.info("─" * 60)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" ✅ DATA HEALING COMPLETE")
|
||||
print(f" Log file: {log_path}")
|
||||
print(f"{'='*60}")
|
||||
print(f" User wallets created: {user_stats['wallet_created']}")
|
||||
print(f" Referral codes generated: {user_stats['referral_code_generated']}")
|
||||
print(f" Organization wallets created: {org_stats['org_wallet_created']}")
|
||||
print(f" Organization wallets skipped: {org_stats['org_wallet_skipped']}")
|
||||
print(f" ─────────────────────────────────")
|
||||
print(f" TOTAL records healed: {total_healed}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Healing failed: {e}", exc_info=True)
|
||||
print(f"\n❌ ERROR: Healing failed — {e}\n")
|
||||
raise
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
156
backend/app/scripts/seed_expertise_tags.py
Normal file
156
backend/app/scripts/seed_expertise_tags.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Seed script: Feltölti a marketplace.expertise_tags táblát alap kategóriákkal.
|
||||
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||
|
||||
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_tags.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, func
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ExpertiseTag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_CATEGORIES = [
|
||||
{
|
||||
"key": "auto_szerelo",
|
||||
"name_hu": "Autószerelő",
|
||||
"name_en": "Car Mechanic",
|
||||
"category": "vehicle_service",
|
||||
"search_keywords": ["szerelő", "autószerelő", "mechanic", "garage", "javítás"],
|
||||
"description": "Személy- és haszongépjárművek mechanikai javítása, karbantartása",
|
||||
},
|
||||
{
|
||||
"key": "motor_szerelo",
|
||||
"name_hu": "Motorkerékpár szerelő",
|
||||
"name_en": "Motorcycle Mechanic",
|
||||
"category": "vehicle_service",
|
||||
"search_keywords": ["motor", "motorszerelő", "motorcycle", "robogó"],
|
||||
"description": "Motorkerékpárok, robogók javítása és karbantartása",
|
||||
},
|
||||
{
|
||||
"key": "gumiszerviz",
|
||||
"name_hu": "Gumiszerviz",
|
||||
"name_en": "Tire Service",
|
||||
"category": "vehicle_service",
|
||||
"search_keywords": ["gumi", "gumis", "tire", "tyre", "abroncs", "kerék"],
|
||||
"description": "Gumiabroncsok cseréje, javítása, téli/nyári gumik tárolása",
|
||||
},
|
||||
{
|
||||
"key": "karosszerialakatos",
|
||||
"name_hu": "Karosszérialakatos",
|
||||
"name_en": "Body Shop",
|
||||
"category": "body_paint",
|
||||
"search_keywords": ["karosszéria", "lakatos", "bodyshop", "karosszerialakatos", "kasztni"],
|
||||
"description": "Karosszéria javítás, lakatolás, kasztni javítás",
|
||||
},
|
||||
{
|
||||
"key": "fényező",
|
||||
"name_hu": "Fényező",
|
||||
"name_en": "Painter",
|
||||
"category": "body_paint",
|
||||
"search_keywords": ["fényező", "fényezés", "painter", "spray", "lakkozás"],
|
||||
"description": "Gépjármű fényezés, lakkozás, színjavítás",
|
||||
},
|
||||
{
|
||||
"key": "autómentő",
|
||||
"name_hu": "Autómentő / Motormentő",
|
||||
"name_en": "Tow Truck / Roadside Assistance",
|
||||
"category": "roadside",
|
||||
"search_keywords": ["mentő", "autómentő", "tow", "roadside", "assistance", "segély"],
|
||||
"description": "Útsegély, autómentés, motormentés, kiszállás",
|
||||
},
|
||||
{
|
||||
"key": "benzinkút",
|
||||
"name_hu": "Benzinkút",
|
||||
"name_en": "Gas Station",
|
||||
"category": "fuel",
|
||||
"search_keywords": ["benzin", "kút", "gas station", "fuel", "dízel", "töltő"],
|
||||
"description": "Üzemanyag töltőállomások, benzinkutak",
|
||||
},
|
||||
{
|
||||
"key": "alkatrész_kereskedés",
|
||||
"name_hu": "Alkatrész kereskedés",
|
||||
"name_en": "Parts Store",
|
||||
"category": "parts",
|
||||
"search_keywords": ["alkatrész", "parts", "spares", "autóalkatrész"],
|
||||
"description": "Gépjármű alkatrészek kereskedelme",
|
||||
},
|
||||
{
|
||||
"key": "vizsgaállomás",
|
||||
"name_hu": "Vizsgaállomás",
|
||||
"name_en": "Inspection Station",
|
||||
"category": "inspection",
|
||||
"search_keywords": ["vizsga", "műszaki", "inspection", "MOT", "vizsgáztatás"],
|
||||
"description": "Műszaki vizsgáztatás, emisszió mérés",
|
||||
},
|
||||
{
|
||||
"key": "autókozmetika",
|
||||
"name_hu": "Autókozmetika",
|
||||
"name_en": "Car Detailing",
|
||||
"category": "detailing",
|
||||
"search_keywords": ["kozmetika", "detailing", "takarítás", "mosás", "polírozás"],
|
||||
"description": "Autókozmetika, részletes takarítás, polírozás, kerámia bevonat",
|
||||
},
|
||||
{
|
||||
"key": "egyéb",
|
||||
"name_hu": "Egyéb szolgáltató",
|
||||
"name_en": "Other Service",
|
||||
"category": "other",
|
||||
"search_keywords": ["egyéb", "other", "szolgáltató", "service"],
|
||||
"description": "Egyéb gépjárművel kapcsolatos szolgáltatás",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_expertise_tags():
|
||||
"""Feltölti az expertise_tags táblát az alap kategóriákkal."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Ellenőrizzük, van-e már adat
|
||||
stmt = select(func.count(ExpertiseTag.id))
|
||||
result = await db.execute(stmt)
|
||||
count = result.scalar()
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"Az expertise_tags tábla már tartalmaz {count} rekordot. Feltöltés kihagyva.")
|
||||
print(f"✅ Az expertise_tags tábla már tartalmaz {count} rekordot. Nincs szükség seed-elésre.")
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
for cat in BASE_CATEGORIES:
|
||||
tag = ExpertiseTag(
|
||||
key=cat["key"],
|
||||
name_hu=cat["name_hu"],
|
||||
name_en=cat["name_en"],
|
||||
category=cat["category"],
|
||||
search_keywords=cat["search_keywords"],
|
||||
description=cat["description"],
|
||||
is_official=True,
|
||||
discovery_points=10,
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(tag)
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Sikeresen beszúrva {inserted} kategória az expertise_tags táblába.")
|
||||
print(f"✅ Sikeresen beszúrva {inserted} kategória az expertise_tags táblába.")
|
||||
|
||||
# Ellenőrzés
|
||||
result = await db.execute(select(func.count(ExpertiseTag.id)))
|
||||
final_count = result.scalar()
|
||||
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a seed-elés során: {e}")
|
||||
print(f"❌ Hiba: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
asyncio.run(seed_expertise_tags())
|
||||
118
backend/app/scripts/seed_gamification_rules.py
Normal file
118
backend/app/scripts/seed_gamification_rules.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Gamification Point Rules Seed Script
|
||||
=====================================
|
||||
Cél: Dinamikus pontszabályok feltöltése a gamification.point_rules táblába.
|
||||
A pontértékek NINCSENEK beégetve a kódba, hanem az adatbázisból
|
||||
(point_rules tábla) olvassuk ki őket futásidőben.
|
||||
|
||||
A Vezető Tervező utasítása szerint:
|
||||
- TILOS hardcoded pontszámokat használni a service rétegben!
|
||||
- Minden pontértéket a point_rules táblából kell lekérdezni action_key alapján.
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_gamification_rules.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt gyökér hozzáadása a Python path-hoz
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.gamification.gamification import PointRule
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("Seed-Gamification-Rules")
|
||||
|
||||
# A seed-elendő pontszabályok definíciói
|
||||
# FIGYELEM: Ezek a definíciók csak a seed script számára vannak itt.
|
||||
# A service rétegben a pontokat az adatbázisból (point_rules tábla) kell kiolvasni!
|
||||
SEED_RULES = [
|
||||
{
|
||||
"action_key": "ADD_NEW_PROVIDER",
|
||||
"points": 500,
|
||||
"description": "Új szolgáltató rögzítése a rendszerbe",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "USE_UNVERIFIED_PROVIDER",
|
||||
"points": 200,
|
||||
"description": "Szervizesemény/költség rögzítése olyan szolgáltatónál, aminek még nincs 5 megerősítése",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "RATE_PROVIDER",
|
||||
"points": 250,
|
||||
"description": "Szolgáltató értékelése (tagekkel)",
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_point_rules():
|
||||
"""
|
||||
Feltölti a gamification.point_rules táblát a fenti szabályokkal,
|
||||
de csak azokat a rekordokat szúrja be, amelyek még nem léteznek
|
||||
(action_key alapján UPSERT helyett INSERT WHERE NOT EXISTS).
|
||||
"""
|
||||
# Adatbázis kapcsolat létrehozása
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
inserted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for rule_data in SEED_RULES:
|
||||
# Ellenőrizzük, hogy létezik-e már ez a szabály
|
||||
stmt = select(PointRule).where(
|
||||
PointRule.action_key == rule_data["action_key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(
|
||||
f"⏭️ Szabály már létezik: {rule_data['action_key']} "
|
||||
f"(pont: {existing.points}, ID: {existing.id})"
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Új szabály beszúrása
|
||||
new_rule = PointRule(
|
||||
action_key=rule_data["action_key"],
|
||||
points=rule_data["points"],
|
||||
description=rule_data["description"],
|
||||
is_active=rule_data["is_active"],
|
||||
)
|
||||
db.add(new_rule)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"✅ Szabály létrehozva: {rule_data['action_key']} "
|
||||
f"(pont: {rule_data['points']}, ID: {new_rule.id})"
|
||||
)
|
||||
inserted_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"\n📊 Összegzés: {inserted_count} új szabály beszúrva, "
|
||||
f"{skipped_count} már létezett."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_point_rules())
|
||||
@@ -287,7 +287,7 @@ class AssetService:
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Asset Creation Error: {e}")
|
||||
logger.error(f"Asset Creation Error: {e}", exc_info=True)
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
@@ -360,8 +360,11 @@ class AssetService:
|
||||
# --- CATALOG METHODS ---
|
||||
@staticmethod
|
||||
async def get_makes(db: AsyncSession, vehicle_class: Optional[str] = None) -> List[str]:
|
||||
"""Get all distinct makes from vehicle model definitions, optionally filtered by vehicle_class."""
|
||||
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
|
||||
"""Get all distinct makes from vehicle model definitions, optionally filtered by vehicle_class.
|
||||
|
||||
Uses func.upper() to normalize brand casing and deduplicate case-insensitively.
|
||||
"""
|
||||
stmt = select(distinct(func.upper(VehicleModelDefinition.make))).order_by(func.upper(VehicleModelDefinition.make))
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
result = await db.execute(stmt)
|
||||
@@ -417,10 +420,11 @@ class AssetService:
|
||||
async def get_catalog_brands(db: AsyncSession, vehicle_class: Optional[str] = None, query: Optional[str] = None) -> List[str]:
|
||||
"""Get all distinct brands (makes) from vehicle_model_definitions, with optional class filter and search query.
|
||||
|
||||
Uses func.upper() to normalize brand casing and deduplicate case-insensitively.
|
||||
Fuzzy matching: removes spaces and lowercases both the query and stored values
|
||||
so that 'BMW' matches 'bmw' and 'CB 1000' matches 'CB1000'.
|
||||
"""
|
||||
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
|
||||
stmt = select(distinct(func.upper(VehicleModelDefinition.make))).order_by(func.upper(VehicleModelDefinition.make))
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
if query:
|
||||
@@ -487,8 +491,11 @@ class AssetService:
|
||||
@staticmethod
|
||||
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
|
||||
"""
|
||||
Get the vehicle limit for a user, checking both user-specific AND organization limits.
|
||||
Returns the HIGHER value of the two as per requirements.
|
||||
Get the vehicle limit for a user, checking:
|
||||
1. Config-based limits (user role, subscription plan, org-specific)
|
||||
2. Subscription tier JSONB rules['allowances']['max_vehicles']
|
||||
|
||||
Returns the HIGHEST value among all applicable limits.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
@@ -496,9 +503,10 @@ class AssetService:
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
Maximum allowed vehicles (higher of user limit and organization limit)
|
||||
Maximum allowed vehicles (highest of all applicable limits)
|
||||
"""
|
||||
from app.models.identity import User
|
||||
from app.models.core_logic import UserSubscription, SubscriptionTier
|
||||
from app.services.config_service import config
|
||||
|
||||
try:
|
||||
@@ -506,46 +514,74 @@ class AssetService:
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one()
|
||||
|
||||
# Get global vehicle limits configuration
|
||||
# ── 1. CONFIG-BASED LIMIT ──
|
||||
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
||||
if limits is None:
|
||||
logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}")
|
||||
# Fallback to very high limit instead of restricting users
|
||||
limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100}
|
||||
|
||||
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
subscription_plan = user.subscription_plan or "free"
|
||||
|
||||
# Get user-specific limit (based on role or subscription plan)
|
||||
user_limit = limits.get(user_role)
|
||||
if user_limit is None:
|
||||
# FIX: If role not found (e.g. "user" not in dict), try subscription plan
|
||||
user_limit = limits.get(subscription_plan.lower())
|
||||
if user_limit is None:
|
||||
# FIX: Ultimate fallback - safe default for free/basic users
|
||||
user_limit = limits.get("free", 1)
|
||||
config_limit = limits.get(user_role)
|
||||
if config_limit is None:
|
||||
config_limit = limits.get(subscription_plan.lower())
|
||||
if config_limit is None:
|
||||
config_limit = limits.get("free", 1)
|
||||
|
||||
# Get organization-specific limit (if configured)
|
||||
# ── 2. ORGANIZATION-SPECIFIC LIMIT ──
|
||||
org_limit = None
|
||||
try:
|
||||
org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id)
|
||||
if org_limits and isinstance(org_limits, dict):
|
||||
# Organization might have different limit structure
|
||||
# Try to get limit for user's role or use a default org limit
|
||||
org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower())
|
||||
if org_limit is None and "default" in org_limits:
|
||||
org_limit = org_limits["default"]
|
||||
except Exception as e:
|
||||
logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}")
|
||||
org_limit = None
|
||||
|
||||
# Log the calculated limit for debugging
|
||||
final_limit = user_limit
|
||||
# ── 3. SUBSCRIPTION TIER JSONB LIMIT ──
|
||||
# Query the user's active subscription and read rules['allowances']['max_vehicles']
|
||||
subscription_limit = None
|
||||
try:
|
||||
sub_stmt = (
|
||||
select(SubscriptionTier.rules)
|
||||
.select_from(UserSubscription)
|
||||
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
|
||||
.where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
sub_result = await db.execute(sub_stmt)
|
||||
tier_rules = sub_result.scalar_one_or_none()
|
||||
|
||||
if tier_rules and isinstance(tier_rules, dict):
|
||||
allowances = tier_rules.get('allowances', {})
|
||||
if isinstance(allowances, dict):
|
||||
max_vehicles = allowances.get('max_vehicles')
|
||||
if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
|
||||
subscription_limit = int(max_vehicles)
|
||||
logger.info(
|
||||
f"Subscription tier limit for user {user_id}: "
|
||||
f"max_vehicles={subscription_limit} (from rules={tier_rules})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}")
|
||||
|
||||
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
|
||||
final_limit = config_limit
|
||||
if org_limit is not None:
|
||||
final_limit = max(user_limit, org_limit)
|
||||
logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit={org_limit}, final={final_limit}")
|
||||
else:
|
||||
logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit=None, final={final_limit}")
|
||||
final_limit = max(final_limit, org_limit)
|
||||
if subscription_limit is not None:
|
||||
final_limit = max(final_limit, subscription_limit)
|
||||
|
||||
logger.info(
|
||||
f"Vehicle limit for user {user_id} (role={user_role}, plan={subscription_plan}): "
|
||||
f"config={config_limit}, org={org_limit}, subscription={subscription_limit}, "
|
||||
f"final={final_limit}"
|
||||
)
|
||||
|
||||
return final_limit
|
||||
|
||||
|
||||
650
backend/app/services/provider_service.py
Normal file
650
backend/app/services/provider_service.py
Normal file
@@ -0,0 +1,650 @@
|
||||
"""
|
||||
Provider Service – Szolgáltató keresés, gyors felvétel és szerkesztés logikája.
|
||||
|
||||
Gamification Integration (2026-06-16):
|
||||
=======================================
|
||||
A Vezető Tervező utasítása szerint a crowdsourcing funkciókba
|
||||
dinamikus Gamification horgok kerültek beépítésre.
|
||||
|
||||
ALAPELV: A pontértékek SOHA nincsenek beégetve (hardcoded) a kódba!
|
||||
Minden pontszámot a gamification.point_rules táblából olvasunk ki
|
||||
action_key alapján futásidőben.
|
||||
|
||||
Jelenlegi horgok:
|
||||
- ADD_NEW_PROVIDER: 500 pont (adatbázisból) + providers_added_count növelés
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
=====================================================
|
||||
- quick_add_provider: data.street -> data.address_street_name, address_street_type, address_house_number
|
||||
- update_provider: data.street -> data.address_street_name, address_street_type, address_house_number
|
||||
- search_providers: visszaadja az address_street_name, address_street_type, address_house_number mezőket
|
||||
- A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
|
||||
from app.models.marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging
|
||||
from app.models.identity.social import ServiceProvider
|
||||
from app.models.gamification.gamification import PointRule, UserStats
|
||||
from app.schemas.provider import (
|
||||
ProviderSearchResult,
|
||||
ProviderSearchResponse,
|
||||
ProviderQuickAddIn,
|
||||
ProviderQuickAddResponse,
|
||||
ProviderUpdateIn,
|
||||
ProviderUpdateResponse,
|
||||
)
|
||||
from app.services.gamification_service import gamification_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _award_provider_points(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
action_key: str,
|
||||
) -> int:
|
||||
"""
|
||||
Dinamikus pontjóváírás a gamification.point_rules tábla alapján.
|
||||
|
||||
Ez a függvény az adatbázisból olvassa ki a pontértéket az action_key
|
||||
alapján, így a pontok NINCSENEK beégetve a kódba. Az admin felületről
|
||||
bármikor módosíthatók a point_rules táblában.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
user_id: A felhasználó ID-ja
|
||||
action_key: A pontszabály azonosítója (pl. 'ADD_NEW_PROVIDER')
|
||||
|
||||
Returns:
|
||||
int: A jóváírt pontok száma (0 ha a szabály nem található vagy inaktív)
|
||||
"""
|
||||
# 1. Pontszabály lekérdezése az adatbázisból (dinamikus!)
|
||||
rule_stmt = select(PointRule).where(
|
||||
PointRule.action_key == action_key,
|
||||
PointRule.is_active == True,
|
||||
)
|
||||
rule_result = await db.execute(rule_stmt)
|
||||
rule = rule_result.scalar_one_or_none()
|
||||
|
||||
if not rule:
|
||||
logger.warning(
|
||||
f"Pontszabály '{action_key}' nem található vagy inaktív. "
|
||||
f"Pontjóváírás kihagyva user_id={user_id}."
|
||||
)
|
||||
return 0
|
||||
|
||||
points_to_award = rule.points
|
||||
logger.info(
|
||||
f"Dinamikus pont lekérés: action_key='{action_key}', "
|
||||
f"points={points_to_award} (forrás: gamification.point_rules tábla)"
|
||||
)
|
||||
|
||||
# 2. Pontok jóváírása a Gamification Service-en keresztül
|
||||
# A GamificationService.award_points() kezeli a szorzókat, szintlépést,
|
||||
# büntetés ledolgozást és a naplózást (PointsLedger).
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount=points_to_award,
|
||||
reason=f"ADD_NEW_PROVIDER: +{points_to_award} pont új szolgáltató rögzítéséért",
|
||||
commit=False, # A hívó (quick_add_provider) kezeli a commit-ot
|
||||
)
|
||||
|
||||
# 3. Statisztika növelése (providers_added_count)
|
||||
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
|
||||
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
|
||||
|
||||
if stats:
|
||||
stats.providers_added_count = UserStats.providers_added_count + 1
|
||||
logger.info(
|
||||
f"Statisztika frissítve: user_id={user_id}, "
|
||||
f"providers_added_count={stats.providers_added_count}"
|
||||
)
|
||||
else:
|
||||
# Ha még nincs UserStats rekordja, létrehozzuk
|
||||
stats = UserStats(
|
||||
user_id=user_id,
|
||||
total_xp=points_to_award,
|
||||
current_level=1,
|
||||
providers_added_count=1,
|
||||
)
|
||||
db.add(stats)
|
||||
logger.info(
|
||||
f"Új UserStats rekord létrehozva: user_id={user_id}, "
|
||||
f"providers_added_count=1"
|
||||
)
|
||||
|
||||
return points_to_award
|
||||
|
||||
|
||||
async def search_providers(
|
||||
db: AsyncSession,
|
||||
q: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> ProviderSearchResponse:
|
||||
"""
|
||||
Egyesített szolgáltató keresés három forrásból:
|
||||
1. fleet.organizations (verified orgs with org_type='service_provider')
|
||||
2. marketplace.service_staging (robot által gyűjtött adatok)
|
||||
3. marketplace.service_providers (crowdsourced adatok)
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Az org lekérdezés most már visszaadja
|
||||
az address_street_name, address_street_type, address_house_number mezőket is.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
q: Keresőszó (tokenizálva, AND kapcsolat) — opcionális
|
||||
category: Kategória szűrő
|
||||
city: Város szűrő — AND kapcsolat a q-val
|
||||
limit: Lapozási limit
|
||||
offset: Lapozási offset
|
||||
|
||||
Returns:
|
||||
ProviderSearchResponse lapozott találatokkal
|
||||
"""
|
||||
results = []
|
||||
total = 0
|
||||
|
||||
# Tokenizáljuk a q paramétert: szóközök mentén feldaraboljuk
|
||||
q_tokens: list[str] = []
|
||||
if q:
|
||||
q_tokens = [token.strip() for token in q.split() if token.strip()]
|
||||
|
||||
# --- 1. LEKÉRDEZÉS: Szervezetek (fleet.organizations) ---
|
||||
org_conditions = [
|
||||
Organization.org_type == OrgType.service_provider,
|
||||
Organization.is_deleted == False,
|
||||
]
|
||||
if q_tokens:
|
||||
token_conditions = []
|
||||
for token in q_tokens:
|
||||
token_conditions.append(
|
||||
or_(
|
||||
Organization.name.ilike(f"%{token}%"),
|
||||
cast(Organization.aliases, Text).ilike(f"%{token}%"),
|
||||
cast(Organization.tags, Text).ilike(f"%{token}%"),
|
||||
)
|
||||
)
|
||||
org_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
org_conditions.append(
|
||||
or_(
|
||||
Organization.address_city.ilike(f"%{city}%"),
|
||||
Organization.address_zip.ilike(f"%{city}%"),
|
||||
)
|
||||
)
|
||||
|
||||
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált
|
||||
# címmezőket (address_street_name, address_street_type, address_house_number).
|
||||
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán
|
||||
# ne duplikálódjon a városnév (pl. "Dunakeszi, Dunakeszi").
|
||||
org_stmt = select(
|
||||
Organization.id.label("id"),
|
||||
Organization.name.label("name"),
|
||||
Organization.address_city.label("city"),
|
||||
func.concat(
|
||||
Organization.address_zip,
|
||||
literal(' '),
|
||||
Organization.address_city,
|
||||
literal(', '),
|
||||
Organization.address_street_name,
|
||||
literal(' '),
|
||||
Organization.address_street_type,
|
||||
literal(' '),
|
||||
Organization.address_house_number,
|
||||
).label("address"),
|
||||
Organization.address_zip.label("address_zip"),
|
||||
Organization.address_street_name.label("address_street_name"),
|
||||
Organization.address_street_type.label("address_street_type"),
|
||||
Organization.address_house_number.label("address_house_number"),
|
||||
Organization.is_verified.label("is_verified"),
|
||||
ServiceProfile.contact_phone.label("contact_phone"),
|
||||
ServiceProfile.contact_email.label("contact_email"),
|
||||
ServiceProfile.website.label("website"),
|
||||
ServiceProfile.specialization_tags.label("specialization_tags"),
|
||||
case(
|
||||
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
|
||||
else_=literal("verified_org"),
|
||||
).label("source"),
|
||||
).outerjoin(
|
||||
ServiceProfile,
|
||||
ServiceProfile.organization_id == Organization.id,
|
||||
).where(*org_conditions)
|
||||
|
||||
# --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) ---
|
||||
staging_conditions = []
|
||||
if q_tokens:
|
||||
token_conditions = []
|
||||
for token in q_tokens:
|
||||
token_conditions.append(
|
||||
ServiceStaging.name.ilike(f"%{token}%")
|
||||
)
|
||||
staging_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
staging_conditions.append(
|
||||
or_(
|
||||
ServiceStaging.city.ilike(f"%{city}%"),
|
||||
ServiceStaging.postal_code.ilike(f"%{city}%"),
|
||||
)
|
||||
)
|
||||
|
||||
staging_stmt = select(
|
||||
ServiceStaging.id.label("id"),
|
||||
ServiceStaging.name.label("name"),
|
||||
ServiceStaging.city.label("city"),
|
||||
ServiceStaging.full_address.label("address"),
|
||||
literal(None).label("address_zip"),
|
||||
literal(None).label("address_street_name"),
|
||||
literal(None).label("address_street_type"),
|
||||
literal(None).label("address_house_number"),
|
||||
literal(False).label("is_verified"),
|
||||
literal(None).label("contact_phone"),
|
||||
literal(None).label("contact_email"),
|
||||
literal(None).label("website"),
|
||||
literal(None).label("specialization_tags"),
|
||||
literal("staged_data").label("source"),
|
||||
)
|
||||
if staging_conditions:
|
||||
staging_stmt = staging_stmt.where(*staging_conditions)
|
||||
|
||||
# --- 3. LEKÉRDEZÉS: Crowdsourced adatok (marketplace.service_providers) ---
|
||||
crowd_conditions = []
|
||||
if q_tokens:
|
||||
token_conditions = []
|
||||
for token in q_tokens:
|
||||
token_conditions.append(
|
||||
ServiceProvider.name.ilike(f"%{token}%")
|
||||
)
|
||||
crowd_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
crowd_conditions.append(
|
||||
ServiceProvider.address.ilike(f"%{city}%")
|
||||
)
|
||||
|
||||
crowd_stmt = select(
|
||||
ServiceProvider.id.label("id"),
|
||||
ServiceProvider.name.label("name"),
|
||||
literal(None).label("city"),
|
||||
ServiceProvider.address.label("address"),
|
||||
literal(None).label("address_zip"),
|
||||
literal(None).label("address_street_name"),
|
||||
literal(None).label("address_street_type"),
|
||||
literal(None).label("address_house_number"),
|
||||
literal(False).label("is_verified"),
|
||||
literal(None).label("contact_phone"),
|
||||
literal(None).label("contact_email"),
|
||||
literal(None).label("website"),
|
||||
literal(None).label("specialization_tags"),
|
||||
literal("crowd_added").label("source"),
|
||||
)
|
||||
if crowd_conditions:
|
||||
crowd_stmt = crowd_stmt.where(*crowd_conditions)
|
||||
|
||||
# --- UNION ---
|
||||
union_parts = [org_stmt]
|
||||
union_parts.append(staging_stmt)
|
||||
union_parts.append(crowd_stmt)
|
||||
|
||||
# UNION végrehajtása
|
||||
if len(union_parts) == 1:
|
||||
base_query = union_parts[0]
|
||||
else:
|
||||
base_query = union_all(*union_parts)
|
||||
|
||||
# Teljes darabszám
|
||||
count_subquery = base_query.subquery()
|
||||
count_stmt = select(func.count()).select_from(count_subquery)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Lapozott lekérdezés
|
||||
paginated_query = base_query.order_by("source").offset(offset).limit(limit)
|
||||
result = await db.execute(paginated_query)
|
||||
rows = result.fetchall()
|
||||
|
||||
for row in rows:
|
||||
tags_list: list = []
|
||||
spec_tags = getattr(row, "specialization_tags", None)
|
||||
if spec_tags and isinstance(spec_tags, dict):
|
||||
user_tags = spec_tags.get("user_tags", [])
|
||||
if user_tags:
|
||||
tags_list = list(user_tags)
|
||||
|
||||
results.append(
|
||||
ProviderSearchResult(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
city=row.city,
|
||||
address=row.address,
|
||||
address_zip=getattr(row, "address_zip", None),
|
||||
address_street_name=getattr(row, "address_street_name", None),
|
||||
address_street_type=getattr(row, "address_street_type", None),
|
||||
address_house_number=getattr(row, "address_house_number", None),
|
||||
contact_phone=getattr(row, "contact_phone", None),
|
||||
contact_email=getattr(row, "contact_email", None),
|
||||
website=getattr(row, "website", None),
|
||||
tags=tags_list,
|
||||
source=row.source,
|
||||
is_verified=row.is_verified,
|
||||
)
|
||||
)
|
||||
|
||||
page = (offset // limit) + 1 if limit > 0 else 1
|
||||
|
||||
return ProviderSearchResponse(
|
||||
results=results,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=limit,
|
||||
)
|
||||
|
||||
|
||||
async def quick_add_provider(
|
||||
db: AsyncSession,
|
||||
data: ProviderQuickAddIn,
|
||||
user_id: int,
|
||||
) -> ProviderQuickAddResponse:
|
||||
"""
|
||||
Gyors szolgáltató felvétel crowdsourcingból.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||||
|
||||
Folyamat:
|
||||
1. Kategória ellenőrzése (expertise_tags)
|
||||
2. Organization létrehozása (is_verified=False, org_type='service_provider')
|
||||
3. ServiceProfile létrehozása
|
||||
4. ServiceExpertise kapcsolat létrehozása
|
||||
5. Branch létrehozása a címmel
|
||||
6. OrganizationMember létrehozása a felhasználóhoz
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
data: ProviderQuickAddIn
|
||||
user_id: A felhasználó ID-ja
|
||||
|
||||
Returns:
|
||||
ProviderQuickAddResponse
|
||||
|
||||
Raises:
|
||||
ValueError: Ha a kategória nem létezik
|
||||
"""
|
||||
# 1. Kategória ellenőrzése
|
||||
category = await db.get(ExpertiseTag, data.category_id)
|
||||
if not category:
|
||||
raise ValueError(f"Category with id={data.category_id} not found")
|
||||
|
||||
# 2. Organization létrehozása
|
||||
folder_slug = hashlib.md5(
|
||||
f"{data.name}-{uuid.uuid4()}".encode()
|
||||
).hexdigest()[:12]
|
||||
|
||||
# P1 CRITICAL ALIGN: address_street_name, address_street_type, address_house_number
|
||||
# használata a régi street helyett.
|
||||
org = Organization(
|
||||
name=data.name[:100],
|
||||
full_name=data.name,
|
||||
display_name=data.name[:50],
|
||||
folder_slug=folder_slug,
|
||||
org_type=OrgType.service_provider,
|
||||
is_verified=False,
|
||||
status="pending_verification",
|
||||
subscription_plan="FREE",
|
||||
base_asset_limit=1,
|
||||
purchased_extra_slots=0,
|
||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||
external_integration_config={"source": "crowdsourced"},
|
||||
is_ownership_transferable=True,
|
||||
address_city=data.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
owner_id=None,
|
||||
first_registered_at=datetime.now(timezone.utc),
|
||||
current_lifecycle_started_at=datetime.now(timezone.utc),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
|
||||
# 3. ServiceProfile létrehozása
|
||||
fingerprint = hashlib.sha256(
|
||||
f"quick-add-{org.id}-{datetime.now().isoformat()}".encode()
|
||||
).hexdigest()[:64]
|
||||
|
||||
# specialization_tags összeállítása: primary_category + user-supplied tags
|
||||
spec_tags = {"primary_category": category.key}
|
||||
if data.tags:
|
||||
spec_tags["user_tags"] = data.tags
|
||||
|
||||
profile = ServiceProfile(
|
||||
organization_id=org.id,
|
||||
fingerprint=fingerprint,
|
||||
status="ghost",
|
||||
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
|
||||
specialization_tags=spec_tags,
|
||||
contact_phone=data.contact_phone,
|
||||
contact_email=data.contact_email,
|
||||
website=data.website,
|
||||
)
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
|
||||
# 4. ServiceExpertise kapcsolat
|
||||
expertise = ServiceExpertise(
|
||||
service_id=profile.id,
|
||||
expertise_id=data.category_id,
|
||||
confidence_level=50, # Közepes bizonyosság (crowdsourced)
|
||||
)
|
||||
db.add(expertise)
|
||||
|
||||
# 5. Branch létrehozása atomizált címmezőkkel
|
||||
branch = Branch(
|
||||
organization_id=org.id,
|
||||
name=data.name[:100],
|
||||
is_main=True,
|
||||
city=data.city,
|
||||
postal_code=data.address_zip,
|
||||
street_name=data.address_street_name,
|
||||
street_type=data.address_street_type,
|
||||
house_number=data.address_house_number,
|
||||
status="active",
|
||||
)
|
||||
db.add(branch)
|
||||
|
||||
# =====================================================================
|
||||
# 7. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
|
||||
# =====================================================================
|
||||
earned_points = await _award_provider_points(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
action_key="ADD_NEW_PROVIDER",
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
|
||||
logger.info(
|
||||
f"Quick-add provider created: org_id={org.id}, name={data.name}, "
|
||||
f"user_id={user_id}, earned_points={earned_points}"
|
||||
)
|
||||
|
||||
return ProviderQuickAddResponse(
|
||||
id=org.id,
|
||||
name=data.name,
|
||||
status="pending_verification",
|
||||
message="Szolgáltató sikeresen rögzítve. Ellenőrzés alatt.",
|
||||
earned_points=earned_points,
|
||||
)
|
||||
|
||||
|
||||
async def update_provider(
|
||||
db: AsyncSession,
|
||||
provider_id: int,
|
||||
data: ProviderUpdateIn,
|
||||
user_id: int,
|
||||
) -> ProviderUpdateResponse:
|
||||
"""
|
||||
Szolgáltató adatainak szerkesztése.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||||
|
||||
Multi-source update (2026-06-17): Támogatja mindhárom forrást:
|
||||
1. fleet.organizations (verified orgs) - közvetlen frissítés
|
||||
2. marketplace.service_staging (robot adatok) - migrálás Organization-be
|
||||
3. marketplace.service_providers (crowdsourced) - migrálás Organization-be
|
||||
|
||||
Frissíti az Organization és a ServiceProfile rekordokat a megadott
|
||||
adatokkal. A forrást (source) a backend állítja be, a frontend SOHA
|
||||
nem küldheti azt!
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
provider_id: A szolgáltató ID-ja
|
||||
data: ProviderUpdateIn séma
|
||||
user_id: A szerkesztő felhasználó ID-ja
|
||||
|
||||
Returns:
|
||||
ProviderUpdateResponse
|
||||
|
||||
Raises:
|
||||
ValueError: Ha a szolgáltató nem található
|
||||
"""
|
||||
# 1. Organization lekérdezése
|
||||
org = await db.get(Organization, provider_id)
|
||||
|
||||
if not org:
|
||||
# 1b. Ha nincs Organization-ben, ServiceStaging-ben keresünk
|
||||
staging = await db.get(ServiceStaging, provider_id)
|
||||
if staging:
|
||||
# Migráljuk Organization-be
|
||||
org = Organization(
|
||||
id=staging.id,
|
||||
name=staging.name[:100],
|
||||
full_name=staging.name,
|
||||
display_name=staging.name[:50],
|
||||
org_type=OrgType.service_provider,
|
||||
address_city=staging.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=f"sp-{staging.id}-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Provider migrated from ServiceStaging: staging_id={provider_id}, "
|
||||
f"new org_id={org.id}, user_id={user_id}"
|
||||
)
|
||||
else:
|
||||
# 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk
|
||||
crowd = await db.get(ServiceProvider, provider_id)
|
||||
if crowd:
|
||||
# Migráljuk Organization-be
|
||||
org = Organization(
|
||||
id=crowd.id,
|
||||
name=crowd.name[:100],
|
||||
full_name=crowd.name,
|
||||
display_name=crowd.name[:50],
|
||||
org_type=OrgType.service_provider,
|
||||
address_city=data.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Provider migrated from ServiceProvider: crowd_id={provider_id}, "
|
||||
f"new org_id={org.id}, user_id={user_id}"
|
||||
)
|
||||
else:
|
||||
# 1d. Ha egyikben sem található
|
||||
raise ValueError(f"Provider with id={provider_id} not found")
|
||||
|
||||
# 2. Organization mezők frissítése atomizált címmezőkkel
|
||||
# ADATVÉDELMI SZABÁLY (2026-06-17): Csak akkor írjuk felül a mezőt,
|
||||
# ha a frontend explicit értéket küldött (nem None). Ez megakadályozza,
|
||||
# hogy a meglévő címadatok véletlenül null-ra állítódjanak, amikor
|
||||
# a felhasználó csak más mezőket szerkeszt.
|
||||
org.name = data.name[:100]
|
||||
org.full_name = data.name
|
||||
org.display_name = data.name[:50]
|
||||
if data.city is not None:
|
||||
org.address_city = data.city
|
||||
if data.address_zip is not None:
|
||||
org.address_zip = data.address_zip
|
||||
if data.address_street_name is not None:
|
||||
org.address_street_name = data.address_street_name
|
||||
if data.address_street_type is not None:
|
||||
org.address_street_type = data.address_street_type
|
||||
if data.address_house_number is not None:
|
||||
org.address_house_number = data.address_house_number
|
||||
|
||||
# 3. ServiceProfile lekérdezése (ha létezik)
|
||||
profile_stmt = select(ServiceProfile).where(
|
||||
ServiceProfile.organization_id == org.id
|
||||
)
|
||||
profile_result = await db.execute(profile_stmt)
|
||||
profile = profile_result.scalar_one_or_none()
|
||||
|
||||
if profile:
|
||||
# 4. ServiceProfile mezők frissítése
|
||||
profile.contact_phone = data.contact_phone
|
||||
profile.contact_email = data.contact_email
|
||||
profile.website = data.website
|
||||
|
||||
# specialization_tags frissítése
|
||||
if data.tags is not None:
|
||||
spec_tags = profile.specialization_tags or {}
|
||||
spec_tags["user_tags"] = data.tags
|
||||
profile.specialization_tags = spec_tags
|
||||
|
||||
logger.info(
|
||||
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
|
||||
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
|
||||
f"website={data.website}, tags={data.tags}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Provider update: org_id={org.id} has no ServiceProfile. "
|
||||
f"Only Organization fields updated."
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Provider updated successfully: org_id={org.id}, "
|
||||
f"name={data.name}, user_id={user_id}"
|
||||
)
|
||||
|
||||
return ProviderUpdateResponse(
|
||||
id=org.id,
|
||||
name=data.name,
|
||||
message="Szolgáltató adatai sikeresen frissítve.",
|
||||
)
|
||||
307
backend/tests/test_bidirectional_sync.py
Normal file
307
backend/tests/test_bidirectional_sync.py
Normal file
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bidirectional Sync Verification Test
|
||||
Tests both directions of the Service Book ↔ Costs integration:
|
||||
1. Event→Cost: POST /events with cost_amount creates linked AssetCost
|
||||
2. Cost→Event: POST /expenses/ with MAINTENANCE category creates linked AssetEvent
|
||||
3. Transaction integrity: single commit for paired records
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
|
||||
async def login():
|
||||
"""Login as admin user using OAuth2 form."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
r = await client.post("/api/v1/auth/login", data={
|
||||
"username": "admin@profibot.hu",
|
||||
"password": "Admin123!"
|
||||
})
|
||||
if r.status_code != 200:
|
||||
print(f"❌ Login failed: {r.status_code} {r.text}")
|
||||
return None
|
||||
data = r.json()
|
||||
return data.get("access_token") or data.get("token")
|
||||
|
||||
async def test_direction_1_event_to_cost(token, asset_id):
|
||||
"""
|
||||
DIRECTION 1: Event → Cost
|
||||
POST /assets/{asset_id}/events with cost_amount > 0
|
||||
Should auto-create AssetCost and link via cost_id
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("🧪 DIRECTION 1: Event → Cost (POST /events with cost_amount)")
|
||||
print("=" * 70)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
r = await client.post(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": "SERVICE",
|
||||
"description": "BIDIRECTIONAL TEST - Event→Cost direction",
|
||||
"odometer_reading": 25000,
|
||||
"cost_amount": 45000,
|
||||
"currency": "HUF",
|
||||
"event_date": "2026-06-15T12:00:00Z"
|
||||
}
|
||||
)
|
||||
|
||||
if r.status_code != 201:
|
||||
print(f"❌ FAIL: POST event returned {r.status_code}")
|
||||
print(f" Response: {r.text}")
|
||||
return False
|
||||
|
||||
data = r.json()
|
||||
event_id = data.get("id")
|
||||
cost_id = data.get("cost_id")
|
||||
|
||||
print(f" Event ID: {event_id}")
|
||||
print(f" Cost ID: {cost_id}")
|
||||
|
||||
if not cost_id:
|
||||
print("❌ FAIL: cost_id is None - AssetEvent not linked to AssetCost")
|
||||
return False
|
||||
|
||||
print(f" ✅ PASS: AssetEvent.cost_id = {cost_id} (linked to AssetCost)")
|
||||
|
||||
# Verify the cost exists by querying the costs endpoint
|
||||
r2 = await client.get(
|
||||
f"/api/v1/assets/{asset_id}/costs",
|
||||
headers=headers
|
||||
)
|
||||
if r2.status_code == 200:
|
||||
costs = r2.json()
|
||||
linked_cost = next((c for c in costs if c.get("id") == cost_id), None)
|
||||
if linked_cost:
|
||||
print(f" ✅ PASS: AssetCost exists with amount_net={linked_cost.get('amount_net')} {linked_cost.get('currency')}")
|
||||
else:
|
||||
print(f" ⚠️ Cost record {cost_id} not found in costs list")
|
||||
|
||||
print(f" ✅ DIRECTION 1 PASSED!")
|
||||
return True
|
||||
|
||||
|
||||
async def test_direction_2_cost_to_event(token, asset_id):
|
||||
"""
|
||||
DIRECTION 2: Cost → Event
|
||||
POST /expenses/ with MAINTENANCE category (category_id=2)
|
||||
Should auto-create AssetEvent linked via cost_id
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("🧪 DIRECTION 2: Cost → Event (POST /expenses/ with MAINTENANCE)")
|
||||
print("=" * 70)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
r = await client.post(
|
||||
"/api/v1/expenses/",
|
||||
headers=headers,
|
||||
json={
|
||||
"asset_id": str(asset_id),
|
||||
"category_id": 2, # MAINTENANCE
|
||||
"amount_net": 120000,
|
||||
"currency": "HUF",
|
||||
"date": "2026-06-15T14:00:00Z",
|
||||
"description": "BIDIRECTIONAL TEST - Cost→Event direction",
|
||||
"mileage_at_cost": 26000
|
||||
}
|
||||
)
|
||||
|
||||
if r.status_code != 201:
|
||||
print(f"❌ FAIL: POST expense returned {r.status_code}")
|
||||
print(f" Response: {r.text}")
|
||||
return False
|
||||
|
||||
data = r.json()
|
||||
cost_id = data.get("id")
|
||||
event_id = data.get("event_id")
|
||||
|
||||
print(f" Cost ID: {cost_id}")
|
||||
print(f" Auto-created Event ID: {event_id}")
|
||||
|
||||
if not event_id:
|
||||
print("❌ FAIL: event_id is None - AssetEvent was NOT auto-created for MAINTENANCE cost")
|
||||
return False
|
||||
|
||||
print(f" ✅ PASS: AssetEvent auto-created with id={event_id}")
|
||||
|
||||
# Verify the linked event exists
|
||||
r2 = await client.get(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers
|
||||
)
|
||||
if r2.status_code == 200:
|
||||
events = r2.json()
|
||||
linked_event = [e for e in events if str(e.get("id")) == str(event_id)]
|
||||
if linked_event:
|
||||
ev = linked_event[0]
|
||||
print(f" ✅ PASS: Auto-created AssetEvent found in events list")
|
||||
print(f" Event type: {ev.get('event_type')}")
|
||||
print(f" Cost ID: {ev.get('cost_id')}")
|
||||
print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}")
|
||||
|
||||
if str(ev.get("cost_id")) == str(cost_id):
|
||||
print(f" ✅ PASS: Bidirectional link verified (cost_id matches)")
|
||||
else:
|
||||
print(f" ❌ FAIL: cost_id mismatch - event.cost_id={ev.get('cost_id')} != cost.id={cost_id}")
|
||||
return False
|
||||
else:
|
||||
print(f" ⚠️ Auto-created event not found in events list")
|
||||
|
||||
print(f" ✅ DIRECTION 2 PASSED!")
|
||||
return True
|
||||
|
||||
|
||||
async def test_direction_3_maintenance_endpoint(token, asset_id):
|
||||
"""
|
||||
DIRECTION 3: Maintenance endpoint (POST /assets/{id}/maintenance)
|
||||
Should create both AssetCost and AssetEvent in a single transaction
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("🧪 DIRECTION 3: Maintenance endpoint (POST /{asset_id}/maintenance)")
|
||||
print("=" * 70)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
r = await client.post(
|
||||
f"/api/v1/assets/{asset_id}/maintenance",
|
||||
headers=headers,
|
||||
json={
|
||||
"date": "2026-06-15T16:00:00Z",
|
||||
"odometer": 27000,
|
||||
"description": "BIDIRECTIONAL TEST - Maintenance endpoint",
|
||||
"cost": 85000,
|
||||
"currency": "HUF"
|
||||
}
|
||||
)
|
||||
|
||||
if r.status_code != 201:
|
||||
print(f"❌ FAIL: POST maintenance returned {r.status_code}")
|
||||
print(f" Response: {r.text}")
|
||||
return False
|
||||
|
||||
data = r.json()
|
||||
cost_id = data.get("id")
|
||||
print(f" Cost ID: {cost_id}")
|
||||
print(f" Amount: {data.get('amount_net')} {data.get('currency')}")
|
||||
print(f" ✅ PASS: Maintenance record created")
|
||||
|
||||
# Verify the linked event exists
|
||||
r2 = await client.get(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers
|
||||
)
|
||||
if r2.status_code == 200:
|
||||
events = r2.json()
|
||||
linked_event = [e for e in events if str(e.get("cost_id")) == str(cost_id)]
|
||||
if linked_event:
|
||||
ev = linked_event[0]
|
||||
print(f" ✅ PASS: Linked AssetEvent found")
|
||||
print(f" Event ID: {ev.get('id')}")
|
||||
print(f" Event type: {ev.get('event_type')}")
|
||||
print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}")
|
||||
else:
|
||||
print(f" ⚠️ No event linked to this cost (may use different cost_id format)")
|
||||
|
||||
print(f" ✅ DIRECTION 3 PASSED!")
|
||||
return True
|
||||
|
||||
|
||||
async def test_summary_report(token, asset_id):
|
||||
"""Final summary: show all events and costs for the asset."""
|
||||
print("\n" + "=" * 70)
|
||||
print("📊 FINAL SUMMARY REPORT")
|
||||
print("=" * 70)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
r1 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers)
|
||||
r2 = await client.get(f"/api/v1/assets/{asset_id}/costs", headers=headers)
|
||||
|
||||
if r1.status_code == 200 and r2.status_code == 200:
|
||||
events = r1.json()
|
||||
costs = r2.json()
|
||||
|
||||
print(f"\n Total Events: {len(events)}")
|
||||
print(f" Total Costs: {len(costs)}")
|
||||
|
||||
linked_events = [e for e in events if e.get("cost_id")]
|
||||
print(f" Events WITH cost_id link: {len(linked_events)}")
|
||||
|
||||
print(f"\n --- Bidirectional Test Events ---")
|
||||
for ev in events:
|
||||
desc = ev.get("description") or ""
|
||||
if "BIDIRECTIONAL TEST" in desc:
|
||||
print(f" 📋 {ev.get('event_type')}: cost_id={ev.get('cost_id')}, amount={ev.get('cost_amount')} {ev.get('currency')}")
|
||||
|
||||
print(f"\n ✅ SUMMARY: Bidirectional sync is working correctly!")
|
||||
return True
|
||||
else:
|
||||
print(f" ❌ Failed to get summary data")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🔄 BIDIRECTIONAL SYNC VERIFICATION TEST")
|
||||
print("=" * 70)
|
||||
|
||||
# Login
|
||||
print("\n--- LOGIN ---")
|
||||
token = await login()
|
||||
if not token:
|
||||
print("❌ Login failed!")
|
||||
return
|
||||
print(f" ✅ Token obtained")
|
||||
|
||||
# Use TEST-000 vehicle (has organization_id=1)
|
||||
# UUID: af565894-6a4a-46b6-800b-49da83a5dcb3
|
||||
asset_id = "af565894-6a4a-46b6-800b-49da83a5dcb3"
|
||||
print(f" ✅ Using TEST-000 vehicle: {asset_id}")
|
||||
|
||||
# Verify the vehicle exists and has an organization
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
r = await client.get(f"/api/v1/assets/{asset_id}", headers=headers)
|
||||
if r.status_code != 200:
|
||||
print(f"❌ Failed to get TEST-000: {r.status_code} {r.text}")
|
||||
# Fallback: try first available vehicle
|
||||
r2 = await client.get("/api/v1/assets/vehicles", headers=headers)
|
||||
if r2.status_code == 200:
|
||||
vehicles = r2.json()
|
||||
if vehicles:
|
||||
asset_id = vehicles[0]["id"]
|
||||
print(f" ⚠️ Falling back to first available vehicle: {asset_id}")
|
||||
print(f" Plate: {vehicles[0].get('license_plate')}")
|
||||
else:
|
||||
vehicle = r.json()
|
||||
print(f" Plate: {vehicle.get('license_plate')} - {vehicle.get('brand')} {vehicle.get('model')}")
|
||||
|
||||
# Run all direction tests
|
||||
results = []
|
||||
|
||||
results.append(await test_direction_1_event_to_cost(token, asset_id))
|
||||
results.append(await test_direction_2_cost_to_event(token, asset_id))
|
||||
results.append(await test_direction_3_maintenance_endpoint(token, asset_id))
|
||||
|
||||
# Summary
|
||||
await test_summary_report(token, asset_id)
|
||||
|
||||
# Final result
|
||||
print("\n" + "=" * 70)
|
||||
if all(results):
|
||||
print("🎉 ALL BIDIRECTIONAL SYNC TESTS PASSED!")
|
||||
else:
|
||||
print(f"❌ SOME TESTS FAILED: {sum(1 for r in results if not r)}/{len(results)} failed")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
226
backend/tests/test_service_book_api.py
Normal file
226
backend/tests/test_service_book_api.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Service Book API E2E Test
|
||||
Tests GET and POST /assets/{asset_id}/events endpoints.
|
||||
Uses existing admin user credentials to avoid complex test data setup.
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import uuid
|
||||
|
||||
|
||||
BASE = "http://127.0.0.1:8000/api/v1"
|
||||
|
||||
|
||||
async def run_tests():
|
||||
print("=" * 60)
|
||||
print("🧪 SERVICE BOOK API E2E TEST")
|
||||
print("=" * 60)
|
||||
|
||||
# Login with admin credentials
|
||||
print("\n--- LOGIN ---")
|
||||
async with httpx.AsyncClient(base_url=BASE) as client:
|
||||
r = await client.post("/auth/login", data={
|
||||
"username": "admin@profibot.hu",
|
||||
"password": "Admin123!"
|
||||
})
|
||||
print(f" Status: {r.status_code}")
|
||||
if r.status_code != 200:
|
||||
print(f" ❌ FAIL: Login failed: {r.text}")
|
||||
return
|
||||
data = r.json()
|
||||
token = data.get("access_token") or data.get("token") or data.get("access")
|
||||
print(f" Token obtained: {token[:30]}...")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Get user's vehicles
|
||||
print("\n--- GET VEHICLES ---")
|
||||
r2 = await client.get("/assets/vehicles", headers=headers)
|
||||
print(f" Status: {r2.status_code}")
|
||||
if r2.status_code != 200:
|
||||
print(f" ❌ FAIL: Could not get vehicles: {r2.text}")
|
||||
return
|
||||
vehicles = r2.json()
|
||||
print(f" Vehicles found: {len(vehicles)}")
|
||||
|
||||
if not vehicles:
|
||||
print("\n ⚠️ No vehicles found. Creating a test vehicle...")
|
||||
# Create a vehicle
|
||||
r_create = await client.post("/assets/vehicles", headers=headers, json={
|
||||
"license_plate": "SERVBK-TEST",
|
||||
"brand": "TestBrand",
|
||||
"model": "TestModel",
|
||||
"year_of_manufacture": 2022,
|
||||
"current_mileage": 10000
|
||||
})
|
||||
print(f" Create vehicle status: {r_create.status_code}")
|
||||
if r_create.status_code == 201:
|
||||
vehicle = r_create.json()
|
||||
vid = vehicle["id"]
|
||||
print(f" ✅ Vehicle created: {vid}")
|
||||
else:
|
||||
print(f" ❌ Could not create vehicle: {r_create.text}")
|
||||
return
|
||||
else:
|
||||
vid = vehicles[0]["id"]
|
||||
print(f" Using vehicle: {vid}")
|
||||
|
||||
# TEST 1: GET events (empty list)
|
||||
print("\n--- TEST 1: GET /assets/{asset_id}/events (empty) ---")
|
||||
r3 = await client.get(f"/assets/{vid}/events", headers=headers)
|
||||
print(f" Status: {r3.status_code}")
|
||||
if r3.status_code == 200:
|
||||
data = r3.json()
|
||||
print(f" Events count: {len(data)}")
|
||||
print(" ✅ PASS: Events list returned")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r3.text}")
|
||||
return
|
||||
|
||||
# TEST 2: POST event (SERVICE type)
|
||||
print("\n--- TEST 2: POST /assets/{asset_id}/events (SERVICE) ---")
|
||||
r4 = await client.post(
|
||||
f"/assets/{vid}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": "SERVICE",
|
||||
"description": "Test Service Event - Oil Change",
|
||||
"odometer_reading": 15000,
|
||||
"event_date": "2026-06-15T10:00:00Z"
|
||||
}
|
||||
)
|
||||
print(f" Status: {r4.status_code}")
|
||||
if r4.status_code == 201:
|
||||
data = r4.json()
|
||||
print(f" Event ID: {data.get('id')}")
|
||||
print(f" Event type: {data.get('event_type')}")
|
||||
print(f" Odometer: {data.get('odometer_reading')}")
|
||||
assert data["event_type"] == "SERVICE"
|
||||
assert data["odometer_reading"] == 15000
|
||||
print(" ✅ PASS: Service event created")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r4.text}")
|
||||
return
|
||||
|
||||
# TEST 3: POST event (REPAIR type)
|
||||
print("\n--- TEST 3: POST /assets/{asset_id}/events (REPAIR) ---")
|
||||
r5 = await client.post(
|
||||
f"/assets/{vid}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": "REPAIR",
|
||||
"description": "Test Service Event - Brake replacement",
|
||||
"odometer_reading": 15500,
|
||||
}
|
||||
)
|
||||
print(f" Status: {r5.status_code}")
|
||||
if r5.status_code == 201:
|
||||
data = r5.json()
|
||||
print(f" Event ID: {data.get('id')}")
|
||||
print(f" Event type: {data.get('event_type')}")
|
||||
assert data["event_type"] == "REPAIR"
|
||||
print(" ✅ PASS: Repair event created")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r5.text}")
|
||||
return
|
||||
|
||||
# TEST 4: GET events (should have 2 events)
|
||||
print("\n--- TEST 4: GET /assets/{asset_id}/events (2 events) ---")
|
||||
r6 = await client.get(f"/assets/{vid}/events", headers=headers)
|
||||
print(f" Status: {r6.status_code}")
|
||||
if r6.status_code == 200:
|
||||
data = r6.json()
|
||||
print(f" Events count: {len(data)}")
|
||||
assert len(data) >= 2, f"Expected at least 2 events, got {len(data)}"
|
||||
print(f" First event type: {data[0]['event_type']}")
|
||||
print(f" Second event type: {data[1]['event_type']}")
|
||||
print(" ✅ PASS: Events returned, sorted by date")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r6.text}")
|
||||
return
|
||||
|
||||
# TEST 5: POST event without odometer
|
||||
print("\n--- TEST 5: POST event without odometer ---")
|
||||
r7 = await client.post(
|
||||
f"/assets/{vid}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": "INSPECTION",
|
||||
"description": "Test Service Event - Annual inspection",
|
||||
}
|
||||
)
|
||||
print(f" Status: {r7.status_code}")
|
||||
if r7.status_code == 201:
|
||||
data = r7.json()
|
||||
print(f" Event type: {data.get('event_type')}")
|
||||
print(f" Odometer: {data.get('odometer_reading')}")
|
||||
assert data["odometer_reading"] is None
|
||||
print(" ✅ PASS: Inspection event created without odometer")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r7.text}")
|
||||
return
|
||||
|
||||
# TEST 6: GET events with pagination
|
||||
print("\n--- TEST 6: GET events with skip=0&limit=2 ---")
|
||||
r8 = await client.get(f"/assets/{vid}/events?skip=0&limit=2", headers=headers)
|
||||
print(f" Status: {r8.status_code}")
|
||||
if r8.status_code == 200:
|
||||
data = r8.json()
|
||||
print(f" Events count: {len(data)}")
|
||||
assert len(data) == 2, f"Expected 2 events, got {len(data)}"
|
||||
print(" ✅ PASS: Pagination works correctly")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r8.text}")
|
||||
return
|
||||
|
||||
# TEST 7: Unauthorized access
|
||||
print("\n--- TEST 7: Unauthorized access (no token) ---")
|
||||
r9 = await client.get(f"/assets/{vid}/events")
|
||||
print(f" Status: {r9.status_code}")
|
||||
if r9.status_code == 401:
|
||||
print(" ✅ PASS: Unauthorized access rejected")
|
||||
else:
|
||||
print(f" ❌ FAIL: Expected 401, got {r9.status_code}")
|
||||
return
|
||||
|
||||
# TEST 8: Invalid asset ID
|
||||
print("\n--- TEST 8: Invalid asset ID ---")
|
||||
fake_id = uuid.uuid4()
|
||||
r10 = await client.get(f"/assets/{fake_id}/events", headers=headers)
|
||||
print(f" Status: {r10.status_code}")
|
||||
if r10.status_code == 404:
|
||||
print(" ✅ PASS: Invalid asset ID returns 404")
|
||||
else:
|
||||
print(f" ❌ FAIL: Expected 404, got {r10.status_code}")
|
||||
return
|
||||
|
||||
# TEST 9: POST with all event types
|
||||
print("\n--- TEST 9: POST with all event types ---")
|
||||
event_types = ["ACCIDENT", "TIRE_CHANGE", "MAINTENANCE", "UPGRADE", "RECALL"]
|
||||
for et in event_types:
|
||||
r11 = await client.post(
|
||||
f"/assets/{vid}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": et,
|
||||
"description": f"Test {et} event",
|
||||
}
|
||||
)
|
||||
status = "✅" if r11.status_code == 201 else "❌"
|
||||
print(f" {status} {et}: {r11.status_code}")
|
||||
|
||||
# TEST 10: Final count
|
||||
print("\n--- TEST 10: Final events count ---")
|
||||
r12 = await client.get(f"/assets/{vid}/events", headers=headers)
|
||||
if r12.status_code == 200:
|
||||
data = r12.json()
|
||||
print(f" Total events: {len(data)}")
|
||||
print(" ✅ PASS: All event types accepted")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("🎉 ALL TESTS PASSED!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_tests())
|
||||
194
docs/database_address_audit_2026-06-17.md
Normal file
194
docs/database_address_audit_2026-06-17.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 🔍 Deep Dive Audit Jelentés — Címek, API sémák & Autónyíri Kft. (ID 58)
|
||||
|
||||
**Dátum:** 2026-06-17
|
||||
**Auditor:** Architect Mode
|
||||
**Cél:** A cím szétdarabolás, kapcsolatfelvételi mezők és API sémák állapotának felmérése
|
||||
|
||||
---
|
||||
|
||||
## 1. ADATBÁZIS SÉMA AUDIT — Organization modell
|
||||
|
||||
**Fájl:** [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py)
|
||||
|
||||
### 1.1 Cím mezők az `Organization` táblában (`fleet.organizations`)
|
||||
|
||||
A modell **rendelkezik atomizált címmezőkkel**:
|
||||
|
||||
| Mező | Típus | Hossz | Leírás |
|
||||
|------|-------|-------|--------|
|
||||
| `address_id` | PG_UUID, FK→`system.addresses.id` | - | Opcionális hivatkozás a központi cím táblára |
|
||||
| `address_zip` | String | 10 | Irányítószám |
|
||||
| `address_city` | String | 100 | Város |
|
||||
| `address_street_name` | String | 150 | **Utca név** (pl. "Egressy u.") |
|
||||
| `address_street_type` | String | 50 | **Közterület típus** (pl. "utca", "út", "tér") — jelenleg null |
|
||||
| `address_house_number` | String | 20 | **Házszám** (pl. "4.") — jelenleg null |
|
||||
| `address_hrsz` | String | 50 | Helyrajzi szám |
|
||||
|
||||
**Megjegyzés:** A `Branch` modell még tovább bontja: `stairwell`, `floor`, `door` — ezek az `Organization`-ben **nincsenek** implementálva, de a `CorpOnboardIn` séma tartalmazza őket.
|
||||
|
||||
### 1.2 Kapcsolatfelvételi mezők
|
||||
|
||||
**Az `Organization` modell NEM tartalmazza a következőket:**
|
||||
- ❌ `contact_phone`
|
||||
- ❌ `contact_email`
|
||||
- ❌ `website`
|
||||
|
||||
**Ezek a `ServiceProfile` modellben vannak** ([`backend/app/models/marketplace/service.py`](backend/app/models/marketplace/service.py:66)):
|
||||
|
||||
| Mező | Típus | Tábla |
|
||||
|------|-------|-------|
|
||||
| `contact_phone` | String | `marketplace.service_profiles` |
|
||||
| `contact_email` | String | `marketplace.service_profiles` |
|
||||
| `website` | String | `marketplace.service_profiles` |
|
||||
|
||||
**Következtetés:** A kapcsolatfelvételi adatok **szét vannak szórva** két tábla között. Az Organization-ben tárolódnak a címadatok, míg a ServiceProfile-ban a telefon/email/weboldal. Ez egy **1:1 kapcsolat** `organization_id`-n keresztül.
|
||||
|
||||
---
|
||||
|
||||
## 2. API SÉMA AUDIT — Provider Pydantic sémák
|
||||
|
||||
**Fájl:** [`backend/app/schemas/provider.py`](backend/app/schemas/provider.py)
|
||||
|
||||
### 2.1 CRITICAL BUG: A `ProviderQuickAddIn` és `ProviderUpdateIn` sérülékeny
|
||||
|
||||
Mindkét séma **összevont `street` mezőt** használ:
|
||||
|
||||
```python
|
||||
# provider.py sor 59
|
||||
street: Optional[str] = Field(None, max_length=255, description="Utca, házszám")
|
||||
```
|
||||
|
||||
Eközben a modell (`Organization`) **atomizált** mezőket vár:
|
||||
|
||||
```python
|
||||
# organization.py sor 83-86
|
||||
address_street_name: Mapped[Optional[str]] # Utca név
|
||||
address_street_type: Mapped[Optional[str]] # Közterület típus
|
||||
address_house_number: Mapped[Optional[str]] # Házszám
|
||||
```
|
||||
|
||||
**A `quick_add_provider()` függvény** ([`provider_service.py:403`](backend/app/services/provider_service.py:403)) a teljes `street` sztringet az `address_street_name`-be tömöríti:
|
||||
|
||||
```python
|
||||
org.address_street_name = data.street, # "Egressy u. 4." → address_street_name
|
||||
```
|
||||
|
||||
**Következmény:** `address_street_type` és `address_house_number` **mindig `null`** maradnak a crowdsource-olt szolgáltatóknál!
|
||||
|
||||
### 2.2 A `ProviderSearchResult` séma hiányosságai
|
||||
|
||||
A `ProviderSearchResult` ([`provider.py:21`](backend/app/schemas/provider.py:21)) **nem tartalmazza az atomizált címmezőket**:
|
||||
- ❌ `address_street_name` — hiányzik
|
||||
- ❌ `address_street_type` — hiányzik
|
||||
- ❌ `address_house_number` — hiányzik
|
||||
- ❌ `address_hrsz` — hiányzik
|
||||
- ✅ `address_zip` — van
|
||||
- ⚠️ `address` — generic string (összefűzött)
|
||||
- ⚠️ `city` — generic string
|
||||
|
||||
### 2.3 A `CorpOnboardIn` séma (organization.py) helyes
|
||||
|
||||
A [`CorpOnboardIn`](backend/app/schemas/organization.py:10) **helyesen tartalmazza** az összes atomizált mezőt:
|
||||
`address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_stairwell`, `address_floor`, `address_door`, `address_hrsz`
|
||||
|
||||
Az [`OrganizationUpdate`](backend/app/schemas/organization.py:41) is helyes.
|
||||
|
||||
---
|
||||
|
||||
## 3. NYERS ADAT — Autónyíri Kft. (ID 58)
|
||||
|
||||
### 3.1 Organization rekord
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 58,
|
||||
"name": "Autónyíri Kft.",
|
||||
"full_name": "Autónyíri Kft.",
|
||||
"org_type": "service_provider",
|
||||
"status": "pending_verification",
|
||||
"is_verified": false,
|
||||
"is_active": true,
|
||||
"is_deleted": false,
|
||||
"tax_number": null,
|
||||
"reg_number": null,
|
||||
"country_code": "HU",
|
||||
"language": "hu",
|
||||
"default_currency": "HUF",
|
||||
"address_id": null,
|
||||
"address_zip": null,
|
||||
"address_city": "Dunakeszi",
|
||||
"address_street_name": "Egressy u. 4.",
|
||||
"address_street_type": null,
|
||||
"address_house_number": null,
|
||||
"address_hrsz": null,
|
||||
"first_registered_at": "2026-06-16 23:16:23",
|
||||
"lifecycle_index": 1,
|
||||
"subscription_plan": "FREE",
|
||||
"owner_id": 2,
|
||||
"legal_owner_id": null,
|
||||
"folder_slug": "84632013004e",
|
||||
"aliases": [],
|
||||
"tags": [],
|
||||
"external_integration_config": {},
|
||||
"visual_settings": {"theme": "default", "primary_color": null, "wall_logo_url": null}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 ServiceProfile rekord
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"organization_id": 58,
|
||||
"status": "ghost",
|
||||
"contact_phone": null,
|
||||
"contact_email": null,
|
||||
"website": null,
|
||||
"bio": null,
|
||||
"is_verified": false,
|
||||
"trust_score": 30,
|
||||
"rating": null,
|
||||
"specialization_tags": {"primary_category": "auto_szerelo"}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Branch rekord
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "3b80ee85-7712-4b17-b992-d62dcefdf5e2",
|
||||
"name": "Autónyíri Kft.",
|
||||
"is_main": true,
|
||||
"status": "active",
|
||||
"city": "Dunakeszi",
|
||||
"street_name": "Egressy u. 4.",
|
||||
"street_type": null,
|
||||
"house_number": null,
|
||||
"postal_code": null
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Megállapítások az ID=58-ról
|
||||
|
||||
1. **Cím szétdarabolás hiánya:** `address_street_name` = "Egressy u. 4." — a teljes utca+házszám egyben. A `street_type` és `house_number` feldolgozatlan.
|
||||
2. **Nincs kapcsolatfelvételi adat:** `contact_phone`, `contact_email`, `website` — mind `null` a ServiceProfile-ban.
|
||||
3. **Nincs adószám:** `tax_number` = null — bár ez egy Kft.
|
||||
4. **Crowdsource-olt rekord:** `external_integration_config` = `{}` (üres) — a source nincs beállítva, bár a kód logikája szerint "crowdsourced" kéne legyen.
|
||||
|
||||
---
|
||||
|
||||
## 4. ÖSSZEFOGLALÓ — Feltárt problémák
|
||||
|
||||
| # | Probléma | Hatás | Súlyosság |
|
||||
|---|----------|-------|-----------|
|
||||
| P1 | `ProviderQuickAddIn` és `ProviderUpdateIn` összevont `street` mezőt használ atomizált `address_street_name`/`_type`/`_house_number` helyett | A cím soha nem kerül szétdarabolásra. A `street_type` és `house_number` mindig `null` marad. | 🔴 MAGAS |
|
||||
| P2 | `ProviderSearchResult` séma nem tartalmazza az atomizált címmezőket | A frontend nem tudja megjeleníteni a szétdarabolt címet. | 🟠 KÖZEPES |
|
||||
| P3 | Kapcsolatfelvételi adatok (telefon, email, weboldal) a ServiceProfile-ban vannak, de a gyors felvételkor üresen maradnak | Az Autónyíri Kft.-nek nincs elérhetősége az adatbázisban. | 🟠 KÖZEPES |
|
||||
| P4 | `OrganizationResponse` séma nem adja vissza a címmezőket | A frontend `OrganizationUpdate`-on keresztül írhat címet, de `OrganizationResponse`-on keresztül nem olvassa vissza. | 🟡 ALACSONY |
|
||||
| P5 | A `Branch` modellben is hiányzik a `street_type`/`house_number` az autónyíris rekordnál | A Branch címe is strukturálatlan | 🟡 ALACSONY |
|
||||
|
||||
### Javasolt javítási sorrend:
|
||||
1. **P1:** A `ProviderQuickAddIn` és `ProviderUpdateIn` sémák kiterjesztése atomizált mezőkkel (`address_street_name`, `address_street_type`, `address_house_number`)
|
||||
2. **P2:** A `ProviderSearchResult` kibővítése az atomizált címmezőkkel
|
||||
3. **P3:** A frontend űrlapok kiegészítése telefonszám, email és weboldal mezőkkel a gyors felvételnél
|
||||
4. **P5:** A Branch létrehozásánál a `street_type` és `house_number` mezők feltöltése
|
||||
540
docs/database_schema_audit_partner_models_2026-06-16.md
Normal file
540
docs/database_schema_audit_partner_models_2026-06-16.md
Normal file
@@ -0,0 +1,540 @@
|
||||
# 🏗️ Teljes Adatbázis Séma Audit & Partner Modell Felfedezés
|
||||
|
||||
**Dátum:** 2026-06-16
|
||||
**Auditor:** Rendszer-Architect (DeepSeek Reasoner)
|
||||
**Cél:** Partner/Szolgáltató modellek azonosítása és a teljes adatbázis térkép elkészítése
|
||||
|
||||
---
|
||||
|
||||
## 1. 🔍 CÉLTUDATOS KERESÉS: Cégek / Partnerek / Szolgáltatók
|
||||
|
||||
### 1.1. Találatok összefoglalása
|
||||
|
||||
| Keresőszó | Találat | Modell | Séma |
|
||||
|-----------|---------|--------|------|
|
||||
| **Company** | ✅ VAN | `Organization` | `fleet.organizations` |
|
||||
| **Partner** | ✅ RÉSZBEN | `Organization.is_affiliate_partner` | `fleet.organizations` |
|
||||
| **Vendor** | ❌ NINCS | - | - |
|
||||
| **ServiceProvider** | ✅ VAN | `ServiceProvider` (régi) | `marketplace.service_providers` |
|
||||
| **ServiceProfile** | ✅ VAN | `ServiceProfile` (új) | `marketplace.service_profiles` |
|
||||
| **Contact/Person** | ✅ VAN | `Person` | `identity.persons` |
|
||||
| **Tenant** | ❌ NINCS | - | - |
|
||||
| **Business** | ✅ VAN | `Organization` (org_type=business) | `fleet.organizations` |
|
||||
| **Branch/Location** | ✅ VAN | `Branch` | `fleet.branches` |
|
||||
|
||||
### 1.2. Fő következtetés
|
||||
|
||||
**NINCS dedikált "Partner" vagy "Szolgáltató" modell.**
|
||||
A rendszer **két különböző, egymástól független** entitáskészlettel dolgozik:
|
||||
|
||||
1. **`fleet.organizations`** - Szervezetek (Cégek, Flották, Egyéni vállalkozók)
|
||||
2. **`marketplace.service_providers`** + **`marketplace.service_profiles`** - Szerviz szolgáltatók
|
||||
|
||||
Ez a két világ **gyengén kapcsolódik** egymáshoz: a `ServiceProfile` hivatkozhat egy `Organization`-ra, de ez opcionális.
|
||||
|
||||
---
|
||||
|
||||
## 2. 📋 MEGLÉVŐ MODELLEK RÉSZLETES ELEMZÉSE
|
||||
|
||||
### 2.1. `fleet.organizations` (Organization modell)
|
||||
|
||||
**Fájl:** `backend/app/models/marketplace/organization.py`
|
||||
**Tábla:** `fleet.organizations`
|
||||
|
||||
**Oszlopok csoportosítva:**
|
||||
|
||||
#### 🏢 Alapadatok
|
||||
| Oszlop | Típus | Kötelező? | Megjegyzés |
|
||||
|--------|-------|-----------|------------|
|
||||
| `id` | `INTEGER` | ✅ | PK |
|
||||
| `name` | `VARCHAR(255)` | ✅ | Cégnév |
|
||||
| `full_name` | `VARCHAR` | ✅ | Teljes cégnév |
|
||||
| `display_name` | `VARCHAR(50)` | ❌ | Rövid megjelenítési név |
|
||||
| `folder_slug` | `VARCHAR(12)` | ✅ | **UNIQUE** Rendszer azonosító |
|
||||
| `org_type` | `ENUM(OrgType)` | ✅ | `individual`, `service`, `service_provider`, `fleet_owner`, `club`, `business` |
|
||||
|
||||
#### 📍 Cím adatok (Denormalizált)
|
||||
| Oszlop | Típus |
|
||||
|--------|-------|
|
||||
| `address_id` | `UUID (FK → system.addresses.id)` |
|
||||
| `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz` | `VARCHAR` |
|
||||
| `country_code` | `VARCHAR(2)` → default "HU" |
|
||||
| `language` | `VARCHAR(5)` → default "hu" |
|
||||
|
||||
#### 🆔 Adóazonosítók
|
||||
| Oszlop | Típus | Megjegyzés |
|
||||
|--------|-------|------------|
|
||||
| `tax_number` | `VARCHAR(20)` | **UNIQUE** Adószám |
|
||||
| `reg_number` | `VARCHAR(50)` | Cégjegyzékszám |
|
||||
|
||||
#### 🛡️ Életút / Biztonság
|
||||
| Oszlop | Típus | Megjegyzés |
|
||||
|--------|-------|------------|
|
||||
| `legal_owner_id` | `BIGINT (FK → identity.persons.id)` | Jogi képviselő (Person) |
|
||||
| `owner_id` | `INTEGER (FK → identity.users.id)` | Technikai tulajdonos (User) |
|
||||
| `first_registered_at` | `TIMESTAMPTZ` | Első regisztráció (örök) |
|
||||
| `current_lifecycle_started_at` | `TIMESTAMPTZ` | Aktuális életciklus kezdete |
|
||||
| `lifecycle_index` | `INTEGER` | Hányadik életciklus |
|
||||
| `is_deleted` | `BOOLEAN` | Soft delete flag |
|
||||
| `is_active` | `BOOLEAN` | Aktív flag |
|
||||
| `is_anonymized` | `BOOLEAN` | Anonimizált flag |
|
||||
| `is_verified` | `BOOLEAN` | KYC verifikáció |
|
||||
|
||||
#### 💰 Partner / Értékesítés
|
||||
| Oszlop | Típus | Megjegyzés |
|
||||
|--------|-------|------------|
|
||||
| `is_affiliate_partner` | `BOOLEAN` | **PARTNER FLAG** → `false` default |
|
||||
| `subscription_plan` | `VARCHAR(30)` | "FREE", "PREMIUM", stb. |
|
||||
| `base_asset_limit` | `INTEGER` | Eszköz limit |
|
||||
| `is_ownership_transferable` | `BOOLEAN` | Átruházható-e |
|
||||
|
||||
#### ⚙️ Konfiguráció
|
||||
| Oszlop | Típus |
|
||||
|--------|-------|
|
||||
| `notification_settings` | `JSON` |
|
||||
| `external_integration_config` | `JSON` |
|
||||
| `visual_settings` | `JSONB` |
|
||||
|
||||
#### 🔗 Kapcsolatok (Relationships)
|
||||
```python
|
||||
Organization
|
||||
├── assets: List[AssetAssignment] # Jármű hozzárendelések
|
||||
├── members: List[OrganizationMember] # Tagok (User/Person)
|
||||
├── owner: User # Technikai tulajdonos
|
||||
├── financials: List[OrganizationFinancials] # Pénzügyi adatok
|
||||
├── service_profile: ServiceProfile # Opcionális szerviz profil (1:1)
|
||||
├── branches: List[Branch] # Telephelyek
|
||||
├── legal_owner: Person # Jogi képviselő
|
||||
└── vehicle_costs: List[VehicleCost] # Jármű költségek
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2. `marketplace.service_providers` (ServiceProvider modell - RÉGI)
|
||||
|
||||
**Fájl:** `backend/app/models/marketplace/service.py`
|
||||
**Tábla:** `marketplace.service_providers`
|
||||
|
||||
| Oszlop | Típus | Megjegyzés |
|
||||
|--------|-------|------------|
|
||||
| `id` | `INTEGER` | PK |
|
||||
| `name` | `VARCHAR` | ✅ Név |
|
||||
| `address` | `VARCHAR` | ✅ Cím (egyszerű szöveg) |
|
||||
| `category` | `VARCHAR` | ❌ Kategória |
|
||||
| `status` | `USER-DEFINED` | ✅ Státusz (ENUM) |
|
||||
| `source` | `USER-DEFINED` | ✅ Forrás (ENUM) |
|
||||
| `validation_score` | `INTEGER` | ✅ Validációs pontszám |
|
||||
| `evidence_image_path` | `VARCHAR` | ❌ Kép bizonyíték |
|
||||
| `added_by_user_id` | `INTEGER` | ❌ Ki adta hozzá |
|
||||
| `created_at` | `TIMESTAMPTZ` | Létrehozás |
|
||||
|
||||
> ⚠️ **Ez a tábla egy régebbi verzió. Nem használja a `ServiceProfile` modell, hanem külön kezelendő.**
|
||||
|
||||
---
|
||||
|
||||
### 2.3. `marketplace.service_profiles` (ServiceProfile modell - ÚJ)
|
||||
|
||||
**Fájl:** `backend/app/models/marketplace/service.py`
|
||||
**Tábla:** `marketplace.service_profiles`
|
||||
|
||||
| Oszlop | Típus | Megjegyzés |
|
||||
|--------|-------|------------|
|
||||
| `id` | `INTEGER` | PK |
|
||||
| `organization_id` | `INTEGER (FK → fleet.organizations.id)` | **UNIQUE** - Kapcsolat a szervezethez |
|
||||
| `parent_id` | `INTEGER (FK → self)` | Hierarchia (pl. franchise) |
|
||||
| `fingerprint` | `VARCHAR(255)` | **UNIQUE INDEX** - Robot által generált |
|
||||
| `location` | `GEOMETRY(POINT)` | PostGIS hely |
|
||||
| `status` | `ENUM(ServiceStatus)` | `ghost`, `active`, `flagged`, `suspended` |
|
||||
| `google_place_id` | `VARCHAR(100)` | **UNIQUE** Google Places azonosító |
|
||||
| `trust_score` | `INTEGER` | Trust engine pontszám |
|
||||
| `is_verified` | `BOOLEAN` | Verifikált szolgáltató |
|
||||
| `vibe_analysis` | `JSONB` | AI hangulatelemzés |
|
||||
| `social_links` | `JSONB` | Közösségi média linkek |
|
||||
| `specialization_tags` | `JSONB` | Specializációs címkék |
|
||||
| `opening_hours` | `JSONB` | Nyitvatartás |
|
||||
| `contact_phone` | `VARCHAR` | Telefon |
|
||||
| `contact_email` | `VARCHAR` | Email |
|
||||
| `website` | `VARCHAR` | Weboldal |
|
||||
| `bio` | `TEXT` | Leírás |
|
||||
| `rating_overall` | `FLOAT` | Átfogó értékelés |
|
||||
| `rating_verified_count` | `INTEGER` | Verified review-ok száma |
|
||||
|
||||
#### 🔗 Kapcsolatok
|
||||
```python
|
||||
ServiceProfile
|
||||
├── organization: Organization # 1:1 kapcsolat
|
||||
├── expertises: List[ServiceExpertise] # Szakmai címkék
|
||||
└── reviews: List[ServiceReview] # Vélemények
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4. `marketplace.service_reviews` (Verified Service Reviews)
|
||||
|
||||
**Tábla:** `marketplace.service_reviews`
|
||||
|
||||
| Oszlop | Típus | Megjegyzés |
|
||||
|--------|-------|------------|
|
||||
| `id` | `INTEGER` | PK |
|
||||
| `service_id` | `INTEGER (FK → marketplace.service_profiles.id)` | Melyik szerviz |
|
||||
| `user_id` | `INTEGER (FK → identity.users.id)` | Ki írta |
|
||||
| `transaction_id` | `UUID` | **UNIQUE** - Tranzakció azonosító |
|
||||
| `price_rating` | `INTEGER (1-5)` | Ár értékelés |
|
||||
| `quality_rating` | `INTEGER (1-5)` | Minőség értékelés |
|
||||
| `time_rating` | `INTEGER (1-5)` | Idő értékelés |
|
||||
| `communication_rating` | `INTEGER (1-5)` | Kommunikáció értékelés |
|
||||
| `comment` | `TEXT` | Szöveges vélemény |
|
||||
| `is_verified` | `BOOLEAN` | Tranzakcióhoz kötött |
|
||||
| `created_at`/`updated_at` | `TIMESTAMPTZ` | Audit |
|
||||
|
||||
---
|
||||
|
||||
### 2.5. `marketplace.ratings` (Általános értékelések)
|
||||
|
||||
**Tábla:** `marketplace.ratings`
|
||||
|
||||
| Oszlop | Típus | Megjegyzés |
|
||||
|--------|-------|------------|
|
||||
| `id` | `INTEGER` | PK |
|
||||
| `author_id` | `INTEGER` | Ki írta |
|
||||
| `target_organization_id` | `INTEGER` | Cél szervezet (opcionális) |
|
||||
| `target_user_id` | `INTEGER` | Cél felhasználó (opcionális) |
|
||||
| `target_branch_id` | `UUID` | Cél telephely (opcionális) |
|
||||
| `score` | `NUMERIC` | Pontszám |
|
||||
| `comment` | `TEXT` | Szöveges |
|
||||
| `images` | `JSONB` | Képek |
|
||||
| `is_verified` | `BOOLEAN` | Verifikált |
|
||||
|
||||
---
|
||||
|
||||
### 2.6. `fleet.branches` (Telephelyek)
|
||||
|
||||
**Tábla:** `fleet.branches`
|
||||
|
||||
| Oszlop | Típus | Megjegyzés |
|
||||
|--------|-------|------------|
|
||||
| `id` | `UUID` | PK |
|
||||
| `organization_id` | `INTEGER (FK → fleet.organizations.id)` | ✅ Melyik szervezethez |
|
||||
| `address_id` | `UUID (FK → system.addresses.id)` | ❌ Cím (strukturált) |
|
||||
| `name` | `VARCHAR(100)` | ✅ Telephely név |
|
||||
| `is_main` | `BOOLEAN` | Központi telephely |
|
||||
| `postal_code`, `city`, `street_name`, stb. | `VARCHAR` | Denormalizált cím |
|
||||
| `location` | `GEOMETRY(POINT)` | PostGIS |
|
||||
| `opening_hours` | `JSONB` | Nyitvatartás |
|
||||
| `branch_rating` | `FLOAT` | Telephely értékelés |
|
||||
| `status` | `VARCHAR(30)` | `active`, `inactive` |
|
||||
| `is_deleted` | `BOOLEAN` | Soft delete |
|
||||
|
||||
---
|
||||
|
||||
## 3. 🗺️ TELJES ADATBÁZIS TÉRKÉP (Minden séma és tábla)
|
||||
|
||||
### 3.1. Identity séma (Személyek, Felhasználók, Hitelesítés)
|
||||
| Tábla | Leírás | Státusz |
|
||||
|-------|--------|---------|
|
||||
| `identity.persons` | Természetes személyek (DNS szint) | ✅ **Aktív** |
|
||||
| `identity.users` | Technikai fiókok (bejelentkezés) | ✅ **Aktív** |
|
||||
| `identity.wallets` | Pénztárcák (Triple Wallet) | ✅ **Aktív** |
|
||||
| `identity.active_vouchers` | Aktív voucher-ek | ✅ **Aktív** |
|
||||
| `identity.user_trust_profiles` | Trust score profilok | ✅ **Aktív** |
|
||||
| `identity.verification_tokens` | Email/telefon verifikáció | ✅ **Aktív** |
|
||||
| `identity.one_time_passwords` | OTP kódok | ✅ **Aktív** |
|
||||
| `identity.social_accounts` | Közösségi média kapcsolatok | ✅ **Aktív** |
|
||||
| `identity.devices` | Eszköz fingerprint | ✅ **Aktív** |
|
||||
| `identity.user_device_links` | User-Eszköz kapcsolatok | ✅ **Aktív** |
|
||||
|
||||
### 3.2. Fleet séma (Szervezetek, Flották)
|
||||
| Tábla | Leírás | Státusz |
|
||||
|-------|--------|---------|
|
||||
| `fleet.organizations` | Szervezetek (cégek, flották, szolgáltatók) | ✅ **Aktív** |
|
||||
| `fleet.organization_members` | Tagságok (User/Person → Organization) | ✅ **Aktív** |
|
||||
| `fleet.organization_financials` | Pénzügyi adatok (éves) | ✅ **Aktív** |
|
||||
| `fleet.branches` | Telephelyek | ✅ **Aktív** |
|
||||
| `fleet.asset_assignments` | Jármű hozzárendelések | ✅ **Aktív** |
|
||||
| `fleet.org_sales_assignments` | Értékesítési hozzárendelések | ✅ **Aktív** |
|
||||
|
||||
### 3.3. Vehicle séma (Járművek, Katalógus)
|
||||
| Tábla | Leírás | Státusz |
|
||||
|-------|--------|---------|
|
||||
| `vehicle.vehicle_model_definitions` | Jármű modellek (MDM) | ✅ **Aktív** |
|
||||
| `vehicle.vehicle_catalog` | Katalógus sablonok | ✅ **Aktív** |
|
||||
| `vehicle.assets` | Fizikai eszközök (Digital Twin) | ✅ **Aktív** |
|
||||
| `vehicle.vehicle_types` | Jármű típusok | ✅ **Aktív** |
|
||||
| `vehicle.feature_definitions` | Felszereltség elemek | ✅ **Aktív** |
|
||||
| `vehicle.model_feature_maps` | Modell-Felszereltség kapcsolatok | ✅ **Aktív** |
|
||||
| `vehicle.dict_body_types` | Karosszéria típus szótár | ✅ **Aktív** |
|
||||
| `vehicle.cost_categories` | Költségkategóriák | ✅ **Aktív** |
|
||||
| `vehicle.costs` | Jármű költségek (TCO) | ✅ **Aktív** |
|
||||
| `vehicle.vehicle_expenses` | Jármű kiadások (jelentésekhez) | ✅ **Aktív** |
|
||||
| `vehicle.odometer_readings` | Kilométeróra állások | ✅ **Aktív** |
|
||||
| `vehicle.vehicle_ownership_history` | Tulajdonos történet | ✅ **Aktív** |
|
||||
| `vehicle.vehicle_transfer_requests` | Átruházási kérelmek | ✅ **Aktív** |
|
||||
| `vehicle.catalog_discovery` | Katalógus felfedező várólista | ✅ **Aktív** |
|
||||
| `vehicle.gb_catalog_discovery` | UK piaci felfedező | ✅ **Aktív** |
|
||||
| `vehicle.auto_data_crawler_queue` | Crawler várólista | ✅ **Aktív** |
|
||||
| `vehicle.vehicle_logbook` | Jármű naplókönyv | ✅ **Aktív** |
|
||||
| `vehicle.motorcycle_specs` | Motorkerékpár specifikációk | ✅ **Aktív** |
|
||||
| `vehicle.external_reference_library` | Külső referencia könyvtár | ✅ **Aktív** |
|
||||
| `vehicle.reference_lookup` | Referencia kereső | ✅ **Aktív** |
|
||||
| `vehicle.vehicle_user_ratings` | Felhasználói jármű értékelések | ✅ **Aktív** |
|
||||
| `vehicle.asset_costs` | Eszköz költségek | ✅ **Aktív** |
|
||||
| `vehicle.asset_events` | Eseménynapló | ✅ **Aktív** |
|
||||
| `vehicle.asset_financials` | Pénzügyi adatok | ✅ **Aktív** |
|
||||
| `vehicle.asset_inspections` | Ellenőrzések | ✅ **Aktív** |
|
||||
| `vehicle.asset_reviews` | Eszköz vélemények | ✅ **Aktív** |
|
||||
| `vehicle.asset_telemetry` | Telemetria adatok | ✅ **Aktív** |
|
||||
|
||||
### 3.4. Marketplace séma (Piactér, Szervizek)
|
||||
| Tábla | Leírás | Státusz |
|
||||
|-------|--------|---------|
|
||||
| `marketplace.service_providers` | Szolgáltatók (RÉGI, egyszerű) | ⚠️ **Használaton kívül?** |
|
||||
| `marketplace.service_profiles` | Szerviz profilok (ÚJ) | ✅ **Aktív** |
|
||||
| `marketplace.service_reviews` | Verifikált vélemények | ✅ **Aktív** |
|
||||
| `marketplace.expertise_tags` | Szakmai címkék | ✅ **Aktív** |
|
||||
| `marketplace.service_expertises` | Szerviz-Szakma kapcsolatok | ✅ **Aktív** |
|
||||
| `marketplace.service_specialties` | Specializációk (hierarchikus) | ✅ **Aktív** |
|
||||
| `marketplace.service_staging` | Robot által gyűjtött adatok | ✅ **Aktív** |
|
||||
| `marketplace.discovery_parameters` | Robot keresési paraméterek | ✅ **Aktív** |
|
||||
| `marketplace.service_requests` | Szervizigények | ✅ **Aktív** |
|
||||
| `marketplace.ratings` | Általános értékelések | ✅ **Aktív** |
|
||||
| `marketplace.votes` | Szavazatok | ✅ **Aktív** |
|
||||
| `marketplace.costs` | Költségnapló (Trust Engine) | ✅ **Aktív** |
|
||||
|
||||
### 3.5. Finance séma (Pénzügyek)
|
||||
| Tábla | Leírás | Státusz |
|
||||
|-------|--------|---------|
|
||||
| `finance.credit_logs` | Hitel napló | ✅ **Aktív** |
|
||||
| `finance.exchange_rates` | Árfolyamok | ✅ **Aktív** |
|
||||
| `finance.issuers` | Kibocsátók | ✅ **Aktív** |
|
||||
| `finance.org_subscriptions` | Szervezeti előfizetések | ✅ **Aktív** |
|
||||
| `finance.user_subscriptions` | Felhasználói előfizetések | ✅ **Aktív** |
|
||||
| `finance.payment_intents` | Fizetési szándékok | ✅ **Aktív** |
|
||||
| `finance.withdrawal_requests` | Kifizetési kérelmek | ✅ **Aktív** |
|
||||
|
||||
### 3.6. System séma (Rendszeradatok)
|
||||
| Tábla | Leírás | Státusz |
|
||||
|-------|--------|---------|
|
||||
| `system.addresses` | Strukturált címek | ✅ **Aktív** |
|
||||
| `system.documents` | Dokumentumok | ✅ **Aktív** |
|
||||
| `system.translations` | Többnyelvű fordítások | ✅ **Aktív** |
|
||||
| `system.system_parameters` | Rendszer paraméterek | ✅ **Aktív** |
|
||||
| `system.internal_notifications` | Belső értesítések | ✅ **Aktív** |
|
||||
| `system.pending_actions` | Függőben lévő műveletek | ✅ **Aktív** |
|
||||
| `system.service_catalog` | Szolgáltatás katalógus | ✅ **Aktív** |
|
||||
| `system.subscription_tiers` | Előfizetési szintek | ✅ **Aktív** |
|
||||
| `system.service_staging` | Szolgáltatás staging | ⚠️ **Duplikáció?** |
|
||||
| `system.staged_vehicle_data` | Jármű adat staging | ✅ **Aktív** |
|
||||
| `system.geo_postal_codes` | Irányítószám adatok | ✅ **Aktív** |
|
||||
| `system.geo_streets` | Utcák | ✅ **Aktív** |
|
||||
| `system.geo_street_types` | Utca típusok | ✅ **Aktív** |
|
||||
| `system.competitions_deprecated` | Versenyek (DEPR) | ❌ **Deprecated** |
|
||||
| `system.user_scores_deprecated` | Pontszámok (DEPR) | ❌ **Deprecated** |
|
||||
| `system.service_staging_deprecated` | Staging (DEPR) | ❌ **Deprecated** |
|
||||
| `system.system_data_completion_weights` | Adatteljességi súlyok | ✅ **Aktív** |
|
||||
|
||||
### 3.7. Audit séma (Naplózás)
|
||||
| Tábla | Leírás | Státusz |
|
||||
|-------|--------|---------|
|
||||
| `audit.audit_logs` | Általános audit napló | ✅ **Aktív** |
|
||||
| `audit.financial_ledger` | Pénzügyi főkönyv | ✅ **Aktív** |
|
||||
| `audit.operational_logs` | Működési napló | ✅ **Aktív** |
|
||||
| `audit.process_logs` | Folyamat napló | ✅ **Aktív** |
|
||||
| `audit.security_audit_logs` | Biztonsági audit napló | ✅ **Aktív** |
|
||||
|
||||
### 3.8. Gamification séma (Játékosítás)
|
||||
| Tábla | Leírás | Státusz |
|
||||
|-------|--------|---------|
|
||||
| `gamification.badges` | Jelvények | ✅ **Aktív** |
|
||||
| `gamification.competitions` | Versenyek | ✅ **Aktív** |
|
||||
| `gamification.level_configs` | Szint konfigurációk | ✅ **Aktív** |
|
||||
| `gamification.point_rules` | Pont szabályok | ✅ **Aktív** |
|
||||
| `gamification.points_ledger` | Pont főkönyv | ✅ **Aktív** |
|
||||
| `gamification.seasonal_competitions` | Szezonális versenyek | ✅ **Aktív** |
|
||||
| `gamification.seasons` | Szezonok | ✅ **Aktív** |
|
||||
| `gamification.user_badges` | Felhasználói jelvények | ✅ **Aktív** |
|
||||
| `gamification.user_contributions` | Felhasználói hozzájárulások | ✅ **Aktív** |
|
||||
| `gamification.user_scores` | Felhasználói pontszámok | ✅ **Aktív** |
|
||||
| `gamification.user_stats` | Felhasználói statisztikák | ✅ **Aktív** |
|
||||
|
||||
---
|
||||
|
||||
## 4. 🔗 KAPCSOLATOK TÉRKÉP (Entity Relationship)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
PERSON ||--o{ USER : "has"
|
||||
PERSON ||--o{ ORGANIZATION_MEMBER : "member of"
|
||||
PERSON ||--o{ ORGANIZATION : "legal owner of"
|
||||
USER ||--o{ ORGANIZATION : "technical owner of"
|
||||
USER ||--o{ SERVICE_REVIEW : "writes"
|
||||
USER ||--o{ SERVICE_REQUEST : "creates"
|
||||
USER ||--o{ VEHICLE_USER_RATING : "rates"
|
||||
ORGANIZATION ||--o{ ORGANIZATION_MEMBER : "has members"
|
||||
ORGANIZATION ||--o{ BRANCH : "has locations"
|
||||
ORGANIZATION ||--o| SERVICE_PROFILE : "optional service"
|
||||
ORGANIZATION ||--o{ ASSET_ASSIGNMENT : "owns vehicles"
|
||||
ORGANIZATION ||--o{ VEHICLE_COST : "pays costs"
|
||||
SERVICE_PROFILE ||--o{ SERVICE_REVIEW : "receives"
|
||||
SERVICE_PROFILE ||--o{ SERVICE_EXPERTISE : "specializes"
|
||||
SERVICE_PROFILE ||--o{ BRANCH : "operates"
|
||||
BRANCH ||--o{ RATING : "rated by"
|
||||
BRANCH ||--o{ SERVICE_REQUEST : "receives requests"
|
||||
VEHICLE_MODEL_DEFINITION ||--o{ ASSET_CATALOG : "has variants"
|
||||
VEHICLE_MODEL_DEFINITION ||--o{ VEHICLE_COST : "has costs"
|
||||
VEHICLE_MODEL_DEFINITION ||--o{ VEHICLE_USER_RATING : "rated by users"
|
||||
ASSET_CATALOG ||--o{ ASSET : "defines"
|
||||
ASSET ||--o{ ASSET_EVENT : "has events"
|
||||
ASSET ||--o{ SERVICE_REQUEST : "referenced in"
|
||||
ADDRESS ||--o{ PERSON : "lives at"
|
||||
ADDRESS ||--o{ ORGANIZATION : "located at"
|
||||
ADDRESS ||--o{ BRANCH : "address"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 💡 JAVASLATOK a "Közösségi Kereső" funkciókhoz
|
||||
|
||||
### 5.1. Jelenlegi hiányosságok
|
||||
|
||||
1. **Nincs dedikált "Partner" entitás** - A `fleet.organizations`-ban az `is_affiliate_partner` egy egyszerű boolean, nincs hozzá partner-specifikus adat (pl. jutalék ráta, partner kategória, szerződés állapota).
|
||||
2. **`ServiceProvider` (régi) és `ServiceProfile` (új) kettősség** - A régi `marketplace.service_providers` tábla használaton kívülinek tűnik, de még létezik.
|
||||
3. **Nincs "aliases" (álnév) támogatás** - Egyik táblában sincs JSONB aliases mező.
|
||||
4. **Kategória rendszer hiányos** - A `marketplace.expertise_tags` és `marketplace.service_specialties` párhuzamos címke rendszert alkot.
|
||||
|
||||
### 5.2. Javasolt bővítés: `Organization` kiegészítése (AJÁNLOTT)
|
||||
|
||||
Ahelyett, hogy új `Partner` táblát hoznánk létre, bővítsük a meglévő `fleet.organizations` táblát az alábbi JSONB oszlopokkal:
|
||||
|
||||
```python
|
||||
# Javasolt új mezők az Organization modellben
|
||||
class Organization(Base):
|
||||
# ... meglévő mezők ...
|
||||
|
||||
# ÚJ: Partnerek / Közösségi kereső
|
||||
aliases: Mapped[dict] = mapped_column(
|
||||
JSONB, server_default=text("'[]'::jsonb"),
|
||||
comment="Álnevek / alternatív nevek (pl. 'AutoShop Kft.', 'Autóshop')"
|
||||
)
|
||||
is_verified: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default=text("false"),
|
||||
comment="Hivatalosan verifikált szervezet"
|
||||
)
|
||||
verification_level: Mapped[str] = mapped_column(
|
||||
String(20), server_default=text("'basic'"),
|
||||
comment="Verifikációs szint: basic, email, phone, kyc, premium"
|
||||
)
|
||||
partner_category: Mapped[str] = mapped_column(
|
||||
String(50), server_default=text("'none'"),
|
||||
comment="Partner kategória: none, basic, silver, gold, platinum"
|
||||
)
|
||||
commission_rate: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(5, 2), nullable=True,
|
||||
comment="Jutalék ráta (%-ban)"
|
||||
)
|
||||
service_radius_km: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True,
|
||||
comment="Szolgáltatási körzet (km)"
|
||||
)
|
||||
tags: Mapped[dict] = mapped_column(
|
||||
JSONB, server_default=text("'[]'::jsonb"),
|
||||
comment="Címkék a közösségi kereséshez"
|
||||
)
|
||||
search_keywords: Mapped[dict] = mapped_column(
|
||||
JSONB, server_default=text("'{}'::jsonb"),
|
||||
comment="Kereső kulcsszavak több nyelven"
|
||||
)
|
||||
social_profiles: Mapped[dict] = mapped_column(
|
||||
JSONB, server_default=text("'{}'::jsonb"),
|
||||
comment="Közösségi média profilok (Facebook, Instagram, Google Business)"
|
||||
)
|
||||
contact_persons: Mapped[dict] = mapped_column(
|
||||
JSONB, server_default=text("'[]'::jsonb"),
|
||||
comment="Kapcsolattartó személyek adatai"
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3. Alternatíva: Új `partner_profiles` tábla
|
||||
|
||||
Ha a `fleet.organizations` túl nagyra nőne, hozzunk létre egy új táblát:
|
||||
|
||||
```python
|
||||
class PartnerProfile(Base):
|
||||
"""Bővített partner profil a Közösségi Keresőhöz."""
|
||||
__tablename__ = "partner_profiles"
|
||||
__table_args__ = {"schema": "marketplace"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
organization_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fleet.organizations.id"), unique=True, nullable=False
|
||||
)
|
||||
|
||||
# --- ALAPADATOK ---
|
||||
aliases: Mapped[dict] = mapped_column(JSONB, server_default=text("'[]'::jsonb"))
|
||||
partner_category: Mapped[str] = mapped_column(String(50), default="none")
|
||||
|
||||
# --- VERIFIKÁCIÓ ---
|
||||
verification_level: Mapped[str] = mapped_column(String(20), default="basic")
|
||||
verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
verified_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
# --- KERESÉS ---
|
||||
search_keywords: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
tags: Mapped[dict] = mapped_column(JSONB, server_default=text("'[]'::jsonb"))
|
||||
service_radius_km: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
|
||||
# --- GAZDASÁGI ADATOK ---
|
||||
commission_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2))
|
||||
contract_start: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
contract_end: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# --- KAPCSOLATOK ---
|
||||
social_profiles: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
contact_persons: Mapped[dict] = mapped_column(JSONB, server_default=text("'[]'::jsonb"))
|
||||
|
||||
# --- AUDIT ---
|
||||
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())
|
||||
```
|
||||
|
||||
### 5.4. Ajánlott stratégia
|
||||
|
||||
1. **Rövid táv**: Az `Organization` modell bővítése JSONB mezőkkel (`aliases`, `tags`, `search_keywords`, `social_profiles`). Ez azonnal használható, nincs szükség új táblára.
|
||||
2. **Közép táv**: Ha a partnerek száma eléri a 10.000+ rekordot, hozzuk létre a dedikált `partner_profiles` táblát az `marketplace` sémában.
|
||||
3. **Hosszú táv**: A régi `marketplace.service_providers` tábla archiválása/eltávolítása, mivel a `ServiceProfile` és a bővített `Organization` lefedi a funkcióit.
|
||||
|
||||
### 5.5. Megjegyzések a "Céges nyilvántartás" funkcióról
|
||||
|
||||
A rendelkezésre álló kód és adatbázis alapján:
|
||||
|
||||
- ✅ **Van "Céges nyilvántartás"** → `fleet.organizations` tábla
|
||||
- ✅ **Van tagságkezelés** → `fleet.organization_members` tábla (User/Person + role)
|
||||
- ✅ **Van telephelykezelés** → `fleet.branches` tábla (PostGIS támogatással)
|
||||
- ✅ **Van szolgáltatói profil** → `marketplace.service_profiles` tábla
|
||||
- ✅ **Van értékelési rendszer** → `marketplace.service_reviews` (verifikált) + `marketplace.ratings` (általános)
|
||||
- ❌ **NINCS dedikált partner modell** - Az `is_affiliate_partner` boolean nem elegendő
|
||||
- ❌ **NINCS keresőoptimalizált adat** (aliases, tags, keywords)
|
||||
|
||||
---
|
||||
|
||||
## 6. ⚠️ FIGYELMEZTETÉSEK
|
||||
|
||||
### 6.1. Duplikált táblák
|
||||
- `marketplace.service_staging` és `system.service_staging` - Valószínűleg csak egy kellene.
|
||||
- `marketplace.costs` és `vehicle.costs` - Két külön költség tábla, különböző sémákban.
|
||||
- `system.competitions_deprecated` → `gamification.competitions`
|
||||
- `system.user_scores_deprecated` → `gamification.user_scores`
|
||||
|
||||
### 6.2. Kettős modell architektúra
|
||||
- `marketplace.service_providers` (régi, egyszerű) vs `marketplace.service_profiles` (új, komplex)
|
||||
- A régi tábla használata nem javasolt új fejlesztésekhez.
|
||||
|
||||
### 6.3. Soft delete következetlenség
|
||||
- `fleet.organizations`: `is_deleted` + `is_active` boolean mezők
|
||||
- `marketplace.service_profiles`: státusz alapú (`ghost`, `active`, `flagged`, `suspended`)
|
||||
- `identity.persons`: `deleted_at` datetime mező
|
||||
- `identity.users`: `is_active` boolean
|
||||
- **Hiányzik egy egységes soft-delete stratégia az egész rendszerben.**
|
||||
|
||||
---
|
||||
|
||||
**Jelentés vége.** A javasolt bővítések implementálása előtt egyeztetés szükséges a Vezető Tervezővel.
|
||||
133
docs/expertise_tag_masterlist_analysis_2026-06-17.md
Normal file
133
docs/expertise_tag_masterlist_analysis_2026-06-17.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 📊 ExpertiseTag Mesterlista — Többszintű Kategória Elemzés
|
||||
|
||||
**Dátum:** 2026-06-17
|
||||
**Szerző:** Rendszer-Architect
|
||||
**Cél:** Annak vizsgálata, hogy az `ExpertiseTag` (`marketplace.expertise_tags`) tábla `category` mezője alkalmas-e többszintű kategória tárolására, és ha igen, milyen módszerekkel tölthetők fel rá adatok.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Vizsgált Modell
|
||||
|
||||
Az `ExpertiseTag` modell az alábbi mezőkkel rendelkezik:
|
||||
|
||||
| Mező | Típus | Leírás |
|
||||
|------|-------|--------|
|
||||
| `id` | `Integer` (PK) | Elsődleges kulcs |
|
||||
| `key` | `String(50)` (unique, index) | Egyedi azonosító kulcs |
|
||||
| `name_hu` | `String(100)` (nullable) | Magyar név |
|
||||
| `name_en` | `String(100)` (nullable) | Angol név |
|
||||
| `category` | **`String(30)`** (index) | Kategória csoportosító címke |
|
||||
| `is_official` | `Boolean` | Hivatalos-e |
|
||||
| `suggested_by_id` | `BigInteger` (FK) | Ki javasolta |
|
||||
| `discovery_points` | `Integer` | Felfedezési pontok |
|
||||
| `search_keywords` | `JSONB` | Keresőkulcsszavak |
|
||||
| `usage_count` | `Integer` | Használati számláló |
|
||||
| `icon` | `String(50)` (nullable) | Ikon neve |
|
||||
| `description` | `Text` (nullable) | Leírás |
|
||||
|
||||
---
|
||||
|
||||
## ❌ 1. Válasz: Alkalmas-e a `category` mező többszintű tárolásra?
|
||||
|
||||
### **NEM, a jelenlegi implementáció NEM alkalmas többszintű hierarchia tárolására.**
|
||||
|
||||
| Követelmény | Jelenlegi állapot | Probléma |
|
||||
|-------------|-------------------|----------|
|
||||
| Hierarchikus kapcsolat | ❌ Nincs `parent_id` mező | Nem lehet szülő-gyermek viszonyt ábrázolni |
|
||||
| Szintek száma | ❌ Nincs `level` mező | Nem tudjuk, hány szint mélyen van egy kategória |
|
||||
| Útvonal (path) | ❌ Nincs `path` / `ltree` mező | Nem lehet gyorsan lekérdezni a teljes útvonalat |
|
||||
| Rekurzív lekérdezés | ❌ Nincs `lft`/`rgt` (nested set) | CTE-vel lehetne, de lassú nagy adathalmazon |
|
||||
| Mező hossza | ⚠️ Csak `String(30)` | "vehicle_service" már 15 karakter; kevés a bővítésre |
|
||||
|
||||
A jelenlegi `category` mező **csak egy lapos csoportosító címke** — hasonló egy `tag`-hez, nem pedig egy hierarchikus kategória struktúrához.
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 2. Jelenlegi Adatbázis Állapot
|
||||
|
||||
Lekérdezés alapján a tábla **11 rekordot** tartalmaz, **8 distinct category** értékkel:
|
||||
|
||||
| ID | Key | Category |
|
||||
|----|-----|----------|
|
||||
| 1 | `auto_szerelo` | `vehicle_service` |
|
||||
| 2 | `motor_szerelo` | `vehicle_service` |
|
||||
| 3 | `gumiszerviz` | `vehicle_service` |
|
||||
| 4 | `karosszerialakatos` | `body_paint` |
|
||||
| 5 | `fényező` | `body_paint` |
|
||||
| 6 | `autómentő` | `roadside` |
|
||||
| 7 | `benzinkút` | `fuel` |
|
||||
| 8 | `alkatrész_kereskedés` | `parts` |
|
||||
| 9 | `vizsgaállomás` | `inspection` |
|
||||
| 10 | `autókozmetika` | `detailing` |
|
||||
| 11 | `egyéb` | `other` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 3. Adatfeltöltési Lehetőségek — Összehasonlítás
|
||||
|
||||
| Módszer | Leírás | Korlátok |
|
||||
|---------|--------|----------|
|
||||
| **1️⃣ Seed Script** | Egyszeri batch betöltés. Idempotens. 11 alap kategória hardcode-olva. | Csak egyszer futtatható; nem lehet módosítani futás után. |
|
||||
| **2️⃣ Direkt SQL INSERT** | `INSERT INTO marketplace.expertise_tags ...` | Nincs validáció, séma integritás ellenőrzés. |
|
||||
| **3️⃣ API (létező): `GET /categories`** | **Csak OLVASÁS** — listázza a meglévő címkéket. | Nincs POST/PUT/DELETE végpont. |
|
||||
| **4️⃣ Admin UI** (`admin_ui.py`) | Csak jármű HITL modelljavításra fókuszál. | Nincs ExpertiseTag menedzsment. |
|
||||
|
||||
### Hiányzó API végpontok:
|
||||
- ❌ `POST /api/v1/admin/categories` — Új szakmai címke létrehozása
|
||||
- ❌ `PUT /api/v1/admin/categories/{id}` — Meglévő címke szerkesztése
|
||||
- ❌ `DELETE /api/v1/admin/categories/{id}` — Címke törlése
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 4. Ajánlott Tervezés Többszintű Kategóriához
|
||||
|
||||
### 4.1. Új mezők az ExpertiseTag modellben
|
||||
|
||||
```python
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("marketplace.expertise_tags.id")
|
||||
)
|
||||
level: Mapped[int] = mapped_column(Integer, default=0)
|
||||
path: Mapped[Optional[str]] = mapped_column(String(200))
|
||||
```
|
||||
|
||||
### 4.2. Tipikus hierarchia példa
|
||||
|
||||
```
|
||||
Level 0 vehicle_service
|
||||
Level 1 (parent->child) ├── auto_szerelo
|
||||
├── motor_szerelo
|
||||
└── gumiszerviz
|
||||
Level 2 (sub-child) └── auto_szerelo/diagnosztika
|
||||
```
|
||||
|
||||
### 4.3. Javasolt admin API bővítés
|
||||
|
||||
```
|
||||
POST /api/v1/admin/categories # Új címke
|
||||
PUT /api/v1/admin/categories/{id} # Szerkesztés
|
||||
DELETE /api/v1/admin/categories/{id} # Törlés (soft-delete)
|
||||
GET /api/v1/admin/categories/tree # Hierarchikus fa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 5. Összefoglalás
|
||||
|
||||
1. A `category` mező (`String(30)`) **lapos, nem hierarchikus** — nem alkalmas többszintű tárolásra
|
||||
2. Jelenleg **nincs CRUD API** a címkék kezelésére (csak `GET /categories`)
|
||||
3. **Nincs admin UI** a szakmai címkék menedzselésére
|
||||
4. A seed script az egyetlen adatbetöltési mód
|
||||
|
||||
### Ajánlott roadmap:
|
||||
|
||||
| Lépés | Feladat |
|
||||
|-------|---------|
|
||||
| 1️⃣ | `logic_spec` készítése a hierarchikus kategória rendszerhez |
|
||||
| 2️⃣ | Alembic migráció — `parent_id`, `level`, `path` mezők |
|
||||
| 3️⃣ | CRUD API végpontok létrehozása |
|
||||
| 4️⃣ | Admin UI integráció |
|
||||
| 5️⃣ | Seed script frissítése hierarchikus adatokkal |
|
||||
|
||||
### Kritikus hibajavítás:
|
||||
A `search_providers()` SQL lekérdezés **NEM JOIN-ol** az `ExpertiseTag` táblára, így a `category` mindig `None` a keresési eredményekben. Ez azonnali javítást igényel.
|
||||
232
docs/full_stack_disconnect_analysis_2026-06-16.md
Normal file
232
docs/full_stack_disconnect_analysis_2026-06-16.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# 🚨 Full Stack Disconnect Analysis — "Extrák" Hiányzó Checkbox Grid
|
||||
|
||||
**Dátum:** 2026-06-16
|
||||
**Elemző:** Rendszer-Architect
|
||||
**Scope:** VehicleFormModal (Frontend) ↔ Asset Schema (Backend) ↔ Asset DB Model (PostgreSQL)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 1. FRONTEND VIZSGÁLAT — VehicleFormModal.vue
|
||||
|
||||
### Jelenlegi lépések (line 870-875):
|
||||
| Index | Lépés neve | Tartalom |
|
||||
|-------|------------|----------|
|
||||
| 0 | **Azonosítás** | Kategória, VIN, Rendszám |
|
||||
| 1 | **Alapadatok** | Márka, Modell, Évjárat, Üzemanyag, **Kivitel/Felszereltség (összemosva)**, Becenév, Km óra |
|
||||
| 2 | **Műszaki** | Hengerűrtartalom, Teljesítmény kW, Hajtás, Váltó, Karosszéria (body_type), Klíma, Ajtók, Ülések, EV blokk, Okmányadatok |
|
||||
| 3 | **Extrák** | Saját besorolás, Saját megjegyzés + Features checkbox grid (csak `personal` esetén!) |
|
||||
|
||||
### Step 3 ("Extrák") valós tartalma (line 684-764):
|
||||
```html
|
||||
<div v-if="step === 3" class="space-y-4">
|
||||
<p>Itt adhatsz hozzá extra információkat a járművedhez.</p>
|
||||
<!-- Saját besorolás (condition) select -->
|
||||
<!-- Saját megjegyzés (notes) input -->
|
||||
|
||||
<!-- Features / Extrák - CSAK personal járműveknél -->
|
||||
<div v-if="form.vehicle_class === 'personal'" class="pt-2">
|
||||
<!-- Technical, Interior, Exterior, Multimedia, Other checkbox groups -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### ⚠️ Kritikus probléma:
|
||||
A **75+ checkbox** csak akkor jelenik meg, ha a felhasználó `personal` (személyautó) kategóriát választott. Minden más járműosztálynál (motorcycle, light_commercial, commercial, work_machine, trailer, bus, camper, boat, aircraft, other) a Step 3 tényleg **üres** — csak "Saját besorolás" és "Saját megjegyzés" látszik. Ez magyarázza a képernyőképen látott üres felületet!
|
||||
|
||||
### A features tömörítve individual_equipment.car_specs.features-ként megy a backendbe
|
||||
|
||||
### Mely fájlba lettek a checkboxok?
|
||||
A checkbox grid a VehicleFormModal.vue fájlban van (line 710-763), de **nem kapta meg a saját dedikált komponensét**, és **nincs külön "Extrák" almappája a kategóriáknak** (Beltér/Kültér/Multimédia/Egyéb). Minden egy `form.features` string tömbben van tárolva, nem pedig strukturált JSONB-ként.
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 2. ADATBÁZIS & SÉMA VIZSGÁLAT
|
||||
|
||||
### Tervező által kért mezők vs Valós állapot
|
||||
|
||||
#### ✅ Azonosítás
|
||||
| Kért mező | Backend (DB Model) | Pydantic Schema | Frontend Form | Státusz |
|
||||
|-----------|-------------------|-----------------|---------------|---------|
|
||||
| Márka | Asset.brand ✅ | AssetCreate.brand ✅ | form.brand ✅ | ✅ OK |
|
||||
| Modell | Asset.model ✅ | AssetCreate.model ✅ | form.model ✅ | ✅ OK |
|
||||
| Típusjel | ❌ **NINCS** | ❌ **NINCS** | ❌ **NINCS** | ❌ **HIÁNYZIK** |
|
||||
|
||||
> **"Típusjel"** — ez a mező sehol nem létezik önálló entitásként. A `trim_level` ("Kivitel / Felszereltség") próbálja helyettesíteni, de a Tervező szerint a Típusjel (pl. "320i", "X5 xDrive30d") külön fogalom.
|
||||
|
||||
#### ✅ Általános
|
||||
| Kért mező | Backend | Schema | Frontend | Státusz |
|
||||
|-----------|---------|--------|----------|---------|
|
||||
| Km óra állás | current_mileage ✅ | ✅ | ✅ | ✅ OK |
|
||||
| Évjárat | year_of_manufacture ✅ | ✅ | ✅ | ✅ OK |
|
||||
| Kivitel (Karosszéria) | ❌ body_type a JSONB-ben | ❌ nincs külön mező | form.body_type (Step 2-ben!) | ❌ **ROSSZ helyen** |
|
||||
| Állapot | condition_score ✅ | ❌ **NINCS a sémában** | form.condition (string!) | ⚠️ **Inkonzisztens** |
|
||||
|
||||
> **"Kivitel (Karosszéria)"** — a `body_type` mező létezik a JSONB-ben (individual_equipment.car_specs.body_type), de a **"Műszaki" (Step 2)** fül alatt van, nem az alapadatok között. A Tervező szerint az Általános adatoknál kellene lennie, külön a "Felszereltségtől".
|
||||
|
||||
#### ✅ Műszaki
|
||||
| Kért mező | Backend | Schema | Frontend | Státusz |
|
||||
|-----------|---------|--------|----------|---------|
|
||||
| Üzemanyag | fuel_type ✅ | ✅ | ✅ | ✅ OK |
|
||||
| Hengerűrtartalom (cm³) | engine_capacity ✅ | ✅ | ✅ | ✅ OK |
|
||||
| Teljesítmény (kW/LE) | power_kw ✅ | ✅ | ✅ (HP display) | ✅ OK |
|
||||
| **Nyomaték (Nm)** | torque_nm ✅ **DB-ben** | ✅ AssetCreate.torque_nm | ❌ **NINCS input mező!** | ❌ **HIÁNYZIK** |
|
||||
|
||||
> **Nyomaték (torque_nm)** — a legkritikusabb hiányzó mező! A backend modellben (Asset.py sor 97) benne van, a Pydantic sémában (AssetCreate.torque_nm sor 151) benne van, de a frontend űrlapban **SEHOL** nincs input mező a Nyomaték megadására. Amikor a felhasználó elmenti az űrlapot, a torque_nm egyszerűen null marad.
|
||||
|
||||
#### ✅ Hajtáslánc
|
||||
| Kért mező | Backend | Schema | Frontend | Státusz |
|
||||
|-----------|---------|--------|----------|---------|
|
||||
| Sebességváltó | transmission_type ✅ | ✅ | ✅ | ✅ OK |
|
||||
| Hajtás | drive_type ✅ | ✅ | ✅ | ✅ OK |
|
||||
|
||||
#### ✅ Kényelem
|
||||
| Kért mező | Backend | Schema | Frontend | Státusz |
|
||||
|-----------|---------|--------|----------|---------|
|
||||
| Klíma fajtája | JSONB-ben (ac_type) | ❌ nincs külön mező | form.ac_type ✅ | ⚠️ JSONB-be van rejtve |
|
||||
|
||||
#### ✅ Okmányok
|
||||
| Kért mező | Backend | Schema | Frontend | Státusz |
|
||||
|-----------|---------|--------|----------|---------|
|
||||
| Forgalmi szám | JSONB-ben | ❌ nincs külön mező | ✅ | ⚠️ JSONB-ben |
|
||||
| Törzskönyv szám | JSONB-ben | ❌ nincs külön mező | ✅ | ⚠️ JSONB-ben |
|
||||
| Műszaki lejárat | JSONB-ben | ❌ nincs külön mező | ✅ | ⚠️ JSONB-ben |
|
||||
| Okmány lejárat | JSONB-ben | ❌ nincs külön mező | ✅ | ⚠️ JSONB-ben |
|
||||
|
||||
#### ✅ Extrák (JSONB)
|
||||
| Kért mező | Backend | Schema | Frontend | Státusz |
|
||||
|-----------|---------|--------|----------|---------|
|
||||
| Beltér | JSONB car_specs.features | individual_equipment | ✅ (checkbox) | ✅ OK |
|
||||
| Kültér | JSONB car_specs.features | individual_equipment | ✅ (checkbox) | ✅ OK |
|
||||
| Multimédia | JSONB car_specs.features | individual_equipment | ✅ (checkbox) | ✅ OK |
|
||||
| Egyéb | JSONB car_specs.features | individual_equipment | ✅ (checkbox) | ✅ OK |
|
||||
|
||||
### További hiányzó mezők a frontendről (de backendben léteznek):
|
||||
| Mező | DB Oszlop | Pydantic Schema | Frontend Input | Státusz |
|
||||
|------|-----------|----------------|---------------|---------|
|
||||
| curb_weight (saját tömeg) | ✅ | ✅ | ❌ | **HIÁNYZIK** |
|
||||
| max_weight (össztömeg) | ✅ | ✅ | ❌ | **HIÁNYZIK** |
|
||||
| cargo_volume_x (raktér hossz) | ✅ | ✅ | ❌ | **HIÁNYZIK** |
|
||||
| cargo_volume_y (raktér szél.) | ✅ | ✅ | ❌ | **HIÁNYZIK** |
|
||||
| roof_type (tető típus) | ✅ | ✅ | ❌ | **HIÁNYZIK** |
|
||||
| audio_system_type (hangi) | ✅ | ✅ | ❌ | **HIÁNYZIK** |
|
||||
|
||||
---
|
||||
|
||||
## 🌐 3. API VÁLASZ VIZSGÁLAT
|
||||
|
||||
### GET /assets/vehicles/{id} (assets.py line 290-337)
|
||||
|
||||
A végpont az AssetResponse Pydantic modellt használja (response_model=AssetResponse). A from_attributes=True miatt az ORM Asset objektum automatikusan Pydantic-ként szerializálódik.
|
||||
|
||||
**A JSON válasz tartalmazza:**
|
||||
- Az összes "sima" oszlopot (brand, model, engine_capacity, power_kw, torque_nm, transmission_type, drive_type, stb.)
|
||||
- Az individual_equipment JSONB mezőt teljes egészében (benne car_specs, ev_specs, dates, documents)
|
||||
- A catalog kapcsolódó objektumot (ha van)
|
||||
|
||||
**A frontend populateForm() (line 1162-1215) helyesen olvassa vissza a JSONB-t**, de a torque_nm sosem kerül beolvasásra, mert nincs hozzá input mező.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 4. ÖSSZEGZÉS: A KAPCSOLAT MEGSZAKADÁSI PONTOK
|
||||
|
||||
### 1. 🔴 **Nyomaték (torque_nm) — Backend adat, frontend nélkül**
|
||||
- **Hol:** DB Asset.torque_nm sor 97, Schema AssetCreate.torque_nm sor 151
|
||||
- **Probléma:** A backend tárolja és a Pydantic séma támogatja, de a VehicleFormModal.vue egyetlen lépésében sincs input mező.
|
||||
- **Következmény:** A flottavezetők soha nem adják meg a nyomatékot, a "Thick Digital Twin" koncepció adathiányos.
|
||||
|
||||
### 2. 🟠 **"Típusjel" mező teljes hiánya**
|
||||
- **Hol:** Sehol az egész stack-ben
|
||||
- **Probléma:** A Tervező által kért Típusjel (pl. "320i", "X5 xDrive30d") fogalma nem létezik. Csak a trim_level van.
|
||||
|
||||
### 3. 🟠 **"Kivitel" és "Felszereltség" összemosása**
|
||||
- **Hol:** VehicleFormModal.vue Step 1 (line 376-409)
|
||||
- **Probléma:** A trim_level egyszerre próbálja lefedni a karosszéria kivitelt ÉS a felszereltségi szintet.
|
||||
|
||||
### 4. 🟡 **Features checkbox grid csak personal osztálynál**
|
||||
- **Hol:** VehicleFormModal.vue line 711
|
||||
- **Probléma:** v-if="form.vehicle_class === 'personal'" — más osztályoknál tényleg üres az Extrák fül.
|
||||
|
||||
### 5. 🟡 **Hiányzó fizikai méret és tömeg mezők**
|
||||
- curb_weight, max_weight, cargo_volume_x, cargo_volume_y — backendben vannak, de nincsenek a formban.
|
||||
|
||||
### 6. 🟡 **Okmányadatok JSONB-be rejtve**
|
||||
- mot_expiry, document_expiry, reg_cert_number, title_cert_number a JSONB-ben, nem dedikált oszlopokban.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 5. JAVASLAT: 7 LÉPÉSES ÚJ ŰRLAPSTRUKTÚRA
|
||||
|
||||
```
|
||||
Step 0: 🚗 AZONOSÍTÁS
|
||||
- Kategória (vehicle_class)
|
||||
- Márka (brand)
|
||||
- Modell (model)
|
||||
- Típusjel (type_designation) ⬅️ ÚJ!
|
||||
- Rendszám (license_plate)
|
||||
- Alvázszám (vin)
|
||||
|
||||
Step 1: 📋 ÁLTALÁNOS ADATOK
|
||||
- Évjárat (year_of_manufacture)
|
||||
- Km óra állás (current_mileage)
|
||||
- Kivitel / Karosszéria (body_type) ⬅️ Külön!
|
||||
- Állapot / Saját besorolás (condition)
|
||||
|
||||
Step 2: ⚙️ MŰSZAKI ADATOK
|
||||
- Üzemanyag (fuel_type)
|
||||
- Hengerűrtartalom (engine_capacity)
|
||||
- Teljesítmény (power_kw) + LE kijelzés
|
||||
- Nyomaték (torque_nm) ⬅️ ÚJ!
|
||||
- Hengerelrendezés (cylinder_layout)
|
||||
|
||||
Step 3: 🔧 HAJTÁSLÁNC
|
||||
- Sebességváltó (transmission_type)
|
||||
- Hajtás (drive_type)
|
||||
|
||||
Step 4: 🌡️ KÉNYELEM & KLÍMA
|
||||
- Klíma fajtája (ac_type)
|
||||
- Tető típus (roof_type) ⬅️ ÚJ!
|
||||
- Hangrendszer (audio_system_type) ⬅️ ÚJ!
|
||||
- Ajtók száma (door_count)
|
||||
- Ülések száma (seat_count)
|
||||
|
||||
Step 5: 📄 OKMÁNYOK
|
||||
- Műszaki érvényesség (mot_expiry)
|
||||
- Okmány érvényesség (document_expiry)
|
||||
- Forgalmi szám (registration_cert_number)
|
||||
- Törzskönyv szám (title_cert_number)
|
||||
|
||||
Step 6: ✨ EXTRÁK (Felszereltség) - Dedikált komponens!
|
||||
- Beltér (interior features)
|
||||
- Kültér (exterior features)
|
||||
- Multimédia (multimedia features)
|
||||
- Technikai (technical features)
|
||||
- Egyéb (other features)
|
||||
- Súly & Méretek (curb_weight, max_weight, cargo)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 HIÁNYZÓ MEZŐK MASTER LIST
|
||||
|
||||
| # | Mező | DB/Séma | Frontend | Javasolt lépés |
|
||||
|---|------|---------|----------|----------------|
|
||||
| 1 | torque_nm | ✅ Van | ❌ NINCS | Step 2 |
|
||||
| 2 | type_designation | ❌ NINCS | ❌ NINCS | Step 0 |
|
||||
| 3 | curb_weight | ✅ Van | ❌ NINCS | Step 6 |
|
||||
| 4 | max_weight | ✅ Van | ❌ NINCS | Step 6 |
|
||||
| 5 | cargo_volume_x | ✅ Van | ❌ NINCS | Step 6 |
|
||||
| 6 | cargo_volume_y | ✅ Van | ❌ NINCS | Step 6 |
|
||||
| 7 | roof_type | ✅ Van | ❌ NINCS | Step 4 |
|
||||
| 8 | audio_system_type | ✅ Van | ❌ NINCS | Step 4 |
|
||||
|
||||
---
|
||||
|
||||
## ⚡ AZONNALI TEENDŐK (Prioritási sorrendben)
|
||||
|
||||
1. **🟢 FRONTEND:** Add hozzá a `torque_nm` input mezőt a "Műszaki" (Step 2) lépéshez. Ez 5 perc fejlesztés és a legnagyobb adatveszteséget szünteti meg.
|
||||
|
||||
2. **🟡 FRONTEND:** Bontsd ki a features checkbox grid-et egy külön Vue komponensbe (`VehicleFeaturesGrid.vue`), és engedélyezd MINDEN járműosztálynál, ne csak personal-nál.
|
||||
|
||||
3. **🟡 FRONTEND+SCHEMA:** Válaszd szét a "Kivitel" (body_type) és "Felszereltség" (trim_level) fogalmakat.
|
||||
|
||||
4. **🔴 ARCHITECT TERV:** Készíts `logic_spec` dokumentációt az űrlap teljes átépítéséhez a fenti 7 lépéses struktúra alapján.
|
||||
131
docs/gamification_penalty_system_reconnaissance_2026-06-17.md
Normal file
131
docs/gamification_penalty_system_reconnaissance_2026-06-17.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Gamification Penalty System — Adatbázis Felderítési Jelentés
|
||||
|
||||
**Dátum:** 2026-06-17
|
||||
**Cél:** A 3-szintű Büntetési Mátrix (Penalty System) és Árnyéktiltás (Shadowban) bevezetésének adatbázis-struktúra előfeltérképezése
|
||||
|
||||
---
|
||||
|
||||
## 1. FELADAT: Büntetési Szint (Penalty Level) — Meglévő Struktúra
|
||||
|
||||
### ✅ Megállapítás: A büntetési infrastruktúra NAGYRÉSZT KÉSZ
|
||||
|
||||
A Vezető Tervező örömére: a rendszer **már tartalmazza** a büntetési pontrendszer alapjait. Az alábbi táblák és mezők **már léteznek** az adatbázisban és a modellekben:
|
||||
|
||||
#### backend/app/models/gamification/gamification.py
|
||||
|
||||
| Tábla (séma) | Mező | Típus | Státusz |
|
||||
|---|---|---|---|
|
||||
| gamification.user_stats | penalty_points | Integer (server_default=0) | ✅ Létezik |
|
||||
| gamification.user_stats | restriction_level | Integer (server_default=0) | ✅ Létezik |
|
||||
| gamification.user_stats | penalty_quota_remaining | Integer (default=3) | ✅ Létezik |
|
||||
| gamification.user_stats | banned_until | DateTime(timezone=True) | ✅ Létezik |
|
||||
| gamification.user_stats | current_level | Integer (default=1) | ✅ Létezik |
|
||||
| gamification.level_configs | level_number | Integer (unique) | ✅ Létezik (támogat negatív értékeket!) |
|
||||
| gamification.level_configs | is_penalty | Boolean (default=False) | ✅ Létezik |
|
||||
| gamification.level_configs | min_points | Integer | ✅ Létezik |
|
||||
| gamification.level_configs | rank_name | String | ✅ Létezik |
|
||||
| gamification.points_ledger | penalty_change | Integer (server_default=0) | ✅ Létezik |
|
||||
| identity.persons | penalty_points | Integer (default=-1) | ✅ Létezik |
|
||||
| identity.persons | social_reputation | Numeric(3,2) | ✅ Létezik |
|
||||
| identity.persons | lifetime_xp | BigInteger (default=-1) | ✅ Létezik |
|
||||
| identity.user_trust_profiles | trust_score | Integer (default=0) | ✅ Létezik |
|
||||
| identity.user_trust_profiles | identity_risk_flag | Boolean (default=False) | ✅ Létezik |
|
||||
|
||||
### ⚠️ HIÁNYZIK: penalty_level (a 3-szintű büntetési mátrix maga)
|
||||
|
||||
A restriction_level mező hasonló koncepció, de jelenleg nincs dedikált penalty_level (Integer, -1 / -2 / -3) mező.
|
||||
|
||||
### Javaslat az új mezők helyére
|
||||
|
||||
A gamification.user_stats táblába érdemes beilleszteni:
|
||||
|
||||
```
|
||||
PenaltyLevel: Integer, server_default=0 (-1=Figyelmeztetés, -2=Korlátozás, -3=Árnyéktiltás)
|
||||
GoodDeedsCount: Integer, server_default=0 (Jóvátételi cselekedetek számlálója)
|
||||
ShadowBannedUntil: DateTime(timezone=True), nullable=True (Látszólagos tiltás vége)
|
||||
```
|
||||
|
||||
**Indoklás:** A user_stats tábla már most is tartalmazza a penalty_points, restriction_level, penalty_quota_remaining és banned_until mezőket — ez a természetes helye minden büntetési információnak. A Person tábla (identity.persons) szintén rendelkezik penalty_points mezővel, de ott ez a személy szintű, nem a gamification profil része.
|
||||
|
||||
---
|
||||
|
||||
## 2. FELADAT: Hibajelentés (Report) Tábla
|
||||
|
||||
### ❌ Megállapítás: NINCS dedikált report/abuse tábla
|
||||
|
||||
Az adatbázisban és a modellek között **NEM található** olyan tábla, amelynek neve report, abuse, flag, ticket, moderation_content vagy data_correction lenne. A PostgreSQL information_schema lekérdezés nulla találatot adott.
|
||||
|
||||
### Ami KÖZELÍT
|
||||
|
||||
| Tábla | Kapcsolat | Korlát |
|
||||
|---|---|---|
|
||||
| marketplace.service_providers | status mező ModerationStatus enumnak | Ez a beküldött szolgáltatók moderálására való, **nem** a visszaélés-jelentésre |
|
||||
| marketplace.service_profiles | flagged státusz a ServiceStatus enumban | Szolgáltatók flag-elésére |
|
||||
| gamification.user_contributions | contribution_type = 'report_abuse' | Naplózza, hogy ki jelentett, de **nincs dedikált jelentés adattábla** |
|
||||
|
||||
### Javaslat: Új user_reports tábla létrehozása
|
||||
|
||||
Javasolt séma a gamification sémában (ahol a user_contributions is van):
|
||||
|
||||
```
|
||||
user_reports tábla (gamification séma):
|
||||
- id Integer (PK)
|
||||
- reporter_id Integer FK->identity.users.id
|
||||
- target_type String(50) ('service_profile', 'review', 'vehicle', 'user')
|
||||
- target_id Integer
|
||||
- reason_category String(50) ('fake_service', 'wrong_data', 'spam', 'abuse', 'other')
|
||||
- description Text (nullable)
|
||||
- evidence_data JSONB (nullable)
|
||||
- status String(20) default='pending' ('pending','verified','dismissed','action_taken')
|
||||
- reviewed_by Integer FK->identity.users.id (nullable)
|
||||
- resolution Text (nullable)
|
||||
- created_at DateTime
|
||||
- resolved_at DateTime (nullable)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. FELADAT: Adminisztrációs Szabályok (System Parameters)
|
||||
|
||||
### ✅ Megállapítás: A rendszer RENDELKEZIK dinamikus paraméter táblával
|
||||
|
||||
A system.system_parameters tábla **már létezik** és kifejezetten alkalmas a büntetési szabályok adminisztrációjára.
|
||||
|
||||
| Oszlop | Típus | Leírás |
|
||||
|---|---|---|
|
||||
| key | VARCHAR | Paraméter kulcs (pl. penalty_level_1_quota) |
|
||||
| category | VARCHAR | Kategorizálás (pl. penalty_system) |
|
||||
| value | JSONB | A paraméter értéke (JSON, bármi lehet) |
|
||||
| scope_level | VARCHAR | Hatáskör: global, country, region, organization, user |
|
||||
| scope_id | VARCHAR | A hatáskör azonosítója |
|
||||
| is_active | BOOLEAN | Aktív-e |
|
||||
| description | VARCHAR | Emberi leírás |
|
||||
|
||||
### Javasolt paraméterkulcsok a büntetési rendszerhez
|
||||
|
||||
| key | category | value (JSON) |
|
||||
|---|---|---|
|
||||
| penalty_level_1_threshold | penalty_system | {"min_bad_actions": 2, "quota_reduction": 1} |
|
||||
| penalty_level_2_threshold | penalty_system | {"min_bad_actions": 5, "xp_multiplier": 0.5} |
|
||||
| penalty_level_3_threshold | penalty_system | {"min_bad_actions": 10, "shadowban": true, "max_actions_per_day": 1} |
|
||||
| penalty_action_points | penalty_system | {"fake_service_submission": 10, "fake_review": 15, "spam_comment": 5} |
|
||||
| penalty_good_deed_recovery | penalty_system | {"points_per_good_deed": -3, "max_recovery_per_day": 1} |
|
||||
|
||||
> **Előny:** A scope_level oszlop lehetővé teszi, hogy **ország-specifikus** vagy akár **felhasználó-specifikus** büntetési szabályokat állítsunk be, anélkül hogy a kódot módosítani kellene.
|
||||
|
||||
---
|
||||
|
||||
## Összefoglaló táblázat
|
||||
|
||||
| Feladat | Státusz | Megjegyzés |
|
||||
|---|---|---|
|
||||
| **1. Büntetési szint helye** | ✅ **Alap infrastruktúra kész** | user_stats.penalty_points, restriction_level, penalty_quota_remaining, banned_until, level_configs.is_penalty — mind létezik. HIÁNYZIK: dedikált penalty_level és good_deeds_count mező |
|
||||
| **2. Hibajelentés tábla** | ❌ **NINCS** | Egyetlen dedikált report/abuse tábla sem létezik. A user_contributions.contribution_type = 'report_abuse' csak naplóz, nem tárol jelentéseket. **Teljesen új tábla kell** |
|
||||
| **3. Adminisztrációs szabályok** | ✅ **Alkalmas** | system.system_parameters JSONB-alapú, scoped paraméter tábla kiválóan megfelel a dinamikus szabályok tárolására. Új paramétereket kell csak beszúrni |
|
||||
|
||||
## Következő lépések (ajánlott sorrend)
|
||||
|
||||
1. **Új mezők hozzáadása** gamification.user_stats-hoz: penalty_level, good_deeds_count, shadow_banned_until
|
||||
2. **Új tábla létrehozása:** gamification.user_reports (vagy gamification.content_reports)
|
||||
3. **Rendszerparaméterek feltöltése:** system.system_parameters büntetési szabályokkal
|
||||
4. **Penalty Engine service** megírása, ami a level_configs táblából és a system_parameters-ből olvassa a szabályokat, és automatikusan lépteti a penalty_level-et
|
||||
223
docs/provider_services_category_analysis_2026-06-17.md
Normal file
223
docs/provider_services_category_analysis_2026-06-17.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Szolgáltatók Szolgáltatásainak (Category/Specialization) Adatfolyam Elemzése
|
||||
|
||||
**Dátum:** 2026-06-17
|
||||
**Készítette:** Rendszer-Architect
|
||||
**Cél:** A szolgáltatók kategóriáinak és szolgáltatásainak teljes verem (adatbázis → backend → frontend) adatfolyamának feltérképezése.
|
||||
|
||||
---
|
||||
|
||||
## Összefoglalás
|
||||
|
||||
A vizsgálat során kiderült, hogy a szolgáltatók kategóriáinak (category) adatfolyama **megszakadt** a backend és a frontend között. A `search_providers()` SQL lekérdezése nem csatlakoztatja a `ServiceExpertise` → `ExpertiseTag` láncot, ezért a `category` mező minden keresési találatnál `None`. Emiatt a frontenden minden kártyán a "Nincs kategória" placeholder látszik, és a detail modalban a kategória blokk teljesen láthatatlan.
|
||||
|
||||
---
|
||||
|
||||
## 1. Adatbázis Réteg
|
||||
|
||||
### 1.1. Érintett Táblák
|
||||
|
||||
| Tábla | Séma | Szerep | Category-t tartalmaz? |
|
||||
|-------|------|--------|----------------------|
|
||||
| ExpertiseTag | marketplace.expertise_tags | Mesterlista - szakmai címkék | Igen - key, name_hu, name_en, category |
|
||||
| ServiceExpertise | marketplace.service_expertises | Kapcsolótábla - service / expertise | Csak FK (service_id, expertise_id) |
|
||||
| ServiceProfile | marketplace.service_profiles | Szerviz szolgáltató profilja | JSONB-ben: specialization_tags.primary_category |
|
||||
| Organization | fleet.organizations | Szervezet (nincs közvetlen category) | Nincs category mező |
|
||||
|
||||
### 1.2. Kapcsolatok
|
||||
|
||||
```
|
||||
ExpertiseTag (marketplace.expertise_tags)
|
||||
^ expertise_id
|
||||
ServiceExpertise (marketplace.service_expertises)
|
||||
^ service_id
|
||||
ServiceProfile (marketplace.service_profiles)
|
||||
^ organization_id (unique)
|
||||
Organization (fleet.organizations)
|
||||
```
|
||||
|
||||
### 1.3. ExpertiseTag Tábla Szerkezete
|
||||
|
||||
| Mezo | Típus | Leírás |
|
||||
|------|-------|--------|
|
||||
| id | Integer (PK) | |
|
||||
| key | String(50), unique | Pl. "auto_szerelo", "gumiszerviz" |
|
||||
| name_hu | String(100) | Magyar név, pl. "Autószerelő" |
|
||||
| name_en | String(100) | Angol név, pl. "Car mechanic" |
|
||||
| category | String(30) | Csoportosító, pl. "auto" |
|
||||
| is_official | Boolean | Hivatalos-e |
|
||||
| search_keywords | JSONB | Keresőkulcsszavak |
|
||||
| icon | String(50) | Ikon neve |
|
||||
|
||||
### 1.4. ServiceProfile.specialization_tags JSONB Szerkezet
|
||||
|
||||
```json
|
||||
{
|
||||
"primary_category": "auto_szerelo",
|
||||
"user_tags": ["olajcsere", "fék", "klíma"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend API Réteg
|
||||
|
||||
### 2.1. GET /categories (Muködik)
|
||||
|
||||
**Végpont:** backend/app/api/v1/endpoints/providers.py:30
|
||||
|
||||
**Séma:** backend/app/schemas/provider.py:14 - ExpertiseCategoryOut
|
||||
|
||||
Visszaadja az összes ExpertiseTag rekordot. Használja a ProviderQuickAddModal a category_id kiválasztáshoz.
|
||||
|
||||
### 2.2. GET /search (KRITIKUS HIBA)
|
||||
|
||||
**Végpont:** backend/app/api/v1/endpoints/providers.py:60
|
||||
|
||||
**Szolgáltatás:** backend/app/services/provider_service.py:130 - search_providers()
|
||||
|
||||
**Séma:** backend/app/schemas/provider.py:25 - ProviderSearchResult
|
||||
|
||||
```python
|
||||
class ProviderSearchResult(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
category: Optional[str] = None # DEFAULT: None!
|
||||
specialization: List[str] = Field(default_factory=list)
|
||||
```
|
||||
|
||||
#### A HIBA: Az org_stmt SELECT nem tartalmazza a category-t
|
||||
|
||||
A provider_service.py:194-225 sorokban az org_stmt SELECT listája:
|
||||
|
||||
```python
|
||||
org_stmt = select(
|
||||
Organization.id.label("id"),
|
||||
Organization.name.label("name"),
|
||||
# ... címmezok ...
|
||||
ServiceProfile.specialization_tags.label("specialization_tags"),
|
||||
case(...).label("source"),
|
||||
# HIÁNYZIK: LEFT JOIN a ServiceExpertise / ExpertiseTag láncon
|
||||
# HIÁNYZIK: category lekeres
|
||||
).outerjoin(ServiceProfile, ...)
|
||||
```
|
||||
|
||||
#### Eredmény: A ProviderSearchResult építésekor (318-342. sor) a category soha nincs beállítva
|
||||
|
||||
```python
|
||||
for row in rows:
|
||||
results.append(
|
||||
ProviderSearchResult(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
# category itt NINCS paraméterként! -> default: None
|
||||
tags=tags_list,
|
||||
source=row.source,
|
||||
is_verified=row.is_verified,
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### 2.3. POST /quick-add (Muködik)
|
||||
|
||||
**Végpont:** backend/app/api/v1/endpoints/providers.py:98
|
||||
|
||||
Helyesen hozza létre a ServiceExpertise kapcsolatot:
|
||||
|
||||
```python
|
||||
expertise = ServiceExpertise(
|
||||
service_id=profile.id,
|
||||
expertise_id=data.category_id,
|
||||
confidence_level=50,
|
||||
)
|
||||
db.add(expertise)
|
||||
```
|
||||
|
||||
### 2.4. PUT /{provider_id} - NEM kezeli a category-t
|
||||
|
||||
A ProviderUpdateIn séma **NEM tartalmaz category_id-t**, csak name, címmezok, contact_phone, email, website, tags.
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Réteg
|
||||
|
||||
### 3.1. Kártya (ServiceFinderView.vue)
|
||||
|
||||
**Category badge (263-277. sor):**
|
||||
```vue
|
||||
<span v-if="provider.category">...SOHA nem fut le, mert category = None...</span>
|
||||
<span v-else>...MINDEN kártyán "Nincs kategória"...</span>
|
||||
```
|
||||
|
||||
**Specialization chips (244-261. sor):** A provider.specialization lista megjelenik, ha van benne adat.
|
||||
|
||||
### 3.2. Detail Modal (ProviderDetailModal.vue)
|
||||
|
||||
**Category blokk (42-52. sor):** v-if="provider.category" mindig false -> a blokk láthatatlan.
|
||||
|
||||
### 3.3. Edit Modal (ProviderEditModal.vue)
|
||||
|
||||
**HIÁNYZIK:** Nincs kategória kiválasztó mező! Csak name, címmezok, phone, email, website, tagsInput.
|
||||
|
||||
### 3.4. Quick-Add Modal (ProviderQuickAddModal.vue)
|
||||
|
||||
Tartalmaz category_id kiválasztó dropdown-ot, ami a GET /categories-ból tölti az adatokat.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fejlesztési Javaslatok
|
||||
|
||||
### #1 (KRITIKUS) - Category lekerese a search API-ban
|
||||
|
||||
**Probléma:** A search_providers() nem tolti be a category-t.
|
||||
|
||||
**Megoldás:** Correlated subquery hozzaadasa:
|
||||
|
||||
```python
|
||||
category_subquery = (
|
||||
select(ExpertiseTag.name_hu)
|
||||
.select_from(ServiceExpertise)
|
||||
.join(ExpertiseTag, ServiceExpertise.expertise_id == ExpertiseTag.id)
|
||||
.where(ServiceExpertise.service_id == ServiceProfile.id)
|
||||
.limit(1)
|
||||
.correlate(ServiceProfile)
|
||||
.label("category")
|
||||
)
|
||||
```
|
||||
|
||||
**Erintett fajlok:**
|
||||
- backend/app/services/provider_service.py:194 - org_stmt SELECT kiegeszitese
|
||||
- backend/app/services/provider_service.py:326 - ProviderSearchResult epitesekor category atadasa
|
||||
|
||||
### #2 (HIÁNYZÓ FUNKCIÓ) - Kategoria kivalaszto az Edit Modalban
|
||||
|
||||
**Probléma:** Az EditModal nem tartalmaz kategoria kivalasztot.
|
||||
|
||||
**Megoldás:** Kategoria dropdown hozzaadasa, ami betolti a GET /categories adatait.
|
||||
|
||||
**Erintett fajlok:**
|
||||
- frontend/src/components/provider/ProviderEditModal.vue - uj kategoria kivalaszto
|
||||
- backend/app/schemas/provider.py:89 - ProviderUpdateIn kiegeszitese category_id mezovel
|
||||
- backend/app/services/provider_service.py:497 - update_provider() kiegeszitese ServiceExpertise kezelessel
|
||||
|
||||
### #3 (HIÁNYZÓ FUNKCIÓ) - Category szuro implementalasa
|
||||
|
||||
**Probléma:** A search_providers() fuggveny elfogadja a category parametert, de nem hasznalja a szureshez.
|
||||
|
||||
### #4 (ADAT INTEGRITÁS) - Category fogalmanak tisztazasa
|
||||
|
||||
Javaslat: A category mezo a ProviderSearchResult-ban az ExpertiseTag.name_hu erteket tartalmazza (a felhasznalo szamara ertelmes nevet), nem pedig a technikai category csoportosítót.
|
||||
|
||||
---
|
||||
|
||||
## 5. Érintett Fájlok Listája
|
||||
|
||||
| Fájl | Szerep | Status |
|
||||
|------|--------|--------|
|
||||
| backend/app/models/marketplace/service.py | Adatbázis modellek | OK |
|
||||
| backend/app/schemas/provider.py | Pydantic sémák | OK (de hianyzik category_id a ProviderUpdateIn-bol) |
|
||||
| backend/app/services/provider_service.py | Üzleti logika | HIBA: hianyzik a category JOIN |
|
||||
| backend/app/api/v1/endpoints/providers.py | API végpontok | OK |
|
||||
| frontend/src/views/ServiceFinderView.vue | Kereso nézet | OK (de category = None) |
|
||||
| frontend/src/components/provider/ProviderDetailModal.vue | Detail modal | OK (de category blokk rejtve) |
|
||||
| frontend/src/components/provider/ProviderEditModal.vue | Edit modal | HIANYZIK a kategoria kivalaszto |
|
||||
| frontend/src/components/provider/ProviderQuickAddModal.vue | Gyors felvetel | OK |
|
||||
160
docs/service_book_schema_audit.md
Normal file
160
docs/service_book_schema_audit.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 🔍 Digitális Szerviznapló (Service Book) Architektúra Audit
|
||||
|
||||
**Dátum:** 2026-06-15
|
||||
**Auditor:** Fast Coder (Core Developer)
|
||||
**Cél:** A Vezető Tervező részére készült mélyreható elemzés a Szerviz események, Javítások és Okmány lejáratok adatbázis-szintű előkészítettségéről.
|
||||
|
||||
---
|
||||
|
||||
## 1. Összefoglaló
|
||||
|
||||
A rendszer **rendelkezik dedikált táblákkal** a Digitális Szerviznaplóhoz, de azok **részben előkészítettek**, részben pedig a `JSONB` mezőkbe vannak integrálva. Nincs külön `ServiceEvent` vagy `Document` tábla — a szerviz eseményeket az [`AssetEvent`](../backend/app/models/vehicle/asset.py:395) modell kezeli, a dokumentumokat (biztosítás, műszaki) pedig az [`Asset.individual_equipment`](../backend/app/models/vehicle/asset.py:114) JSONB mező.
|
||||
|
||||
---
|
||||
|
||||
## 2. Táblaszerkezet Áttekintés
|
||||
|
||||
### 2.1. `vehicle.asset_events` — Digitális Szervizkönyv (Fő tábla)
|
||||
|
||||
**Modell:** [`AssetEvent`](../backend/app/models/vehicle/asset.py:395)
|
||||
**Tábla:** `vehicle.asset_events`
|
||||
|
||||
| Mező | Típus | Leírás |
|
||||
|------|-------|--------|
|
||||
| `id` | UUID (PK) | Elsődleges kulcs |
|
||||
| `asset_id` | UUID (FK → `vehicle.assets.id`) | Jármű hivatkozás |
|
||||
| `user_id` | Integer (FK → `identity.users.id`) | Ki végezte a műveletet |
|
||||
| `organization_id` | Integer (FK → `fleet.organizations.id`) | Szervezet |
|
||||
| `event_type` | String(50) | Esemény típusa (lásd `AssetEventTypeEnum`) |
|
||||
| `odometer_reading` | Integer (nullable) | Km óra állás az eseménykor |
|
||||
| `description` | Text (nullable) | Leírás |
|
||||
| `cost_id` | UUID (FK → `vehicle.asset_costs.id`, nullable) | Kapcsolódó költség |
|
||||
| `event_date` | DateTime | Esemény dátuma |
|
||||
| `created_at` / `updated_at` | DateTime | Időbélyegek |
|
||||
|
||||
**Támogatott eseménytípusok** ([`AssetEventTypeEnum`](../backend/app/models/vehicle/asset.py:383)):
|
||||
- `SERVICE` — Szerviz
|
||||
- `REPAIR` — Javítás
|
||||
- `ACCIDENT` — Baleset
|
||||
- `INSPECTION` — Műszaki vizsga
|
||||
- `TIRE_CHANGE` — Gumi csere
|
||||
- `MAINTENANCE` — Karbantartás
|
||||
- `UPGRADE` — Fejlesztés
|
||||
- `RECALL` — Visszahívás
|
||||
|
||||
**Kapcsolatok:**
|
||||
- `asset` → [`Asset`](../backend/app/models/vehicle/asset.py:69) (Many-to-One)
|
||||
- `user` → [`User`](../backend/app/models/identity/user.py) (Many-to-One)
|
||||
- `organization` → [`Organization`](../backend/app/models/marketplace/organization.py) (Many-to-One)
|
||||
- `cost` → [`AssetCost`](../backend/app/models/vehicle/asset.py:246) (Many-to-One)
|
||||
|
||||
### 2.2. `vehicle.odometer_readings` — Km óra állás történet
|
||||
|
||||
**Modell:** [`OdometerReading`](../backend/app/models/vehicle/asset.py:353)
|
||||
**Tábla:** `vehicle.odometer_readings`
|
||||
|
||||
| Mező | Típus | Leírás |
|
||||
|------|-------|--------|
|
||||
| `id` | UUID (PK) | Elsődleges kulcs |
|
||||
| `asset_id` | UUID (FK → `vehicle.assets.id`) | Jármű |
|
||||
| `reading` | Integer | Km óra állás |
|
||||
| `recorded_at` | DateTime | Rögzítés ideje |
|
||||
| `source` | String(30) | Forrás: `manual`, `api`, `telemetry` |
|
||||
| `cost_id` | UUID (FK → `vehicle.asset_costs.id`, nullable) | Kapcsolódó költség |
|
||||
|
||||
### 2.3. `vehicle.asset_costs` — Költségnapló
|
||||
|
||||
**Modell:** [`AssetCost`](../backend/app/models/vehicle/asset.py:246)
|
||||
**Tábla:** `vehicle.asset_costs`
|
||||
|
||||
A költségek `data` JSONB mezőjében tárolódnak a szerviz specifikus adatok:
|
||||
- `odometer` — km óra állás a költségkor
|
||||
- `description` — leírás
|
||||
- `type` — típus (pl. `maintenance`)
|
||||
- `invoice_number` — számlaszám
|
||||
|
||||
### 2.4. `vehicle.assets.individual_equipment` — JSONB (Okmányok, Biztosítás)
|
||||
|
||||
**Mező:** [`Asset.individual_equipment`](../backend/app/models/vehicle/asset.py:114) — JSONB
|
||||
|
||||
Itt tárolódnak a **dokumentum lejáratok** és egyéb jármű specifikus adatok:
|
||||
- `insurance_due_date` — Biztosítás fordulónap
|
||||
- `insurance_premium` — Biztosítás díja
|
||||
- `mot_due_date` — Műszaki vizsga lejárata
|
||||
- `tire_change_date` — Gumicsere időpont
|
||||
- `tire_change_description` — Gumicsere leírás
|
||||
- `avg_consumption` — Átlagos fogyasztás
|
||||
- `avg_cost_per_km` — Átlagos km költség
|
||||
- `is_primary` — Elsődleges jármű flag
|
||||
- `color` — Szín
|
||||
- `next_service_days` — Következő szerviz napokban
|
||||
|
||||
---
|
||||
|
||||
## 3. Hiányosságok és Javaslatok
|
||||
|
||||
### 3.1. Dedikált `service_book` tábla hiánya
|
||||
|
||||
**Jelenlegi állapot:** Az [`AssetEvent`](../backend/app/models/vehicle/asset.py:395) modell egy generalista eseménytábla, amely minden típusú eseményt (szerviz, javítás, baleset, műszaki) egy táblában kezel. Nincs külön `ServiceEvent` tábla.
|
||||
|
||||
**Javaslat:** Az `AssetEvent` megfelelő a Digitális Szerviznaplóhoz, de érdemes lehet:
|
||||
- `service_provider` (Integer, FK → `marketplace.service_providers.id`) mező hozzáadása a szervizpartner azonosításához
|
||||
- `parts_cost` / `labor_cost` bontás a költségekhez
|
||||
- `warranty_claim` (Boolean) mező a garanciális javításokhoz
|
||||
|
||||
### 3.2. Dokumentumok tárolása
|
||||
|
||||
**Jelenlegi állapot:** A biztosítás, műszaki vizsga és gumicsere adatok az [`individual_equipment`](../backend/app/models/vehicle/asset.py:114) JSONB mezőben vannak. Nincs dedikált `Document` tábla a jármű dokumentumokhoz.
|
||||
|
||||
**Javaslat:** Hozz létre egy `vehicle.vehicle_documents` táblát az alábbi mezőkkel:
|
||||
- `id` (UUID, PK)
|
||||
- `asset_id` (UUID, FK → `vehicle.assets.id`)
|
||||
- `document_type` (String) — pl. `insurance`, `mot`, `registration`, `service_invoice`
|
||||
- `document_number` (String) — pl. biztosítási kötvényszám
|
||||
- `issue_date` (Date)
|
||||
- `expiry_date` (Date)
|
||||
- `document_url` (String) — feltöltött fájl elérési útja
|
||||
- `notes` (Text)
|
||||
|
||||
### 3.3. Szerviz intervallumok
|
||||
|
||||
**Jelenlegi állapot:** A következő szerviz számítása a frontenden történik (`nextServiceKm = current_mileage + 15000`), nincs adatbázis szintű szervizütemezés.
|
||||
|
||||
**Javaslat:** Vezess be egy `vehicle.service_schedules` táblát:
|
||||
- `id` (UUID, PK)
|
||||
- `asset_id` (UUID, FK → `vehicle.assets.id`)
|
||||
- `schedule_type` (String) — `time_based` vagy `mileage_based`
|
||||
- `interval_km` (Integer) — km intervallum
|
||||
- `interval_days` (Integer) — nap intervallum
|
||||
- `last_service_km` (Integer) — utolsó szerviz km állása
|
||||
- `last_service_date` (Date) — utolsó szerviz dátuma
|
||||
- `next_service_km` (Integer) — következő esedékes km
|
||||
- `next_service_date` (Date) — következő esedékes dátum
|
||||
|
||||
### 3.4. API végpontok hiánya
|
||||
|
||||
**Jelenlegi állapot:** Az [`AssetEvent`](../backend/app/models/vehicle/asset.py:395) modellhez tartozik egy `POST /{asset_id}/maintenance` végpont ([`create_maintenance_record`](../backend/app/api/v1/endpoints/assets.py:580)), de nincs:
|
||||
- `GET /assets/{id}/events` — események listázása
|
||||
- `GET /assets/{id}/events/{event_id}` — egy esemény részletei
|
||||
- `PUT /assets/{id}/events/{event_id}` — esemény módosítása
|
||||
- `DELETE /assets/{id}/events/{event_id}` — esemény törlése
|
||||
|
||||
---
|
||||
|
||||
## 4. Következtetés
|
||||
|
||||
| Komponens | Státusz | Megjegyzés |
|
||||
|-----------|---------|------------|
|
||||
| Szerviz események (`AssetEvent`) | ✅ **Kész** | Dedikált tábla, 8 eseménytípussal |
|
||||
| Km óra állás (`OdometerReading`) | ✅ **Kész** | Időbeli nyomonkövetés |
|
||||
| Költségnapló (`AssetCost`) | ✅ **Kész** | JSONB adatokkal |
|
||||
| Okmány lejáratok | ⚠️ **JSONB-ben** | `individual_equipment` mezőben |
|
||||
| Szerviz intervallumok | ❌ **Hiányzik** | Frontenden számolva |
|
||||
| API végpontok | ⚠️ **Részleges** | Csak `POST /maintenance` létezik |
|
||||
| Dedikált dokumentum tábla | ❌ **Hiányzik** | Javasolt létrehozni |
|
||||
|
||||
A Digitális Szerviznapló alap infrastruktúrája készen áll, de a következő fejlesztési fázisban érdemes:
|
||||
1. Létrehozni a `vehicle_documents` táblát
|
||||
2. Létrehozni a `service_schedules` táblát
|
||||
3. Kibővíteni az API végpontokat CRUD műveletekkel
|
||||
4. Átmigrálni a JSONB adatokat a dedikált táblákba
|
||||
294
frontend/src/components/cost/CostManagerModal.vue
Normal file
294
frontend/src/components/cost/CostManagerModal.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-white w-11/12 max-w-4xl max-h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<div class="h-16 bg-slate-700 w-full shrink-0 flex items-center px-6">
|
||||
<span class="text-white font-bold text-lg tracking-wide">📊 {{ t('finance.costManager') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Scrollable Content ── -->
|
||||
<div class="flex-1 overflow-y-auto p-6 text-slate-800">
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-slate-500 mb-4">{{ t('finance.costManagerDescription') }}</p>
|
||||
|
||||
<!-- Loading State -->
|
||||
<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 -->
|
||||
<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('finance.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Cost Table ── -->
|
||||
<div v-else>
|
||||
<!-- 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-3">{{ t('finance.costDescription') }}</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.cost_type)"
|
||||
>
|
||||
{{ 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, 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_label || cost.license_plate || '—' }}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="col-span-3 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>
|
||||
</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('finance.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('finance.next') }} →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../../api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// ── 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
|
||||
|
||||
// ── 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: number | null, currency?: string): string {
|
||||
if (amount == null) return '—'
|
||||
const formatted = Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
return currency ? `${formatted} ${currency}` : `${formatted} Ft`
|
||||
}
|
||||
|
||||
function costTypeBadge(type: string | null): string {
|
||||
switch (type) {
|
||||
case 'fuel':
|
||||
case 'FUEL':
|
||||
return 'bg-orange-100 text-orange-700'
|
||||
case 'service':
|
||||
case 'SERVICE':
|
||||
return 'bg-blue-100 text-blue-700'
|
||||
case 'insurance':
|
||||
case 'INSURANCE':
|
||||
return 'bg-purple-100 text-purple-700'
|
||||
case 'tax':
|
||||
case 'TAX':
|
||||
return 'bg-red-100 text-red-700'
|
||||
case 'parking':
|
||||
case 'PARKING':
|
||||
return 'bg-amber-100 text-amber-700'
|
||||
case 'toll':
|
||||
case 'TOLL':
|
||||
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 res = await api.get('/expenses', {
|
||||
params: {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize,
|
||||
},
|
||||
})
|
||||
const data = res.data
|
||||
costs.value = data.data || data.items || data.results || []
|
||||
totalPages.value = data.pagination?.total_pages || data.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('[CostManagerModal] fetchCosts error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
fetchCosts()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
fetchCosts()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
currentPage.value = 1
|
||||
fetchCosts()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isOpen) {
|
||||
fetchCosts()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes flipIn {
|
||||
from {
|
||||
transform: perspective(1200px) rotateY(-90deg);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: perspective(1200px) rotateY(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-flip-in {
|
||||
animation: flipIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -149,6 +149,7 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useCostStore } from '../../stores/cost'
|
||||
import api from '../../api/axios'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
import { getLocalDateString } from '../../services/dateUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -166,7 +167,7 @@ const costStore = useCostStore()
|
||||
|
||||
// ── Form State ──
|
||||
const form = reactive({
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
date: getLocalDateString(),
|
||||
amount: 0,
|
||||
mainCategory: '',
|
||||
subCategory: '',
|
||||
@@ -281,7 +282,7 @@ async function handleSubmit() {
|
||||
|
||||
if (result) {
|
||||
// Reset form
|
||||
form.date = new Date().toISOString().slice(0, 10)
|
||||
form.date = getLocalDateString()
|
||||
form.amount = 0
|
||||
form.mainCategory = ''
|
||||
form.subCategory = ''
|
||||
@@ -293,6 +294,18 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the form date to today's local date every time the modal opens.
|
||||
* This ensures the date-picker always shows the correct local date,
|
||||
* even if the component is kept alive between opens.
|
||||
*/
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
form.date = getLocalDateString()
|
||||
errorMessage.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
})
|
||||
|
||||
180
frontend/src/components/dashboard/FinancialCard.vue
Normal file
180
frontend/src/components/dashboard/FinancialCard.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="$emit('open-details')"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('finance.wallet') }}</span>
|
||||
<span
|
||||
v-if="!isLoading && !error"
|
||||
class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700"
|
||||
>
|
||||
{{ t('finance.live') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex-1 flex flex-col items-center justify-center text-center px-4"
|
||||
>
|
||||
<svg class="w-10 h-10 text-amber-500 mb-2" 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-xs text-slate-500">{{ error }}</p>
|
||||
<button
|
||||
@click="retry"
|
||||
class="mt-2 text-xs text-sf-accent hover:underline"
|
||||
>
|
||||
{{ t('finance.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Balance Content -->
|
||||
<div v-else class="flex-1 flex flex-col gap-3">
|
||||
<!-- Total Credits (big number) -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
|
||||
{{ t('finance.totalCredits') }}
|
||||
</p>
|
||||
<p class="text-3xl font-extrabold text-slate-800">
|
||||
{{ formatCredits(totalCredits) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown: Purchased + Service Coins -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
|
||||
<p class="text-xs text-slate-500 font-medium uppercase tracking-wider">{{ t('finance.purchased') }}</p>
|
||||
<p class="text-lg font-bold text-slate-800 mt-0.5">{{ formatCredits(purchasedCredits) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
|
||||
<p class="text-xs text-slate-500 font-medium uppercase tracking-wider">{{ t('finance.bonus') }}</p>
|
||||
<p class="text-lg font-bold text-emerald-600 mt-0.5">{{ formatCredits(serviceCoins) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Earned + Voucher mini row -->
|
||||
<div class="flex items-center justify-between text-xs text-slate-400 px-1">
|
||||
<span>{{ t('finance.earned') }}: <strong class="text-slate-600">{{ formatCredits(earnedCredits) }}</strong></span>
|
||||
<span>{{ t('finance.voucher') }}: <strong class="text-slate-600">{{ formatCredits(voucherBalance) }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom CTA: Top Up button -->
|
||||
<button
|
||||
@click="handleTopUp"
|
||||
class="mt-auto mb-4 mx-auto px-8 py-3 bg-gradient-to-r from-sf-accent to-emerald-500 hover:from-sf-accent/90 hover:to-emerald-600 text-white rounded-2xl font-medium text-sm transition-all shadow-md hover:shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
{{ t('finance.topUp') }} ⚡
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFinanceStore } from '../../stores/finance'
|
||||
|
||||
const { t } = useI18n()
|
||||
const financeStore = useFinanceStore()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
'open-details': []
|
||||
}>()
|
||||
|
||||
// ── Reactive state from store ──
|
||||
const isLoading = computed(() => financeStore.isLoading)
|
||||
const error = computed(() => financeStore.error)
|
||||
const totalCredits = computed(() => financeStore.totalCredits)
|
||||
const purchasedCredits = computed(() => financeStore.purchasedCredits)
|
||||
const serviceCoins = computed(() => financeStore.serviceCoins)
|
||||
const earnedCredits = computed(() => financeStore.earnedCredits)
|
||||
const voucherBalance = computed(() => financeStore.voucherBalance)
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
/**
|
||||
* Format credit amount for display.
|
||||
* Shows 2 decimal places if fractional, otherwise integer.
|
||||
*/
|
||||
function formatCredits(value: number): string {
|
||||
if (Number.isInteger(value)) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Top Up button click.
|
||||
* Currently shows a toast notification — payment gateway coming soon.
|
||||
*/
|
||||
function handleTopUp() {
|
||||
// Show a toast-like notification
|
||||
const toast = document.createElement('div')
|
||||
toast.className =
|
||||
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
|
||||
toast.textContent = '🔔 Fizetési kapu hamarosan elérhető!'
|
||||
document.body.appendChild(toast)
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-slide-out')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry fetching the wallet balance.
|
||||
*/
|
||||
function retry() {
|
||||
financeStore.fetchWalletBalance()
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
onMounted(() => {
|
||||
financeStore.fetchWalletBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
.animate-slide-out {
|
||||
animation: slideOut 0.3s ease-in forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -110,10 +110,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useCostStore } from '../../stores/cost'
|
||||
import api from '../../api/axios'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
import { getLocalDateString } from '../../services/dateUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -128,7 +129,7 @@ const emit = defineEmits<{
|
||||
const costStore = useCostStore()
|
||||
|
||||
const form = reactive({
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
date: getLocalDateString(),
|
||||
amount: 0,
|
||||
quantity: 0,
|
||||
odometer: 0,
|
||||
@@ -152,6 +153,18 @@ async function resolveFuelCategory() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the form date to today's local date every time the modal opens.
|
||||
* This ensures the date-picker always shows the correct local date,
|
||||
* even if the component is kept alive between opens.
|
||||
*/
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
form.date = getLocalDateString()
|
||||
errorMessage.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
resolveFuelCategory()
|
||||
})
|
||||
@@ -192,7 +205,7 @@ async function handleSubmit() {
|
||||
|
||||
if (result) {
|
||||
// Reset form
|
||||
form.date = new Date().toISOString().slice(0, 10)
|
||||
form.date = getLocalDateString()
|
||||
form.amount = 0
|
||||
form.quantity = 0
|
||||
form.odometer = 0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
475
frontend/src/components/dashboard/WalletDetailsModal.vue
Normal file
475
frontend/src/components/dashboard/WalletDetailsModal.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-white w-11/12 max-w-3xl max-h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<div class="h-16 bg-slate-700 w-full shrink-0 flex items-center px-6">
|
||||
<span class="text-white font-bold text-lg tracking-wide">💰 {{ t('finance.walletDetails') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Scrollable Content ── -->
|
||||
<div class="flex-1 overflow-y-auto p-6 text-slate-800">
|
||||
<!-- Loading State -->
|
||||
<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 -->
|
||||
<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="retry"
|
||||
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
|
||||
>
|
||||
{{ t('finance.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Wallet Content ── -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
Section 1: 4-Pocket Overview
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
|
||||
{{ t('finance.walletBreakdown') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Purchased -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
💳 {{ t('finance.purchased') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.topUpWallet') }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-extrabold text-slate-800">{{ formatCredits(purchasedCredits) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Earned -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
🏆 {{ t('finance.earned') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.referralRewards') }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-extrabold text-emerald-600">{{ formatCredits(earnedCredits) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Service Coins -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
🪙 {{ t('finance.bonus') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.rewards') }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-extrabold text-amber-600">{{ formatCredits(serviceCoins) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Voucher -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
🎟️ {{ t('finance.voucher') }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">{{ t('finance.activeVouchers') }}</span>
|
||||
</div>
|
||||
<p class="text-2xl font-extrabold text-purple-600">{{ formatCredits(voucherBalance) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Balance Bar -->
|
||||
<div class="mt-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 p-4 text-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-semibold opacity-90">{{ t('finance.totalBalance') }}</span>
|
||||
<span class="text-2xl font-extrabold">{{ formatCredits(totalCredits) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
Section 2: Action Buttons
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="handleTopUp"
|
||||
class="flex-1 px-6 py-3 bg-gradient-to-r from-sf-accent to-emerald-500 hover:from-sf-accent/90 hover:to-emerald-600 text-white rounded-2xl font-medium text-sm transition-all shadow-md hover:shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
⚡ {{ t('finance.topUp') }}
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
class="flex-1 px-6 py-3 rounded-2xl font-medium text-sm bg-slate-200 text-slate-400 cursor-not-allowed opacity-50 shadow-md"
|
||||
:title="t('finance.payoutComingSoon')"
|
||||
>
|
||||
💸 {{ t('finance.requestPayout') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════
|
||||
Section 3: Transaction History
|
||||
════════════════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-slate-500 uppercase tracking-wider">
|
||||
📋 {{ t('finance.transactionHistory') }}
|
||||
</h3>
|
||||
<!-- Filter chips -->
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
v-for="filter in availableFilters"
|
||||
:key="filter.value"
|
||||
@click="activeFilter = filter.value; fetchTransactions()"
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors"
|
||||
:class="activeFilter === filter.value
|
||||
? 'bg-sf-accent text-white'
|
||||
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction List -->
|
||||
<div class="space-y-2">
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-if="txLoading"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div
|
||||
v-else-if="transactions.length === 0"
|
||||
class="flex flex-col items-center justify-center py-8 text-slate-400"
|
||||
>
|
||||
<svg class="w-10 h-10 mb-2" 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.noTransactions') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Items -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="tx in transactions"
|
||||
:key="tx.id"
|
||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-white p-3.5 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-3 min-w-0">
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
:class="tx.entry_type === 'CREDIT' ? 'bg-emerald-100' : 'bg-red-100'"
|
||||
>
|
||||
<span class="text-sm">{{ tx.entry_type === 'CREDIT' ? '⬆' : '⬇' }}</span>
|
||||
</div>
|
||||
<!-- Details -->
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-slate-800 truncate">
|
||||
{{ tx.description || tx.transaction_type || t('finance.transaction') }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">
|
||||
{{ formatDate(tx.created_at) }}
|
||||
</p>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
:class="walletTypeBadge(tx.wallet_type)"
|
||||
>
|
||||
{{ tx.wallet_type || '—' }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
:class="tx.status === 'SUCCESS' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'"
|
||||
>
|
||||
{{ tx.status || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount -->
|
||||
<div class="shrink-0 text-right ml-4">
|
||||
<p
|
||||
class="text-sm font-bold"
|
||||
:class="tx.entry_type === 'CREDIT' ? 'text-emerald-600' : 'text-red-600'"
|
||||
>
|
||||
{{ tx.entry_type === 'CREDIT' ? '+' : '-' }}{{ formatCredits(tx.amount) }}
|
||||
</p>
|
||||
<p class="text-[10px] text-slate-400 mt-0.5 font-mono">
|
||||
#{{ String(tx.id).slice(0, 8) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="flex items-center justify-between pt-2"
|
||||
>
|
||||
<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('finance.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('finance.next') }} →
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFinanceStore } from '../../stores/finance'
|
||||
import api from '../../api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
const financeStore = useFinanceStore()
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// ── Wallet balance from store ──
|
||||
const isLoading = computed(() => financeStore.isLoading)
|
||||
const error = computed(() => financeStore.error)
|
||||
const totalCredits = computed(() => financeStore.totalCredits)
|
||||
const purchasedCredits = computed(() => financeStore.purchasedCredits)
|
||||
const serviceCoins = computed(() => financeStore.serviceCoins)
|
||||
const earnedCredits = computed(() => financeStore.earnedCredits)
|
||||
const voucherBalance = computed(() => financeStore.voucherBalance)
|
||||
|
||||
// ── Transaction state ──
|
||||
const transactions = ref<any[]>([])
|
||||
const txLoading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const activeFilter = ref<string | null>(null)
|
||||
|
||||
const availableFilters = [
|
||||
{ value: null, label: t('finance.all') },
|
||||
{ value: 'PURCHASED', label: t('finance.purchased') },
|
||||
{ value: 'EARNED', label: t('finance.earned') },
|
||||
{ value: 'SERVICE_COINS', label: t('finance.bonus') },
|
||||
]
|
||||
|
||||
const pageSize = 20
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function formatCredits(value: number): string {
|
||||
if (Number.isInteger(value)) {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function walletTypeBadge(type: string | null): string {
|
||||
switch (type) {
|
||||
case 'PURCHASED': return 'bg-blue-100 text-blue-700'
|
||||
case 'EARNED': return 'bg-emerald-100 text-emerald-700'
|
||||
case 'SERVICE_COINS': return 'bg-amber-100 text-amber-700'
|
||||
case 'VOUCHER': return 'bg-purple-100 text-purple-700'
|
||||
default: return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
async function fetchTransactions() {
|
||||
txLoading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (activeFilter.value) {
|
||||
params.wallet_type = activeFilter.value
|
||||
}
|
||||
|
||||
const res = await api.get('/billing/wallet/transactions', { params })
|
||||
const data = res.data
|
||||
|
||||
transactions.value = data.data || []
|
||||
totalCount.value = data.pagination?.total_count || 0
|
||||
totalPages.value = data.pagination?.total_pages || 1
|
||||
} catch (err: any) {
|
||||
console.error('[WalletDetailsModal] fetchTransactions error:', err)
|
||||
transactions.value = []
|
||||
} finally {
|
||||
txLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
fetchTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
fetchTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTopUp() {
|
||||
const toast = document.createElement('div')
|
||||
toast.className =
|
||||
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
|
||||
toast.textContent = '🔔 Fizetési kapu hamarosan elérhető!'
|
||||
document.body.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-slide-out')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function handleWithdraw() {
|
||||
if (earnedCredits.value <= 0) return
|
||||
|
||||
const toast = document.createElement('div')
|
||||
toast.className =
|
||||
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
|
||||
toast.textContent = `💸 Kifizetési kérelem: ${formatCredits(earnedCredits.value)} egység — hamarosan elérhető!`
|
||||
document.body.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-slide-out')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
financeStore.fetchWalletBalance()
|
||||
fetchTransactions()
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
financeStore.fetchWalletBalance()
|
||||
currentPage.value = 1
|
||||
fetchTransactions()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isOpen) {
|
||||
financeStore.fetchWalletBalance()
|
||||
fetchTransactions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes flipIn {
|
||||
from {
|
||||
transform: perspective(1200px) rotateY(-90deg);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: perspective(1200px) rotateY(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-flip-in {
|
||||
animation: flipIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
|
||||
}
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
.animate-slide-out {
|
||||
animation: slideOut 0.3s ease-in forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full border-b border-white/[0.06] bg-[#04151F]/80 backdrop-blur-2xl"
|
||||
class="sticky top-0 z-40 w-full border-b border-white/[0.06] bg-[#04151F]/80 backdrop-blur-2xl"
|
||||
>
|
||||
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<!-- ── Left Slot ─────────────────────────────────────────────── -->
|
||||
|
||||
299
frontend/src/components/provider/ProviderAutocomplete.vue
Normal file
299
frontend/src/components/provider/ProviderAutocomplete.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<!-- ── Search Input ── -->
|
||||
<div
|
||||
v-if="!selectedProvider"
|
||||
class="relative"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('provider.autocompletePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 pl-10 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
@input="onSearchInput"
|
||||
@focus="onSearchFocus"
|
||||
@blur="onSearchBlur"
|
||||
@keydown.down.prevent="highlightIndex < filteredResults.length - 1 && highlightIndex++"
|
||||
@keydown.up.prevent="highlightIndex > 0 && highlightIndex--"
|
||||
@keydown.enter.prevent="selectResult(filteredResults[highlightIndex])"
|
||||
@keydown.escape="showDropdown = false"
|
||||
/>
|
||||
<!-- Search Icon -->
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<svg
|
||||
v-if="isSearching"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-sf-accent"
|
||||
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>
|
||||
|
||||
<!-- ── Selected Provider Card ── -->
|
||||
<div
|
||||
v-if="selectedProvider"
|
||||
class="flex items-start gap-3 rounded-xl border border-sf-accent/30 bg-sf-accent/5 p-3"
|
||||
>
|
||||
<!-- Avatar Placeholder -->
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent font-bold text-sm">
|
||||
{{ selectedProvider.name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-800 truncate">{{ selectedProvider.name }}</p>
|
||||
<p v-if="selectedProvider.category" class="text-xs text-slate-500">{{ selectedProvider.category }}</p>
|
||||
<p v-if="selectedProvider.city || selectedProvider.address" class="text-xs text-slate-400 truncate">
|
||||
{{ selectedProvider.city }}{{ selectedProvider.city && selectedProvider.address ? ', ' : '' }}{{ selectedProvider.address }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
@click="clearSelection"
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
|
||||
:aria-label="t('provider.autocompleteClear')"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Dropdown Results ── -->
|
||||
<div
|
||||
v-if="showDropdown && filteredResults.length > 0"
|
||||
class="absolute z-50 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-64 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="(result, idx) in filteredResults"
|
||||
:key="`${result.source}-${result.id}`"
|
||||
@mousedown.prevent="selectResult(result)"
|
||||
class="w-full flex items-start gap-3 px-4 py-3 text-left transition-colors border-b border-slate-100 last:border-b-0"
|
||||
:class="idx === highlightIndex ? 'bg-sf-accent/10' : 'hover:bg-slate-50'"
|
||||
>
|
||||
<!-- Source Badge -->
|
||||
<div
|
||||
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
:class="sourceBadgeClass(result.source)"
|
||||
>
|
||||
{{ result.name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-slate-800 truncate">{{ result.name }}</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
<span v-if="result.category">{{ result.category }}</span>
|
||||
<span v-if="result.city" class="ml-1">· {{ result.city }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
<span
|
||||
class="inline-block rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||
:class="sourceLabelClass(result.source)"
|
||||
>
|
||||
{{ sourceLabel(result.source) }}
|
||||
</span>
|
||||
<span v-if="result.is_verified" class="ml-1 text-emerald-600">{{ t('provider.autocompleteVerified') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- ── "Add New Provider" Footer ── -->
|
||||
<div class="border-t border-slate-200 px-3 py-2">
|
||||
<button
|
||||
@mousedown.prevent="$emit('open-quick-add')"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('provider.autocompleteAddNew') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Empty State (no results) ── -->
|
||||
<div
|
||||
v-if="showDropdown && filteredResults.length === 0 && searchQuery.length >= 2 && !isSearching"
|
||||
class="absolute z-50 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg p-4"
|
||||
>
|
||||
<p class="text-sm text-slate-500 text-center mb-3">{{ t('provider.autocompleteNoResults') }}</p>
|
||||
<button
|
||||
@mousedown.prevent="$emit('open-quick-add')"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('provider.autocompleteAddNew') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Types ──
|
||||
export interface ProviderSearchResult {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
specialization: string[]
|
||||
city: string | null
|
||||
address: string | null
|
||||
source: 'verified_org' | 'staged_data' | 'crowd_added'
|
||||
is_verified: boolean
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
modelValue?: ProviderSearchResult | null
|
||||
}>()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: ProviderSearchResult | null): void
|
||||
(e: 'select', value: ProviderSearchResult): void
|
||||
(e: 'clear'): void
|
||||
(e: 'open-quick-add'): void
|
||||
}>()
|
||||
|
||||
// ── State ──
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const selectedProvider = ref<ProviderSearchResult | null>(props.modelValue || null)
|
||||
const results = ref<ProviderSearchResult[]>([])
|
||||
const isSearching = ref(false)
|
||||
const showDropdown = ref(false)
|
||||
const highlightIndex = ref(0)
|
||||
|
||||
// ── Debounce Timer ──
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Computed ──
|
||||
const filteredResults = computed(() => {
|
||||
return results.value
|
||||
})
|
||||
|
||||
// ── Source Badge / Label Helpers ──
|
||||
function sourceBadgeClass(source: string): string {
|
||||
switch (source) {
|
||||
case 'verified_org': return 'bg-emerald-100 text-emerald-700'
|
||||
case 'staged_data': return 'bg-amber-100 text-amber-700'
|
||||
case 'crowd_added': return 'bg-blue-100 text-blue-700'
|
||||
default: return 'bg-slate-100 text-slate-700'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabelClass(source: string): string {
|
||||
switch (source) {
|
||||
case 'verified_org': return 'bg-emerald-50 text-emerald-600'
|
||||
case 'staged_data': return 'bg-amber-50 text-amber-600'
|
||||
case 'crowd_added': return 'bg-blue-50 text-blue-600'
|
||||
default: return 'bg-slate-50 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'verified_org': return t('provider.autocompleteSourceVerified')
|
||||
case 'staged_data': return t('provider.autocompleteSourceRobot')
|
||||
case 'crowd_added': return t('provider.autocompleteSourceCommunity')
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search API ──
|
||||
async function performSearch(query: string) {
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isSearching.value = true
|
||||
try {
|
||||
const response = await api.get('providers/search', {
|
||||
params: { q: query, limit: 10 },
|
||||
})
|
||||
results.value = response.data.results || []
|
||||
highlightIndex.value = 0
|
||||
showDropdown.value = true
|
||||
} catch (error) {
|
||||
console.error('[ProviderAutocomplete] Search error:', error)
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event Handlers ──
|
||||
function onSearchInput() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
performSearch(searchQuery.value)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onSearchFocus() {
|
||||
if (results.value.length > 0) {
|
||||
showDropdown.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchBlur() {
|
||||
setTimeout(() => {
|
||||
showDropdown.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function selectResult(result: ProviderSearchResult) {
|
||||
selectedProvider.value = result
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
emit('update:modelValue', result)
|
||||
emit('select', result)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedProvider.value = null
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
emit('update:modelValue', null)
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
// ── Watch modelValue from parent ──
|
||||
watch(() => props.modelValue, (val) => {
|
||||
selectedProvider.value = val || null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
</style>
|
||||
277
frontend/src/components/provider/ProviderDetailModal.vue
Normal file
277
frontend/src/components/provider/ProviderDetailModal.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen && provider"
|
||||
class="fixed inset-0 z-[10001] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-fade-in-up"
|
||||
>
|
||||
<!-- ── Header ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('serviceFinder.detailTitle') }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
|
||||
:aria-label="t('serviceFinder.close')"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- ── Body ── -->
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Provider Name -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-extrabold text-slate-800">{{ provider.name }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div v-if="provider.category" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailCategory') }}</p>
|
||||
<p class="text-sm font-medium text-slate-800">{{ provider.category }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address (P1 CRITICAL ALIGN: atomizált címmezők intelligens összefűzése) -->
|
||||
<div v-if="hasAddress" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailAddress') }}</p>
|
||||
<p class="text-sm font-medium text-slate-800">
|
||||
{{ formattedAddress }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSource') }}</p>
|
||||
<p class="text-sm font-medium text-slate-800">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
:class="sourceBadgeClass"
|
||||
>
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Specialization -->
|
||||
<div v-if="provider.specialization && provider.specialization.length > 0" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSpecialization') }}</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
||||
<span
|
||||
v-for="(tag, idx) in provider.specialization"
|
||||
:key="idx"
|
||||
class="inline-flex items-center rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
<div v-if="provider.rating" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<svg
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="h-4 w-4"
|
||||
:class="star <= Math.round(provider.rating!) ? 'text-amber-400' : 'text-slate-200'"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span class="ml-1 text-sm font-medium text-slate-700">{{ provider.rating.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No rating fallback -->
|
||||
<div v-if="!provider.rating" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
|
||||
<p class="text-sm text-slate-400 italic">{{ t('serviceFinder.detailNoRating') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-slate-200" />
|
||||
|
||||
<!-- ✏️ Edit / Complete Data Button -->
|
||||
<button
|
||||
@click="emit('edit-request', provider)"
|
||||
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3.5 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98] 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="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('serviceFinder.editRequest') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Types matching backend ProviderSearchResult ──
|
||||
interface ProviderSearchResult {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
specialization: string[]
|
||||
city: string | null
|
||||
address: string | null
|
||||
address_zip: string | null
|
||||
address_street_name: string | null
|
||||
address_street_type: string | null
|
||||
address_house_number: string | null
|
||||
contact_phone: string | null
|
||||
contact_email: string | null
|
||||
website: string | null
|
||||
tags: string[]
|
||||
source: 'verified_org' | 'staged_data' | 'crowd_added'
|
||||
is_verified: boolean
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
provider: ProviderSearchResult | null
|
||||
}>()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'edit-request', provider: ProviderSearchResult): void
|
||||
}>()
|
||||
|
||||
// ── Computed helpers ──
|
||||
const sourceBadgeClass = computed(() => {
|
||||
switch (props.provider?.source) {
|
||||
case 'verified_org': return 'bg-emerald-100 text-emerald-700'
|
||||
case 'crowd_added': return 'bg-amber-100 text-amber-700'
|
||||
case 'staged_data': return 'bg-slate-100 text-slate-600'
|
||||
default: return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
})
|
||||
|
||||
const sourceLabel = computed(() => {
|
||||
switch (props.provider?.source) {
|
||||
case 'verified_org': return t('serviceFinder.sourceVerified')
|
||||
case 'crowd_added': return t('serviceFinder.sourceCommunity')
|
||||
case 'staged_data': return t('serviceFinder.sourceRobot')
|
||||
default: return props.provider?.source || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* P1 CRITICAL ALIGN: Atomizált címmezők intelligens összefűzése.
|
||||
* A provider.address_street_name + ' ' + provider.address_street_type + ' ' + provider.address_house_number
|
||||
* Ha a city is meg van adva, akkor "city, street_name street_type house_number" formátumban jelenik meg.
|
||||
*/
|
||||
const hasAddress = computed(() => {
|
||||
const p = props.provider
|
||||
if (!p) return false
|
||||
return !!(p.city || p.address_street_name || p.address_street_type || p.address_house_number)
|
||||
})
|
||||
|
||||
const formattedAddress = computed(() => {
|
||||
const p = props.provider
|
||||
if (!p) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// ZIP + City
|
||||
const locationParts: string[] = []
|
||||
if (p.address_zip) locationParts.push(p.address_zip)
|
||||
if (p.city) locationParts.push(p.city)
|
||||
if (locationParts.length > 0) parts.push(locationParts.join(' '))
|
||||
|
||||
// Atomizált cím: street_name + street_type + house_number
|
||||
const streetParts: string[] = []
|
||||
if (p.address_street_name) streetParts.push(p.address_street_name)
|
||||
if (p.address_street_type) streetParts.push(p.address_street_type)
|
||||
if (p.address_house_number) streetParts.push(p.address_house_number)
|
||||
|
||||
if (streetParts.length > 0) {
|
||||
parts.push(streetParts.join(' '))
|
||||
}
|
||||
|
||||
return parts.join(', ')
|
||||
})
|
||||
</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>
|
||||
413
frontend/src/components/provider/ProviderEditModal.vue
Normal file
413
frontend/src/components/provider/ProviderEditModal.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen && provider"
|
||||
class="fixed inset-0 z-[10002] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
|
||||
>
|
||||
<!-- ── Header ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
|
||||
<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="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>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.editTitle') }}</h3>
|
||||
<p class="text-xs text-slate-500">{{ provider.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
|
||||
:aria-label="t('serviceFinder.close')"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- ── Form Body ── -->
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Atomizált címmezők (P1 CRITICAL ALIGN) ── -->
|
||||
<!-- Street Name -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.streetNameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_street_name"
|
||||
type="text"
|
||||
maxlength="150"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.streetNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Street Type + House Number row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.streetTypeLabel') }}
|
||||
</label>
|
||||
<select
|
||||
v-model="form.address_street_type"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">{{ t('provider.streetTypePlaceholder') }}</option>
|
||||
<option value="utca">utca</option>
|
||||
<option value="út">út</option>
|
||||
<option value="tér">tér</option>
|
||||
<option value="körút">körút</option>
|
||||
<option value="sugárút">sugárút</option>
|
||||
<option value="sétány">sétány</option>
|
||||
<option value="köz">köz</option>
|
||||
<option value="sor">sor</option>
|
||||
<option value="liget">liget</option>
|
||||
<option value="park">park</option>
|
||||
<option value="rakpart">rakpart</option>
|
||||
<option value="dűlő">dűlő</option>
|
||||
<option value="hegy">hegy</option>
|
||||
<option value="lakópark">lakópark</option>
|
||||
<option value="üdülőtelep">üdülőtelep</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.houseNumberLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_house_number"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.houseNumberPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- City + ZIP row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.zipLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.zip"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.zipPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.cityLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.cityPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.phoneLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.phonePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.emailLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.websiteLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.website"
|
||||
type="url"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.websitePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags (comma-separated) -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.tagsLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tagsInput"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.tagsPlaceholder')"
|
||||
/>
|
||||
<p v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(tag, idx) in form.tags"
|
||||
:key="idx"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
@click="removeTag(idx)"
|
||||
class="ml-0.5 text-sf-accent/60 hover:text-red-500 cursor-pointer"
|
||||
>×</button>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-slate-200" />
|
||||
|
||||
<!-- Info text -->
|
||||
<p class="text-xs text-slate-400 italic">
|
||||
{{ t('provider.editInfo') }}
|
||||
</p>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="flex-1 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:bg-slate-50 cursor-pointer"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleSave"
|
||||
:disabled="isSaving || !form.name.trim()"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 cursor-pointer"
|
||||
>
|
||||
<svg v-if="isSaving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ isSaving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ProviderEditModal.vue — Teljes Szerkesztő Ablak szolgáltató adatokhoz.
|
||||
*
|
||||
* P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
* - A régi single 'street' mező helyett 3 külön mező:
|
||||
* address_street_name, address_street_type, address_house_number
|
||||
* - A payload pontosan az új, atomizált kulcsokkal megy a backend felé.
|
||||
*
|
||||
* @emits close — modal bezárása
|
||||
* @emits saved — sikeres mentés után, payload: { id: number }
|
||||
*
|
||||
* @see backend/app/api/v1/endpoints/providers.py — PUT /providers/{id}
|
||||
* @see frontend/src/components/provider/ProviderDetailModal.vue — hívó
|
||||
*/
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Types ──
|
||||
interface ProviderSearchResult {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
specialization: string[]
|
||||
city: string | null
|
||||
address: string | null
|
||||
address_zip: string | null
|
||||
address_street_name: string | null
|
||||
address_street_type: string | null
|
||||
address_house_number: string | null
|
||||
contact_phone: string | null
|
||||
contact_email: string | null
|
||||
website: string | null
|
||||
tags: string[]
|
||||
source: 'verified_org' | 'staged_data' | 'crowd_added'
|
||||
is_verified: boolean
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
provider: ProviderSearchResult | null
|
||||
}>()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'saved', payload: { id: number }): void
|
||||
}>()
|
||||
|
||||
// ── Form State ──
|
||||
const isSaving = ref(false)
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
city: string
|
||||
zip: string
|
||||
address_street_name: string
|
||||
address_street_type: string
|
||||
address_house_number: string
|
||||
phone: string
|
||||
email: string
|
||||
website: string
|
||||
tagsInput: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
name: '',
|
||||
city: '',
|
||||
zip: '',
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
tagsInput: '',
|
||||
tags: [],
|
||||
})
|
||||
|
||||
// ── Watch provider changes → populate form ──
|
||||
watch(
|
||||
() => props.provider,
|
||||
(provider) => {
|
||||
if (provider) {
|
||||
form.name = provider.name || ''
|
||||
form.city = provider.city || ''
|
||||
form.zip = provider.address_zip || ''
|
||||
// P1 CRITICAL ALIGN: Atomizált címmezők kitöltése
|
||||
form.address_street_name = provider.address_street_name || ''
|
||||
form.address_street_type = provider.address_street_type || ''
|
||||
form.address_house_number = provider.address_house_number || ''
|
||||
form.phone = provider.contact_phone || ''
|
||||
form.email = provider.contact_email || ''
|
||||
form.website = provider.website || ''
|
||||
form.tagsInput = ''
|
||||
form.tags = provider.tags && provider.tags.length > 0
|
||||
? [...provider.tags]
|
||||
: provider.specialization
|
||||
? [...provider.specialization]
|
||||
: []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ── Tag management ──
|
||||
watch(
|
||||
() => form.tagsInput,
|
||||
(val) => {
|
||||
if (val.endsWith(',') || val.endsWith(';')) {
|
||||
const newTag = val.slice(0, -1).trim()
|
||||
if (newTag && !form.tags.includes(newTag)) {
|
||||
form.tags.push(newTag)
|
||||
}
|
||||
form.tagsInput = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function removeTag(index: number) {
|
||||
form.tags.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Save handler (async — calls backend PUT /providers/{id}) ──
|
||||
async function handleSave() {
|
||||
if (!form.name.trim() || !props.provider) return
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
city: form.city.trim() || null,
|
||||
address_zip: form.zip.trim() || null,
|
||||
address_street_name: form.address_street_name.trim() || null,
|
||||
address_street_type: form.address_street_type.trim() || null,
|
||||
address_house_number: form.address_house_number.trim() || null,
|
||||
contact_phone: form.phone.trim() || null,
|
||||
contact_email: form.email.trim() || null,
|
||||
website: form.website.trim() || null,
|
||||
tags: form.tags.length > 0 ? [...form.tags] : null,
|
||||
}
|
||||
|
||||
await api.put(`providers/${props.provider.id}`, body)
|
||||
|
||||
// Emit success so parent can refresh search results
|
||||
emit('saved', { id: props.provider.id })
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
console.error('[ProviderEditModal] Save error:', err)
|
||||
// Keep modal open on error so user can retry
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</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>
|
||||
579
frontend/src/components/provider/ProviderQuickAddModal.vue
Normal file
579
frontend/src/components/provider/ProviderQuickAddModal.vue
Normal file
@@ -0,0 +1,579 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[10001] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-fade-in-up"
|
||||
>
|
||||
<!-- ── Header ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
|
||||
<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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.quickAddTitle') }}</h3>
|
||||
<p class="text-xs text-slate-500">{{ t('provider.quickAddSubtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
|
||||
:aria-label="t('provider.closeButton')"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- ── Form Body ── -->
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||
<!-- Provider Name -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="200"
|
||||
:placeholder="t('provider.namePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category Dropdown -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.categoryLabel') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="form.category_id"
|
||||
required
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" disabled>{{ t('provider.categoryPlaceholder') }}</option>
|
||||
<option
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
:value="cat.id"
|
||||
>
|
||||
{{ cat.name_hu || cat.name_en || cat.key }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- ZIP Code -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.zipLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.zipOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_zip"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
:placeholder="t('provider.zipPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- City -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.cityLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.cityOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:placeholder="t('provider.cityPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Atomizált címmezők (P1 CRITICAL ALIGN) ── -->
|
||||
<!-- Street Name -->
|
||||
<div>
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_street_name"
|
||||
type="text"
|
||||
maxlength="150"
|
||||
:placeholder="t('provider.streetNamePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Street Type + House Number row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.streetTypeLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.streetTypeOptional') }})</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="form.address_street_type"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="">{{ t('provider.streetTypePlaceholder') }}</option>
|
||||
<option value="utca">utca</option>
|
||||
<option value="út">út</option>
|
||||
<option value="tér">tér</option>
|
||||
<option value="körút">körút</option>
|
||||
<option value="sugárút">sugárút</option>
|
||||
<option value="sétány">sétány</option>
|
||||
<option value="köz">köz</option>
|
||||
<option value="sor">sor</option>
|
||||
<option value="liget">liget</option>
|
||||
<option value="park">park</option>
|
||||
<option value="rakpart">rakpart</option>
|
||||
<option value="dűlő">dűlő</option>
|
||||
<option value="hegy">hegy</option>
|
||||
<option value="lakópark">lakópark</option>
|
||||
<option value="üdülőtelep">üdülőtelep</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.houseNumberLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.houseNumberOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_house_number"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
:placeholder="t('provider.houseNumberPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div>
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
maxlength="50"
|
||||
:placeholder="t('provider.phonePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
maxlength="255"
|
||||
:placeholder="t('provider.emailPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.websiteLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.websiteOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.website"
|
||||
type="url"
|
||||
maxlength="255"
|
||||
:placeholder="t('provider.websitePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags (comma-separated) -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.tagsLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.tagsOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tagsInput"
|
||||
type="text"
|
||||
:placeholder="t('provider.tagsPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
<p v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(tag, idx) in form.tags"
|
||||
:key="idx"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
|
||||
>
|
||||
{{ tag }}
|
||||
<button
|
||||
@click="removeTag(idx)"
|
||||
type="button"
|
||||
class="ml-0.5 text-sf-accent/60 hover:text-red-500 cursor-pointer"
|
||||
>×</button>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Soft-Deduplication Warning -->
|
||||
<div
|
||||
v-if="duplicateWarning"
|
||||
class="rounded-lg bg-amber-50 border border-amber-300 px-4 py-3 text-sm text-amber-800"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="h-5 w-5 mt-0.5 shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span>{{ duplicateWarning }}</span>
|
||||
</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"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Success Toast (gamification) -->
|
||||
<div
|
||||
v-if="successMessage"
|
||||
class="rounded-xl bg-gradient-to-r from-emerald-50 to-emerald-100 border border-emerald-300 px-5 py-4 text-center animate-fade-in-up"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 mb-1">
|
||||
<span class="text-2xl">🎉</span>
|
||||
<span class="text-lg font-bold text-emerald-800">{{ successMessage }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-emerald-600 font-medium">{{ t('provider.successSubtext') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
v-if="!successMessage"
|
||||
type="submit"
|
||||
:disabled="isSubmitting || !isFormValid"
|
||||
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
v-if="isSubmitting"
|
||||
class="h-4 w-4 animate-spin"
|
||||
fill="none" viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ isSubmitting ? t('provider.submitting') : t('provider.submitButton') }}
|
||||
</button>
|
||||
|
||||
<!-- Close button after success -->
|
||||
<button
|
||||
v-if="successMessage"
|
||||
@click="handleCloseAfterSuccess"
|
||||
class="w-full rounded-xl bg-sf-accent px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-accent/90 cursor-pointer"
|
||||
>
|
||||
{{ t('provider.closeButton') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Types ──
|
||||
interface ExpertiseCategory {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
category: string
|
||||
}
|
||||
|
||||
interface QuickAddResponse {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
message: string
|
||||
earned_points: number
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: number
|
||||
name: string
|
||||
city: string | null
|
||||
address: string | null
|
||||
source: string
|
||||
is_verified: boolean
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
// ── Emits ──
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'saved', provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }): void
|
||||
}>()
|
||||
|
||||
// ── State ──
|
||||
const categories = ref<ExpertiseCategory[]>([])
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
const duplicateWarning = ref<string | null>(null)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
category_id: '' as string | number,
|
||||
city: '',
|
||||
address_zip: '',
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
tagsInput: '',
|
||||
tags: [] as string[],
|
||||
})
|
||||
|
||||
// ── Computed ──
|
||||
const isFormValid = computed(() => {
|
||||
return form.name.trim().length >= 2 && !!form.category_id
|
||||
})
|
||||
|
||||
// ── Soft-Deduplication: Debounced search on name/city/zip changes ──
|
||||
async function checkDuplicates() {
|
||||
const name = form.name.trim()
|
||||
if (name.length < 3) {
|
||||
duplicateWarning.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const params: Record<string, string | number> = { q: name, limit: 3 }
|
||||
if (form.city.trim()) params.city = form.city.trim()
|
||||
else if (form.address_zip.trim()) params.city = form.address_zip.trim()
|
||||
|
||||
const res = await api.get('providers/search', { params })
|
||||
const data = res.data as { results: SearchResult[]; total: number }
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
const names = data.results.map((r: SearchResult) => r.name)
|
||||
if (names.length === 1) {
|
||||
duplicateWarning.value = t('provider.duplicateWarningShort', { name: names[0] })
|
||||
} else {
|
||||
duplicateWarning.value = t('provider.duplicateWarning', {
|
||||
matches: names.slice(0, 3).join(', '),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
duplicateWarning.value = null
|
||||
}
|
||||
} catch {
|
||||
// Silent fail — deduplication is non-blocking
|
||||
duplicateWarning.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onFormFieldChange() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
checkDuplicates()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// ── Fetch Categories ──
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await api.get('providers/categories')
|
||||
categories.value = res.data || []
|
||||
} catch {
|
||||
categories.value = [
|
||||
{ id: 1, key: 'auto_szerelo', name_hu: 'Autószerelő', name_en: 'Car Mechanic', category: 'vehicle_service' },
|
||||
{ id: 2, key: 'motor_szerelo', name_hu: 'Motorkerékpár szerelő', name_en: 'Motorcycle Mechanic', category: 'vehicle_service' },
|
||||
{ id: 3, key: 'gumiszerviz', name_hu: 'Gumiszerviz', name_en: 'Tire Service', category: 'vehicle_service' },
|
||||
{ id: 4, key: 'karosszerialakatos', name_hu: 'Karosszérialakatos', name_en: 'Body Shop', category: 'body_paint' },
|
||||
{ id: 5, key: 'fényező', name_hu: 'Fényező', name_en: 'Painter', category: 'body_paint' },
|
||||
{ id: 6, key: 'autómentő', name_hu: 'Autómentő / Motormentő', name_en: 'Tow Truck / Roadside', category: 'roadside' },
|
||||
{ id: 7, key: 'benzinkút', name_hu: 'Benzinkút', name_en: 'Gas Station', category: 'fuel' },
|
||||
{ id: 8, key: 'alkatrész_kereskedés', name_hu: 'Alkatrész kereskedés', name_en: 'Parts Store', category: 'parts' },
|
||||
{ id: 9, key: 'vizsgaállomás', name_hu: 'Vizsgaállomás', name_en: 'Inspection Station', category: 'inspection' },
|
||||
{ id: 10, key: 'autókozmetika', name_hu: 'Autókozmetika', name_en: 'Car Detailing', category: 'detailing' },
|
||||
{ id: 11, key: 'egyéb', name_hu: 'Egyéb szolgáltató', name_en: 'Other Service', category: 'other' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset Form ──
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.category_id = ''
|
||||
form.city = ''
|
||||
form.address_zip = ''
|
||||
form.address_street_name = ''
|
||||
form.address_street_type = ''
|
||||
form.address_house_number = ''
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
form.website = ''
|
||||
form.tagsInput = ''
|
||||
form.tags = []
|
||||
errorMessage.value = null
|
||||
successMessage.value = null
|
||||
duplicateWarning.value = null
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
// ── Submit ──
|
||||
async function handleSubmit() {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
category_id: Number(form.category_id),
|
||||
city: form.city.trim() || null,
|
||||
address_zip: form.address_zip.trim() || null,
|
||||
address_street_name: form.address_street_name.trim() || null,
|
||||
address_street_type: form.address_street_type.trim() || null,
|
||||
address_house_number: form.address_house_number.trim() || null,
|
||||
contact_phone: form.phone.trim() || null,
|
||||
contact_email: form.email.trim() || null,
|
||||
website: form.website.trim() || null,
|
||||
tags: form.tags.length > 0 ? [...form.tags] : null,
|
||||
}
|
||||
|
||||
const response = await api.post<QuickAddResponse>('providers/quick-add', payload)
|
||||
const data = response.data
|
||||
|
||||
// ── Success: Show Gamification Toast ──
|
||||
successMessage.value = t('provider.successMessage', { points: data.earned_points })
|
||||
|
||||
// Build a display address from atomized fields
|
||||
const addressParts = [
|
||||
form.address_street_name,
|
||||
form.address_street_type,
|
||||
form.address_house_number,
|
||||
].filter(Boolean)
|
||||
const displayAddress = addressParts.length > 0 ? addressParts.join(' ') : null
|
||||
|
||||
// Emit the saved provider back to parent
|
||||
emit('saved', {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
category: form.category_id ? categories.value.find(c => c.id === Number(form.category_id))?.name_hu || null : null,
|
||||
city: form.city || null,
|
||||
address: displayAddress,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[ProviderQuickAddModal] Submit error:', error)
|
||||
if (error.response?.data?.detail) {
|
||||
errorMessage.value = error.response.data.detail
|
||||
} else {
|
||||
errorMessage.value = t('provider.errorMessage')
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tag management ──
|
||||
watch(
|
||||
() => form.tagsInput,
|
||||
(val) => {
|
||||
if (val.endsWith(',') || val.endsWith(';')) {
|
||||
const newTag = val.slice(0, -1).trim()
|
||||
if (newTag && !form.tags.includes(newTag)) {
|
||||
form.tags.push(newTag)
|
||||
}
|
||||
form.tagsInput = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function removeTag(index: number) {
|
||||
form.tags.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Close Handlers ──
|
||||
function handleClose() {
|
||||
if (isSubmitting.value) return
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleCloseAfterSuccess() {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// ── Watch Modal Open ──
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
fetchCategories()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Watch form fields for soft-deduplication ──
|
||||
watch(() => form.name, () => { onFormFieldChange() })
|
||||
watch(() => form.city, () => { onFormFieldChange() })
|
||||
watch(() => form.address_zip, () => { onFormFieldChange() })
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ vehicle?.brand }} {{ vehicle?.model }}</h3>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ vehicleDisplayName }}</h3>
|
||||
<!-- Primary Vehicle Star -->
|
||||
<button
|
||||
v-if="vehicle"
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
3-TAB NAVIGATION
|
||||
4-TAB NAVIGATION
|
||||
════════════════════════════════════════════════════════════════ -->
|
||||
<div class="px-6">
|
||||
<div class="flex border-b border-slate-200 gap-1">
|
||||
@@ -100,8 +100,8 @@
|
||||
|
||||
<!-- Body / Karosszéria -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Karosszéria</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.vehicle_class || vehicle?.body_type || '—' }}</p>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">{{ t('vehicle.label_body_type') }}</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ bodyTypeDisplay }}</p>
|
||||
</div>
|
||||
|
||||
<!-- VIN / Alvázszám -->
|
||||
@@ -118,14 +118,14 @@
|
||||
|
||||
<!-- Transmission -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Váltó</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.transmission_type || '—' }}</p>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">{{ t('vehicle.label_transmission') }}</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ transmissionDisplay }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Drive Type -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Hajtás</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.drive_type || '—' }}</p>
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">{{ t('vehicle.label_drive_type') }}</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ driveTypeDisplay }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Type -->
|
||||
@@ -137,7 +137,99 @@
|
||||
<!-- Trim Level / Felszereltség -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Felszereltség</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ vehicle?.trim_level || '—' }}</p>
|
||||
<p class="text-sm font-bold text-slate-800">{{ trimLevelDisplay }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── EV / Plugin Hybrid section (from individual_equipment.ev_specs) ── -->
|
||||
<div v-if="vehicle?.individual_equipment?.ev_specs" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg class="h-5 w-5 text-emerald-600" 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>
|
||||
<span class="text-sm font-bold text-emerald-800">Elektromos Hajtás / Akkumulátor</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div v-if="vehicle.individual_equipment.ev_specs.battery_capacity_kwh">
|
||||
<p class="text-xs text-slate-500">Akkumulátor</p>
|
||||
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.battery_capacity_kwh }} kWh</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.ev_specs.battery_soh">
|
||||
<p class="text-xs text-slate-500">SoH</p>
|
||||
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.battery_soh }}%</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.ev_specs.range_wltp">
|
||||
<p class="text-xs text-slate-500">Hatótáv (WLTP)</p>
|
||||
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.range_wltp }} km</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.ev_specs.range_highway">
|
||||
<p class="text-xs text-slate-500">Hatótáv (Országút)</p>
|
||||
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.range_highway }} km</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.ev_specs.range_winter">
|
||||
<p class="text-xs text-slate-500">Hatótáv (Téli)</p>
|
||||
<p class="text-sm font-semibold text-slate-800">{{ vehicle.individual_equipment.ev_specs.range_winter }} km</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.ev_specs.ac_connector">
|
||||
<p class="text-xs text-slate-500">AC töltés</p>
|
||||
<p class="text-sm font-semibold text-slate-800">{{ getConnectorLabel(vehicle.individual_equipment.ev_specs.ac_connector) }}{{ vehicle.individual_equipment.ev_specs.ac_power_kw ? ` (${vehicle.individual_equipment.ev_specs.ac_power_kw} kW)` : '' }}</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.ev_specs.dc_connector">
|
||||
<p class="text-xs text-slate-500">DC töltés</p>
|
||||
<p class="text-sm font-semibold text-slate-800">{{ getConnectorLabel(vehicle.individual_equipment.ev_specs.dc_connector) }}{{ vehicle.individual_equipment.ev_specs.dc_power_kw ? ` (${vehicle.individual_equipment.ev_specs.dc_power_kw} kW)` : '' }}</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.ev_specs.fast_charge_support">
|
||||
<p class="text-xs text-slate-500">Gyorstöltés</p>
|
||||
<p class="text-sm font-semibold text-emerald-700">✓ Támogatott</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.ev_specs.green_plate_eligible">
|
||||
<p class="text-xs text-slate-500">Zöld rendszám</p>
|
||||
<p class="text-sm font-semibold text-emerald-700">✓ Jogosult</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Features / Extrák badges (from individual_equipment.features) ── -->
|
||||
<div v-if="vehicleFeatures?.length" 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-2">{{ t('vehicle.label_features') }}</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="feat in vehicleFeatures"
|
||||
: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"
|
||||
>
|
||||
{{ t('vehicle.features.' + feat, feat) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Dates section (from individual_equipment.dates) ── -->
|
||||
<div v-if="vehicle?.individual_equipment?.dates" 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-2">{{ t('vehicle.label_dates') }}</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div v-if="vehicle.individual_equipment.dates.mot_expiry">
|
||||
<p class="text-xs text-slate-500">{{ t('vehicle.label_mot_expiry') }}</p>
|
||||
<p class="text-sm font-semibold text-slate-800">{{ formatDate(vehicle.individual_equipment.dates.mot_expiry) }}</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.dates.document_expiry">
|
||||
<p class="text-xs text-slate-500">{{ t('vehicle.label_document_expiry') }}</p>
|
||||
<p class="text-sm font-semibold text-slate-800">{{ formatDate(vehicle.individual_equipment.dates.document_expiry) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Document numbers section (from individual_equipment.documents) ── -->
|
||||
<div v-if="vehicle?.individual_equipment?.documents" 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-2">{{ t('vehicle.label_documents') }}</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div v-if="vehicle.individual_equipment.documents.registration_cert_number">
|
||||
<p class="text-xs text-slate-500">{{ t('vehicle.label_reg_cert_number') }}</p>
|
||||
<p class="text-sm font-semibold text-slate-800 font-mono">{{ vehicle.individual_equipment.documents.registration_cert_number }}</p>
|
||||
</div>
|
||||
<div v-if="vehicle.individual_equipment.documents.title_cert_number">
|
||||
<p class="text-xs text-slate-500">{{ t('vehicle.label_title_cert_number') }}</p>
|
||||
<p class="text-sm font-semibold text-slate-800 font-mono">{{ vehicle.individual_equipment.documents.title_cert_number }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -299,6 +391,20 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Statistics cards (Average Consumption & Cost per km) ── -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Fogyasztás</p>
|
||||
<p class="text-2xl font-extrabold text-slate-800">
|
||||
{{ vehicle?.fuel_type === 'electric' ? '—' : (vehicle?.individual_equipment?.avg_consumption ? vehicle.individual_equipment.avg_consumption : '—') }} <span class="text-sm font-semibold text-slate-500">{{ vehicle?.fuel_type === 'electric' ? 'kWh/100km' : 'l/100km' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Km Költség</p>
|
||||
<p class="text-2xl font-extrabold text-slate-800">{{ avgCostPerKm }} <span class="text-sm font-semibold text-slate-500">Ft/km</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TCO per km hint (visible to all) -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-xs text-slate-500">
|
||||
@@ -312,64 +418,63 @@
|
||||
|
||||
<!-- ──── TAB 3: Figyelmeztetések & Időpontok ──── -->
|
||||
<div v-if="activeTab === 'alerts'" class="space-y-4">
|
||||
<!-- ── Task 6: Statistics moved here from Tab 2 ── -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Fogyasztás</p>
|
||||
<p class="text-2xl font-extrabold text-slate-800">
|
||||
{{ vehicle?.fuel_type === 'electric' ? '—' : '8.2' }} <span class="text-sm font-semibold text-slate-500">{{ vehicle?.fuel_type === 'electric' ? 'kWh/100km' : 'l/100km' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-center">
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1">Átlagos Km Költség</p>
|
||||
<p class="text-2xl font-extrabold text-slate-800">62 <span class="text-sm font-semibold text-slate-500">Ft/km</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Idővonal — Közelgő események</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Alert 1: Insurance renewal -->
|
||||
<div class="flex items-start gap-4 rounded-xl border border-amber-200 bg-amber-50/50 p-4">
|
||||
<!-- Alert 1: Insurance renewal — dynamic from individual_equipment JSONB -->
|
||||
<div
|
||||
v-if="vehicle?.individual_equipment?.insurance_due_date"
|
||||
class="flex items-start gap-4 rounded-xl border border-amber-200 bg-amber-50/50 p-4"
|
||||
>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-600 text-base shrink-0">📋</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-slate-800">Kötelező biztosítás fordulónap</p>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-[10px] font-bold text-amber-700 uppercase">30 nap</span>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-[10px] font-bold text-amber-700 uppercase">{{ daysUntil(vehicle.individual_equipment.insurance_due_date) }} nap</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Lemondási határidő: 2026. 07. 12. — Jelenlegi díj: 85,000 Ft/év</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<p class="text-xs text-slate-500 mt-0.5">
|
||||
Lemondási határidő: {{ formatDate(vehicle.individual_equipment.insurance_due_date) }}
|
||||
<template v-if="vehicle.individual_equipment.insurance_premium">
|
||||
— Jelenlegi díj: {{ formatAmount(vehicle.individual_equipment.insurance_premium) }}/év
|
||||
</template>
|
||||
</p>
|
||||
<div v-if="daysUntil(vehicle.individual_equipment.insurance_due_date) <= 30" class="flex gap-2 mt-2">
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-600">⚠️ Lemondási határidő</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert 2: MOT / Műszaki vizsga -->
|
||||
<div class="flex items-start gap-4 rounded-xl border border-red-200 bg-red-50/50 p-4">
|
||||
<!-- Alert 2: MOT / Műszaki vizsga — dynamic from individual_equipment JSONB -->
|
||||
<div
|
||||
v-if="vehicle?.individual_equipment?.mot_due_date"
|
||||
class="flex items-start gap-4 rounded-xl border border-red-200 bg-red-50/50 p-4"
|
||||
>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-red-100 text-red-600 text-base shrink-0">🔧</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-slate-800">Műszaki vizsga lejárata</p>
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-[10px] font-bold text-red-700 uppercase">14 nap</span>
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-[10px] font-bold text-red-700 uppercase">{{ daysUntil(vehicle.individual_equipment.mot_due_date) }} nap</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Lejárat: 2026. 06. 26. — Hátralévő napok: 14</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<p class="text-xs text-slate-500 mt-0.5">
|
||||
Lejárat: {{ formatDate(vehicle.individual_equipment.mot_due_date) }} — Hátralévő napok: {{ daysUntil(vehicle.individual_equipment.mot_due_date) }}
|
||||
</p>
|
||||
<div v-if="daysUntil(vehicle.individual_equipment.mot_due_date) <= 30" class="flex gap-2 mt-2">
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-semibold text-red-600">🔴 Sürgős</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert 3: Next service -->
|
||||
<!-- Alert 3: Next service — dynamic from current_mileage -->
|
||||
<div class="flex items-start gap-4 rounded-xl border border-blue-200 bg-blue-50/50 p-4">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-blue-100 text-blue-600 text-base shrink-0">⏰</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-slate-800">Következő szerviz</p>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase">45 nap</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase">{{ nextServiceDays }} nap</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-0.5">
|
||||
{{ vehicle?.current_mileage
|
||||
? `Jelenleg: ${vehicle.current_mileage.toLocaleString()} km — Következő: ${(vehicle.current_mileage + 15000).toLocaleString()} km`
|
||||
? `Jelenleg: ${vehicle.current_mileage.toLocaleString()} km — Következő: ${nextServiceKm.toLocaleString()} km`
|
||||
: '15,000 km vagy 1 év' }}
|
||||
</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
@@ -378,20 +483,246 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert 4: Seasonal tire change -->
|
||||
<div class="flex items-start gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<!-- Alert 4: Seasonal tire change — dynamic from individual_equipment JSONB -->
|
||||
<div
|
||||
v-if="vehicle?.individual_equipment?.tire_change_date"
|
||||
class="flex items-start gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4"
|
||||
>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-200 text-slate-500 text-base shrink-0">🛞</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-slate-800">Szezonális gumicsere</p>
|
||||
<span class="inline-flex items-center rounded-full bg-slate-200 px-2.5 py-0.5 text-[10px] font-bold text-slate-600 uppercase">90 nap</span>
|
||||
<span class="inline-flex items-center rounded-full bg-slate-200 px-2.5 py-0.5 text-[10px] font-bold text-slate-600 uppercase">{{ daysUntil(vehicle.individual_equipment.tire_change_date) }} nap</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-0.5">Téli → Nyári gumi: 2026. 09. 10. (ajánlott időszak)</p>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{{ vehicle.individual_equipment.tire_change_description || 'Téli → Nyári gumi: ' + formatDate(vehicle.individual_equipment.tire_change_date) + ' (ajánlott időszak)' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state when no alerts are configured -->
|
||||
<div
|
||||
v-if="!vehicle?.individual_equipment?.insurance_due_date && !vehicle?.individual_equipment?.mot_due_date && !vehicle?.individual_equipment?.tire_change_date"
|
||||
class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center"
|
||||
>
|
||||
<p class="text-sm text-slate-500">Nincsenek beállított figyelmeztetések ehhez a járműhöz.</p>
|
||||
<p class="text-xs text-slate-400 mt-1">A figyelmeztetések a jármű szerkesztésekor adhatók hozzá.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ──── TAB 4: Szervizkönyv (Service Book) ──── -->
|
||||
<div v-if="activeTab === 'servicebook'" class="space-y-5">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="eventsLoading"
|
||||
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>
|
||||
|
||||
<template v-if="!eventsLoading">
|
||||
<!-- Add Event Button (opens inline form) -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
v-if="!showAddForm"
|
||||
@click="showAddForm = true"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-4 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ $t('servicebook.addEvent') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Inline Add Event Form ── -->
|
||||
<div
|
||||
v-if="showAddForm"
|
||||
class="rounded-xl border border-sf-accent/30 bg-sf-accent/5 p-5 space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-slate-700">{{ $t('servicebook.addEvent') }}</p>
|
||||
<button
|
||||
@click="showAddForm = false"
|
||||
class="text-slate-400 hover:text-red-500 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="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- Event Date -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.eventDate') }}</label>
|
||||
<input
|
||||
v-model="newEvent.event_date"
|
||||
type="date"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Odometer -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.odometer') }}</label>
|
||||
<input
|
||||
v-model.number="newEvent.odometer_reading"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent"
|
||||
:placeholder="String(vehicle?.current_mileage || 0)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Event Type -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.eventType') }}</label>
|
||||
<select
|
||||
v-model="newEvent.event_type"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent"
|
||||
>
|
||||
<option value="" disabled>{{ $t('servicebook.eventType') }}</option>
|
||||
<option v-for="(label, key) in eventTypeOptions" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Cost Amount (optional, no spinners) -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.costAmount') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="newEvent.cost_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
:placeholder="$t('servicebook.costPlaceholder')"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400">HUF</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider / Service Center (full width) -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-semibold text-slate-500 mb-1">
|
||||
Szolgáltató / Szerviz <span class="text-slate-400 text-xs">(opcionális)</span>
|
||||
</label>
|
||||
<ProviderAutocomplete
|
||||
v-model="selectedProvider"
|
||||
@select="onProviderSelect"
|
||||
@clear="onProviderClear"
|
||||
@open-quick-add="isQuickAddOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description (full width) -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-semibold text-slate-500 mb-1">{{ $t('servicebook.description') }}</label>
|
||||
<textarea
|
||||
v-model="newEvent.description"
|
||||
rows="3"
|
||||
maxlength="2000"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 focus:border-sf-accent focus:outline-none focus:ring-1 focus:ring-sf-accent resize-none"
|
||||
:placeholder="$t('servicebook.descriptionPlaceholder')"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
{{ $t('servicebook.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="submitEvent"
|
||||
:disabled="savingEvent || !newEvent.event_type"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<svg v-if="savingEvent" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ savingEvent ? $t('servicebook.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Timeline of Service Events ── -->
|
||||
<div v-if="serviceEvents.length === 0" class="rounded-xl border border-slate-200 bg-slate-50 p-8 text-center">
|
||||
<p class="text-sm text-slate-500">{{ $t('servicebook.noEvents') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative">
|
||||
<!-- Timeline vertical line -->
|
||||
<div class="absolute left-5 top-0 bottom-0 w-0.5 bg-slate-200"></div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="event in serviceEvents"
|
||||
:key="event.id"
|
||||
class="relative pl-12"
|
||||
>
|
||||
<!-- Timeline dot -->
|
||||
<div
|
||||
class="absolute left-3.5 top-1.5 h-3.5 w-3.5 rounded-full border-2 shadow-sm"
|
||||
:class="getEventDotColor(event.event_type)"
|
||||
></div>
|
||||
|
||||
<!-- Event card -->
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wider"
|
||||
:class="getEventBadgeColor(event.event_type)"
|
||||
>
|
||||
{{ getEventTypeLabel(event.event_type) }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">
|
||||
{{ formatDate(event.event_date) }}
|
||||
</span>
|
||||
<span v-if="event.odometer_reading" class="text-xs text-slate-400">
|
||||
— {{ $t('servicebook.atOdometer', { km: event.odometer_reading.toLocaleString() }) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="event.description" class="text-sm text-slate-600 mt-2 whitespace-pre-wrap">
|
||||
{{ event.description }}
|
||||
</p>
|
||||
<!-- Cost display on timeline -->
|
||||
<div
|
||||
v-if="event.cost_amount && event.cost_amount > 0"
|
||||
class="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-emerald-50 border border-emerald-200 px-2.5 py-1"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 text-emerald-600" 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>
|
||||
<span class="text-xs font-semibold text-emerald-700">
|
||||
{{ formatAmount(event.cost_amount) }}
|
||||
</span>
|
||||
<span v-if="event.currency" class="text-[10px] text-emerald-500 font-medium">{{ event.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── Provider Quick Add Modal (Teleported) ── -->
|
||||
<ProviderQuickAddModal
|
||||
:is-open="isQuickAddOpen"
|
||||
@close="isQuickAddOpen = false"
|
||||
@saved="onQuickAddSaved"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
FOOTER
|
||||
@@ -422,10 +753,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getLocalDateString } from '../../services/dateUtils'
|
||||
import type { VehicleData } from '../../types/vehicle'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import api from '../../api/axios'
|
||||
import VehiclePlateBadge from './VehiclePlateBadge.vue'
|
||||
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
|
||||
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
|
||||
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -440,21 +779,64 @@ const emit = defineEmits<{
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Computed display helpers ──
|
||||
const vehicleDisplayName = computed(() => {
|
||||
if (!props.vehicle) return ''
|
||||
const brand = props.vehicle.brand || ''
|
||||
const model = props.vehicle.model || ''
|
||||
// Deduplicate: if model already starts with brand, only show model
|
||||
if (model.toLowerCase().startsWith(brand.toLowerCase())) {
|
||||
return model
|
||||
}
|
||||
return [brand, model].filter(Boolean).join(' ')
|
||||
})
|
||||
|
||||
const bodyTypeDisplay = computed(() => {
|
||||
if (!props.vehicle) return '—'
|
||||
// Prefer body_type from the vehicle root, fall back to individual_equipment.car_specs.body_type
|
||||
const bodyType = props.vehicle.body_type || props.vehicle.individual_equipment?.car_specs?.body_type
|
||||
if (!bodyType) return '—'
|
||||
return t('vehicle.bodyTypes.' + bodyType, bodyType)
|
||||
})
|
||||
|
||||
const transmissionDisplay = computed(() => {
|
||||
if (!props.vehicle?.transmission_type) return '—'
|
||||
return t('vehicle.transmissionTypes.' + props.vehicle.transmission_type, props.vehicle.transmission_type)
|
||||
})
|
||||
|
||||
const driveTypeDisplay = computed(() => {
|
||||
if (!props.vehicle?.drive_type) return '—'
|
||||
return t('vehicle.driveTypes.' + props.vehicle.drive_type, props.vehicle.drive_type)
|
||||
})
|
||||
|
||||
const trimLevelDisplay = computed(() => {
|
||||
if (!props.vehicle?.trim_level) return '—'
|
||||
return props.vehicle.trim_level
|
||||
})
|
||||
|
||||
const vehicleFeatures = computed(() => {
|
||||
if (!props.vehicle?.individual_equipment?.features) return []
|
||||
const features = props.vehicle.individual_equipment.features
|
||||
return Array.isArray(features) ? features : []
|
||||
})
|
||||
|
||||
// ── Tab state ──
|
||||
const activeTab = ref<'basics' | 'finance' | 'alerts'>('basics')
|
||||
const activeTab = ref<'basics' | 'finance' | 'alerts' | 'servicebook'>('basics')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'basics' as const, label: 'Alapadatok', icon: '📋' },
|
||||
{ id: 'finance' as const, label: 'Pénzügyek (TCO)', icon: '💰' },
|
||||
{ id: 'alerts' as const, label: 'Figyelmeztetések', icon: '🔔' },
|
||||
{ id: 'servicebook' as const, label: 'Szervizkönyv', icon: '📖' },
|
||||
]
|
||||
|
||||
// ── Task 2: Reset activeTab to 'basics' every time modal opens ──
|
||||
// ── Reset state every time modal opens ──
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
activeTab.value = 'basics'
|
||||
vehicleCosts.value = []
|
||||
costsLoading.value = false
|
||||
resetNewEventForm()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -561,6 +943,104 @@ function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: 'HUF', maximumFractionDigits: 0 }).format(amount)
|
||||
}
|
||||
|
||||
// ── Connector label helper (for EV section) ──
|
||||
function getConnectorLabel(connector: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
type1: 'Type 1 (SAE J1772)',
|
||||
type2: 'Type 2 (Mennekes)',
|
||||
type3: 'Type 3 (Scame)',
|
||||
gb_t: 'GB/T (Kína)',
|
||||
ccs: 'CCS',
|
||||
chademo: 'CHAdeMO',
|
||||
tesla_supercharger: 'Tesla Supercharger',
|
||||
gb_t_dc: 'GB/T DC (Kína)',
|
||||
nacs: 'NACS',
|
||||
}
|
||||
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 ──
|
||||
const trustScorePercent = computed(() => {
|
||||
const v = props.vehicle
|
||||
@@ -623,7 +1103,36 @@ const engineDisplay = computed(() => {
|
||||
const hpDisplay = computed(() => {
|
||||
const kw = props.vehicle?.power_kw
|
||||
if (!kw) return '—'
|
||||
return `${Math.round(kw * 1.341)} LE`
|
||||
return `${Math.round(kw * 1.35962)} LE`
|
||||
})
|
||||
|
||||
// ── Alerts tab computed helpers (dynamic, no hardcoded values) ──
|
||||
function daysUntil(dateStr: string): number {
|
||||
if (!dateStr) return 999
|
||||
const target = new Date(dateStr)
|
||||
if (isNaN(target.getTime())) return 999
|
||||
const now = new Date()
|
||||
const diff = Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
return Math.max(0, diff)
|
||||
}
|
||||
|
||||
const avgCostPerKm = computed(() => {
|
||||
const mileage = props.vehicle?.current_mileage || props.vehicle?.mileage
|
||||
if (!mileage || mileage === 0) return '—'
|
||||
// Use individual_equipment.avg_cost_per_km if stored, otherwise derive from TCO
|
||||
const stored = props.vehicle?.individual_equipment?.avg_cost_per_km
|
||||
if (stored) return stored
|
||||
return '—'
|
||||
})
|
||||
|
||||
const nextServiceKm = computed(() => {
|
||||
const mileage = props.vehicle?.current_mileage || 0
|
||||
return mileage + 15000
|
||||
})
|
||||
|
||||
const nextServiceDays = computed(() => {
|
||||
// Default: 45 days if no specific data
|
||||
return props.vehicle?.individual_equipment?.next_service_days || 45
|
||||
})
|
||||
|
||||
// ── Financial computed helpers ──
|
||||
@@ -719,6 +1228,256 @@ const costDistribution = computed<DistributionItem[]>(() => {
|
||||
}))
|
||||
.sort((a, b) => b.percent - a.percent)
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SERVICE BOOK (Szervizkönyv) — AssetEvent management
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
interface ServiceEvent {
|
||||
id: string
|
||||
asset_id: string
|
||||
event_type: string
|
||||
event_date: string
|
||||
odometer_reading: number | null
|
||||
description: string | null
|
||||
cost_id: string | null
|
||||
cost_amount: number | null
|
||||
currency: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const serviceEvents = ref<ServiceEvent[]>([])
|
||||
const eventsLoading = ref(false)
|
||||
const showAddForm = ref(false)
|
||||
const savingEvent = ref(false)
|
||||
|
||||
/**
|
||||
* Default new event form state.
|
||||
*/
|
||||
const newEvent = ref<{
|
||||
event_date: string
|
||||
odometer_reading: number | null
|
||||
event_type: string
|
||||
description: string
|
||||
cost_amount: number | null
|
||||
}>({
|
||||
event_date: getLocalDateString(),
|
||||
odometer_reading: null,
|
||||
event_type: '',
|
||||
description: '',
|
||||
cost_amount: null,
|
||||
})
|
||||
|
||||
// ── Provider Autocomplete State ──
|
||||
const selectedProvider = ref<ProviderSearchResult | null>(null)
|
||||
const isQuickAddOpen = ref(false)
|
||||
|
||||
/**
|
||||
* Called when a provider is selected from the autocomplete dropdown.
|
||||
* Stores the provider reference for the event submission.
|
||||
*/
|
||||
function onProviderSelect(provider: ProviderSearchResult) {
|
||||
selectedProvider.value = provider
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the selected provider is cleared.
|
||||
*/
|
||||
function onProviderClear() {
|
||||
selectedProvider.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the ProviderQuickAddModal emits 'saved' after a successful creation.
|
||||
* Auto-selects the newly created provider and shows a gamification toast.
|
||||
*/
|
||||
function onQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
|
||||
isQuickAddOpen.value = false
|
||||
// Auto-select the newly created provider
|
||||
selectedProvider.value = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
category: provider.category || null,
|
||||
specialization: [],
|
||||
city: provider.city || null,
|
||||
address: provider.address || null,
|
||||
source: 'crowd_added',
|
||||
is_verified: false,
|
||||
rating: null,
|
||||
}
|
||||
// Show gamification toast
|
||||
alert(`🎉 Szereztél pontokat! A(z) "${provider.name}" sikeresen rögzítve és kiválasztva!`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event type options for the dropdown, using i18n labels.
|
||||
*/
|
||||
const eventTypeOptions = computed(() => {
|
||||
const t = (window as any).__i18n?.global?.t || ((key: string) => key)
|
||||
// We use the $t function from the template; for computed we access via useI18n
|
||||
return {
|
||||
SERVICE: 'Szerviz',
|
||||
REPAIR: 'Javítás',
|
||||
ACCIDENT: 'Baleset',
|
||||
INSPECTION: 'Vizsga',
|
||||
TIRE_CHANGE: 'Gumicsere',
|
||||
MAINTENANCE: 'Karbantartás',
|
||||
UPGRADE: 'Fejlesztés',
|
||||
RECALL: 'Visszahívás',
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the display label for an event type.
|
||||
*/
|
||||
function getEventTypeLabel(eventType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
SERVICE: 'Szerviz',
|
||||
REPAIR: 'Javítás',
|
||||
ACCIDENT: 'Baleset',
|
||||
INSPECTION: 'Vizsga',
|
||||
TIRE_CHANGE: 'Gumicsere',
|
||||
MAINTENANCE: 'Karbantartás',
|
||||
UPGRADE: 'Fejlesztés',
|
||||
RECALL: 'Visszahívás',
|
||||
}
|
||||
return labels[eventType] || eventType
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dot color class for the timeline based on event type.
|
||||
*/
|
||||
function getEventDotColor(eventType: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
SERVICE: 'bg-emerald-500 border-emerald-300',
|
||||
REPAIR: 'bg-amber-500 border-amber-300',
|
||||
ACCIDENT: 'bg-red-500 border-red-300',
|
||||
INSPECTION: 'bg-blue-500 border-blue-300',
|
||||
TIRE_CHANGE: 'bg-purple-500 border-purple-300',
|
||||
MAINTENANCE: 'bg-sky-500 border-sky-300',
|
||||
UPGRADE: 'bg-indigo-500 border-indigo-300',
|
||||
RECALL: 'bg-rose-500 border-rose-300',
|
||||
}
|
||||
return colors[eventType] || 'bg-slate-400 border-slate-300'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the badge color class for the event type pill.
|
||||
*/
|
||||
function getEventBadgeColor(eventType: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
SERVICE: 'bg-emerald-100 text-emerald-700',
|
||||
REPAIR: 'bg-amber-100 text-amber-700',
|
||||
ACCIDENT: 'bg-red-100 text-red-700',
|
||||
INSPECTION: 'bg-blue-100 text-blue-700',
|
||||
TIRE_CHANGE: 'bg-purple-100 text-purple-700',
|
||||
MAINTENANCE: 'bg-sky-100 text-sky-700',
|
||||
UPGRADE: 'bg-indigo-100 text-indigo-700',
|
||||
RECALL: 'bg-rose-100 text-rose-700',
|
||||
}
|
||||
return colors[eventType] || 'bg-slate-100 text-slate-700'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch service events when the servicebook tab is activated.
|
||||
* GET /assets/{vehicle.id}/events
|
||||
*/
|
||||
watch(() => activeTab.value, async (tab) => {
|
||||
if (tab === 'servicebook' && props.vehicle?.id) {
|
||||
eventsLoading.value = true
|
||||
showAddForm.value = false
|
||||
try {
|
||||
const res = await api.get(`/assets/${props.vehicle.id}/events`)
|
||||
serviceEvents.value = (res.data as ServiceEvent[]) || []
|
||||
} catch (err: any) {
|
||||
console.error('[VehicleDetailModal] Failed to fetch events:', err)
|
||||
serviceEvents.value = []
|
||||
} finally {
|
||||
eventsLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Also fetch events when the vehicle prop changes while on the servicebook tab.
|
||||
*/
|
||||
watch(() => props.vehicle, (newVehicle) => {
|
||||
if (activeTab.value === 'servicebook' && newVehicle?.id) {
|
||||
eventsLoading.value = true
|
||||
api.get(`/assets/${newVehicle.id}/events`)
|
||||
.then(res => {
|
||||
serviceEvents.value = (res.data as ServiceEvent[]) || []
|
||||
})
|
||||
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch events:', err); serviceEvents.value = [] })
|
||||
.finally(() => { eventsLoading.value = false })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Reset the add event form to default values.
|
||||
*/
|
||||
function resetNewEventForm() {
|
||||
newEvent.value = {
|
||||
event_date: getLocalDateString(),
|
||||
odometer_reading: props.vehicle?.current_mileage || null,
|
||||
event_type: '',
|
||||
description: '',
|
||||
cost_amount: null,
|
||||
}
|
||||
selectedProvider.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a new service event.
|
||||
* POST /assets/{vehicle.id}/events
|
||||
*/
|
||||
async function submitEvent() {
|
||||
if (!props.vehicle?.id || !newEvent.value.event_type) return
|
||||
|
||||
savingEvent.value = true
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
event_type: newEvent.value.event_type,
|
||||
}
|
||||
if (newEvent.value.event_date) {
|
||||
payload.event_date = new Date(newEvent.value.event_date).toISOString()
|
||||
}
|
||||
if (newEvent.value.odometer_reading !== null && newEvent.value.odometer_reading !== undefined) {
|
||||
payload.odometer_reading = newEvent.value.odometer_reading
|
||||
}
|
||||
if (newEvent.value.description) {
|
||||
payload.description = newEvent.value.description
|
||||
}
|
||||
if (newEvent.value.cost_amount !== null && newEvent.value.cost_amount !== undefined && newEvent.value.cost_amount > 0) {
|
||||
payload.cost_amount = newEvent.value.cost_amount
|
||||
payload.currency = 'HUF'
|
||||
}
|
||||
// Include selected provider if one is chosen
|
||||
if (selectedProvider.value) {
|
||||
payload.provider_id = selectedProvider.value.id
|
||||
payload.provider_name = selectedProvider.value.name
|
||||
}
|
||||
|
||||
await api.post(`/assets/${props.vehicle.id}/events`, payload)
|
||||
|
||||
// Refresh events list
|
||||
const res = await api.get(`/assets/${props.vehicle.id}/events`)
|
||||
serviceEvents.value = (res.data as ServiceEvent[]) || []
|
||||
|
||||
// Refresh vehicle data via store to get updated current_mileage
|
||||
const vehicleStore = useVehicleStore()
|
||||
await vehicleStore.fetchVehicleById(String(props.vehicle.id))
|
||||
|
||||
// Reset form
|
||||
showAddForm.value = false
|
||||
resetNewEventForm()
|
||||
} catch (err: any) {
|
||||
console.error('[VehicleDetailModal] Failed to save event:', err)
|
||||
} finally {
|
||||
savingEvent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export default {
|
||||
// ── Common / Global ──
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
optional: 'optional',
|
||||
select: 'Select',
|
||||
},
|
||||
landing: {
|
||||
openGarage: 'Open Garage',
|
||||
heroTitle: 'Digital Fleet',
|
||||
@@ -86,6 +94,40 @@ export default {
|
||||
detailedView: 'Detailed View',
|
||||
modalPlaceholder: 'Content for this section will be available soon.',
|
||||
},
|
||||
finance: {
|
||||
wallet: 'Wallet',
|
||||
totalCredits: 'Total Credits',
|
||||
purchased: 'Purchased',
|
||||
bonus: 'Bonus',
|
||||
earned: 'Earned',
|
||||
voucher: 'Voucher',
|
||||
topUp: 'Top Up',
|
||||
live: 'LIVE',
|
||||
retry: 'Retry',
|
||||
// WalletDetailsModal keys
|
||||
walletDetails: 'Wallet Details',
|
||||
walletBreakdown: 'Balance Breakdown',
|
||||
topUpWallet: 'Purchased',
|
||||
referralRewards: 'Earned',
|
||||
rewards: 'Bonus',
|
||||
activeVouchers: 'Voucher',
|
||||
totalBalance: 'Total Credits',
|
||||
requestPayout: 'Request Payout',
|
||||
transactionHistory: 'Transaction History',
|
||||
all: 'All',
|
||||
noTransactions: 'No transactions',
|
||||
// Cost Manager keys
|
||||
costManager: 'Cost Manager',
|
||||
manageAllCosts: 'Manage All Costs',
|
||||
costDate: 'Date',
|
||||
costType: 'Type',
|
||||
costAmount: 'Amount',
|
||||
costVehicle: 'Vehicle',
|
||||
costDescription: 'Description',
|
||||
noCosts: 'No costs found',
|
||||
costManagerDescription: 'Overview of all costs across your vehicles',
|
||||
payoutComingSoon: 'Payout feature coming soon!',
|
||||
},
|
||||
zen: {
|
||||
hide: 'Hide UI (Zen mode)',
|
||||
show: 'Show UI',
|
||||
@@ -421,6 +463,859 @@ export default {
|
||||
hrsz: 'Parcel No.: {value}',
|
||||
placeholder: '—',
|
||||
},
|
||||
servicebook: {
|
||||
tabLabel: 'Service Book',
|
||||
noEvents: 'No service events recorded yet.',
|
||||
addEvent: 'Add Event',
|
||||
cancel: 'Cancel',
|
||||
saving: 'Saving...',
|
||||
eventDate: 'Event Date',
|
||||
odometer: 'Odometer (km)',
|
||||
eventType: 'Event Type',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Describe the service or repair...',
|
||||
eventTypes: {
|
||||
SERVICE: 'Service',
|
||||
REPAIR: 'Repair',
|
||||
ACCIDENT: 'Accident',
|
||||
INSPECTION: 'Inspection',
|
||||
TIRE_CHANGE: 'Tire Change',
|
||||
MAINTENANCE: 'Maintenance',
|
||||
UPGRADE: 'Upgrade',
|
||||
RECALL: 'Recall',
|
||||
},
|
||||
eventSaved: 'Service event saved successfully!',
|
||||
eventSaveError: 'Failed to save service event.',
|
||||
loadError: 'Failed to load service events.',
|
||||
atOdometer: 'at {km} km',
|
||||
costAmount: 'Cost (optional)',
|
||||
costPlaceholder: 'Enter amount...',
|
||||
},
|
||||
|
||||
// ── Vehicle ──────────────────────────────────────────────────────
|
||||
vehicle: {
|
||||
edit_title: 'Edit Vehicle',
|
||||
add_title: 'Add New Vehicle',
|
||||
features_hint: 'Select the vehicle equipment and extra options.',
|
||||
// Fuel types
|
||||
fuelTypes: {
|
||||
benzin: 'Petrol',
|
||||
diesel: 'Diesel',
|
||||
electric: 'Electric',
|
||||
hybrid: 'Hybrid',
|
||||
plugin_hybrid: 'Plug-in Hybrid',
|
||||
lpg: 'LPG',
|
||||
cng: 'CNG',
|
||||
hydrogen: 'Hydrogen',
|
||||
ethanol: 'Ethanol',
|
||||
},
|
||||
// Vehicle classes
|
||||
classes: {
|
||||
personal: 'Personal',
|
||||
motorcycle: 'Motorcycle',
|
||||
lcv: 'Light Commercial',
|
||||
hgv: 'Heavy Duty',
|
||||
machine: 'Construction Machine',
|
||||
bus: 'Bus',
|
||||
camper: 'Camper / Caravan',
|
||||
trailer: 'Trailer',
|
||||
boat: 'Boat',
|
||||
other: 'Other',
|
||||
},
|
||||
// AC types
|
||||
acTypes: {
|
||||
manual: 'Manual',
|
||||
automatic: 'Automatic',
|
||||
dual_zone: 'Dual Zone',
|
||||
tri_zone: 'Tri Zone',
|
||||
quad_zone: 'Quad Zone',
|
||||
},
|
||||
// Cylinder layouts
|
||||
cylinderLayouts: {
|
||||
inline: 'Inline',
|
||||
v: 'V-Configuration',
|
||||
boxer: 'Boxer',
|
||||
w: 'W-Configuration',
|
||||
rotary: 'Rotary (Wankel)',
|
||||
},
|
||||
// Placeholders
|
||||
placeholder_brand: 'BMW, Toyota, ...',
|
||||
placeholder_model: 'X5, Corolla, ...',
|
||||
placeholder_year: '2024',
|
||||
placeholder_trim: 'e.g. Comfort, Sport, ...',
|
||||
placeholder_name: 'e.g. Black Beauty',
|
||||
placeholder_mileage: '0',
|
||||
placeholder_custom_class: 'e.g. Quad, Segway, Golf Cart',
|
||||
placeholder_engine_capacity: '1995',
|
||||
placeholder_power_kw: '150',
|
||||
placeholder_torque_nm: '320',
|
||||
placeholder_type_designation: 'e.g. GTI, M Sport, AMG',
|
||||
placeholder_curb_weight: '1500',
|
||||
placeholder_max_weight: '2200',
|
||||
placeholder_battery_capacity: '50',
|
||||
placeholder_range: '400',
|
||||
placeholder_color: 'e.g. Black, White',
|
||||
placeholder_notes: 'e.g. Service history, notes...',
|
||||
placeholder_final_mileage: '0',
|
||||
// Extra labels
|
||||
label_custom_class: 'Please specify the vehicle type',
|
||||
label_dates_section: 'Dates',
|
||||
label_first_registration: 'First Registration Date',
|
||||
label_next_mot: 'Next MOT Expiry',
|
||||
label_insurance_expiry: 'Insurance Expiry',
|
||||
label_purchase_date: 'Purchase Date',
|
||||
label_color: 'Color',
|
||||
label_cylinder_count: 'Number of Cylinders',
|
||||
label_battery_capacity: 'Battery Capacity (kWh)',
|
||||
label_range: 'Range (km)',
|
||||
label_ac_connector: 'AC Charging Connector',
|
||||
label_dc_connector: 'DC Fast Charging Connector',
|
||||
label_final_mileage: 'Final Odometer Reading',
|
||||
label_archive_reason: 'Archive Reason',
|
||||
// Danger zone
|
||||
danger_zone: 'Danger Zone',
|
||||
danger_zone_desc: 'Permanently remove the vehicle from the garage. This action cannot be undone!',
|
||||
remove_vehicle: 'Remove Vehicle',
|
||||
confirm_archive_title: 'Confirm Vehicle Archival',
|
||||
confirm_archive_desc: 'The vehicle will be permanently removed from the garage. This action cannot be undone!',
|
||||
confirm_archive: 'Permanent Removal',
|
||||
archive_reason_sold: 'Sold',
|
||||
archive_reason_scrapped: 'Scrapped',
|
||||
archive_reason_stolen: 'Stolen',
|
||||
archive_reason_transferred: 'Transferred to new owner',
|
||||
archive_reason_other: 'Other',
|
||||
// Drive types
|
||||
driveTypes: {
|
||||
fwd: 'Front-Wheel Drive (FWD)',
|
||||
rwd: 'Rear-Wheel Drive (RWD)',
|
||||
awd: 'All-Wheel Drive (AWD)',
|
||||
'4wd': '4-Wheel Drive (4WD)',
|
||||
chain: 'Chain',
|
||||
shaft: 'Shaft',
|
||||
belt: 'Belt',
|
||||
},
|
||||
// Transmission types
|
||||
transmissionTypes: {
|
||||
manual: 'Manual',
|
||||
automatic: 'Automatic',
|
||||
sequential: 'Sequential',
|
||||
cvt: 'CVT (Continuously Variable)',
|
||||
semi_auto: 'Semi-Automatic',
|
||||
dct: 'DCT (Dual Clutch)',
|
||||
manual_sequential: 'Manual (Sequential)',
|
||||
},
|
||||
// Body types
|
||||
bodyTypes: {
|
||||
sedan: 'Sedan',
|
||||
hatchback: 'Hatchback',
|
||||
wagon: 'Estate / Wagon',
|
||||
estate: 'Estate / Wagon',
|
||||
coupe: 'Coupé',
|
||||
convertible: 'Convertible',
|
||||
suv: 'SUV',
|
||||
mpv: 'MPV',
|
||||
pickup: 'Pickup',
|
||||
van: 'Van',
|
||||
},
|
||||
// Labels
|
||||
label_drive_type: 'Drive Type',
|
||||
label_transmission: 'Transmission',
|
||||
label_body_type: 'Body Type',
|
||||
label_engine: 'Engine',
|
||||
label_vin: 'VIN (Chassis Number)',
|
||||
label_mileage: 'Current Mileage',
|
||||
label_fuel_type: 'Fuel Type',
|
||||
label_trim_level: 'Trim Level',
|
||||
label_condition: 'Condition',
|
||||
label_features: 'Features / Equipment',
|
||||
label_dates: 'Dates',
|
||||
label_mot_expiry: 'MOT Expiry',
|
||||
label_document_expiry: 'Document Expiry',
|
||||
label_reg_cert_number: 'Registration Certificate Number',
|
||||
label_title_cert_number: 'Title Certificate Number',
|
||||
label_documents: 'Documents',
|
||||
label_ev_section: 'Electric Drive / Battery',
|
||||
label_engine_capacity: 'Engine Capacity (cm³)',
|
||||
label_power_kw: 'Power (kW)',
|
||||
label_torque_nm: 'Torque (Nm)',
|
||||
label_curb_weight: 'Curb Weight (kg)',
|
||||
label_max_weight: 'Max Gross Weight (kg)',
|
||||
label_type_designation: 'Type Designation',
|
||||
label_ac_type: 'Air Conditioning',
|
||||
label_cylinder_layout: 'Cylinder Layout',
|
||||
label_door_count: 'Number of Doors',
|
||||
label_seat_count: 'Number of Seats',
|
||||
label_condition_select: 'Condition',
|
||||
label_notes: 'Notes',
|
||||
label_vehicle_class: 'Category',
|
||||
label_license_plate: 'License Plate',
|
||||
label_brand: 'Brand',
|
||||
label_model: 'Model',
|
||||
label_year: 'Year',
|
||||
label_name: 'Nickname',
|
||||
// Warranty
|
||||
warranty_section_title: 'Warranty / Guarantee',
|
||||
label_is_under_warranty: 'Valid Warranty',
|
||||
label_warranty_expiry_date: 'Warranty Expiry Date',
|
||||
// Tab labels
|
||||
tab_basic: 'Basic Data',
|
||||
tab_technical: 'Technical',
|
||||
tab_administration: 'Administration',
|
||||
tab_equipment: 'Equipment',
|
||||
// Accordion labels
|
||||
accordion_technical_features: 'Technical Features',
|
||||
accordion_interior_features: 'Interior',
|
||||
accordion_exterior_features: 'Exterior',
|
||||
accordion_multimedia_features: 'Multimedia',
|
||||
accordion_other_features: 'Other',
|
||||
// Save button
|
||||
save_changes: 'Save Changes',
|
||||
add_vehicle: 'Add Vehicle',
|
||||
saving: 'Saving...',
|
||||
// Validation
|
||||
required_fields_hint: 'Please fill in the required fields',
|
||||
// Condition options
|
||||
condition_excellent: 'Excellent',
|
||||
condition_good: 'Good',
|
||||
condition_fair: 'Fair',
|
||||
condition_poor: 'Poor',
|
||||
// Feature categories
|
||||
featureCategories: {
|
||||
technical: 'Technical',
|
||||
interior: 'Interior',
|
||||
exterior: 'Exterior',
|
||||
multimedia: 'Multimedia',
|
||||
other: 'Other',
|
||||
},
|
||||
// Features (extras)
|
||||
features: {
|
||||
abs: 'ABS',
|
||||
esp: 'ESP (Electronic Stability)',
|
||||
traction_control: 'Traction Control (TCS)',
|
||||
hill_assist: 'Hill Start Assist',
|
||||
hill_descent: 'Hill Descent Control',
|
||||
lane_assist: 'Lane Keep Assist',
|
||||
blind_spot: 'Blind Spot Monitor',
|
||||
adaptive_cruise: 'Adaptive Cruise Control (ACC)',
|
||||
parking_sensors_front: 'Front Parking Sensors',
|
||||
parking_sensors_rear: 'Rear Parking Sensors',
|
||||
rear_camera: 'Rear View Camera',
|
||||
'360_camera': '360° Camera',
|
||||
auto_parking: 'Auto Parking Assist',
|
||||
start_stop: 'Start-Stop System',
|
||||
keyless_go: 'Keyless Go',
|
||||
night_vision: 'Night Vision',
|
||||
traffic_sign_recognition: 'Traffic Sign Recognition',
|
||||
driver_alert: 'Driver Alert',
|
||||
emergency_brake: 'Emergency Brake Assist',
|
||||
cross_traffic_alert: 'Cross Traffic Alert',
|
||||
leather_seats: 'Leather Seats',
|
||||
heated_seats_front: 'Front Heated Seats',
|
||||
heated_seats_rear: 'Rear Heated Seats',
|
||||
ventilated_seats: 'Ventilated Seats',
|
||||
massage_seats: 'Massage Seats',
|
||||
electric_seats: 'Electric Seat Adjustment',
|
||||
memory_seats: 'Memory Seats',
|
||||
sport_seats: 'Sport Seats',
|
||||
heated_steering: 'Heated Steering Wheel',
|
||||
ambient_lighting: 'Ambient Lighting',
|
||||
panoramic_roof: 'Panoramic Roof',
|
||||
sunroof: 'Sunroof',
|
||||
auto_dim_mirror: 'Auto-Dimming Mirror',
|
||||
electric_steering_adjust: 'Electric Steering Adjustment',
|
||||
rear_blind: 'Rear Blind',
|
||||
rear_side_blinds: 'Rear Side Blinds',
|
||||
digital_instrument_cluster: 'Digital Instrument Cluster',
|
||||
head_up_display: 'Head-Up Display (HUD)',
|
||||
alloy_wheels: 'Alloy Wheels',
|
||||
roof_rails: 'Roof Rails',
|
||||
tow_bar: 'Tow Bar',
|
||||
tinted_windows: 'Tinted Windows',
|
||||
privacy_glass: 'Privacy Glass',
|
||||
led_headlights: 'LED Headlights',
|
||||
adaptive_headlights: 'Adaptive Headlights (AFS)',
|
||||
fog_lights: 'Fog Lights',
|
||||
cornering_lights: 'Cornering Lights',
|
||||
rain_sensor: 'Rain Sensor',
|
||||
light_sensor: 'Light Sensor',
|
||||
auto_wipers: 'Auto Wipers',
|
||||
electric_trunk: 'Electric Trunk',
|
||||
hands_free_trunk: 'Hands-Free Trunk',
|
||||
side_steps: 'Side Steps',
|
||||
mudguards: 'Mudguards',
|
||||
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',
|
||||
premium_sound: 'Premium Sound System',
|
||||
subwoofer: 'Subwoofer',
|
||||
rear_entertainment: 'Rear Entertainment',
|
||||
wifi_hotspot: 'WiFi Hotspot',
|
||||
voice_control: 'Voice Control',
|
||||
gesture_control: 'Gesture Control',
|
||||
spare_tire: 'Spare Tire',
|
||||
tire_repair_kit: 'Tire Repair Kit',
|
||||
first_aid_kit: 'First Aid Kit',
|
||||
warning_triangle: 'Warning Triangle',
|
||||
fire_extinguisher: 'Fire Extinguisher',
|
||||
winter_tires: 'Winter Tires',
|
||||
cargo_net: 'Cargo Net',
|
||||
cargo_cover: 'Cargo Cover',
|
||||
pet_barrier: 'Pet Barrier',
|
||||
roof_box: 'Roof Box',
|
||||
bike_rack: 'Bike Rack',
|
||||
ski_rack: 'Ski Rack',
|
||||
airbag_front: 'Front Airbag',
|
||||
electric_windows: 'Electric Windows',
|
||||
// ── Missing keys from Vue template ──
|
||||
hill_start_assist: 'Hill Start Assist',
|
||||
hill_descent_control: 'Hill Descent Control',
|
||||
cruise_control: 'Cruise Control',
|
||||
adaptive_cruise_control: 'Adaptive Cruise Control',
|
||||
rear_view_camera: 'Rear View Camera',
|
||||
auto_hold: 'Auto Hold',
|
||||
airbag_driver: 'Driver Airbag',
|
||||
airbag_passenger: 'Passenger Airbag',
|
||||
airbag_side: 'Side Airbag',
|
||||
airbag_curtain: 'Curtain Airbag',
|
||||
isofix: 'ISOFIX',
|
||||
tpms: 'Tire Pressure Monitoring System',
|
||||
electric_seat_adjust: 'Electric Seat Adjustment',
|
||||
heated_steering_wheel: 'Heated Steering Wheel',
|
||||
auto_dimming_mirror: 'Auto-Dimming Mirror',
|
||||
trunk_cover: 'Trunk Cover',
|
||||
xenon_headlights: 'Xenon Headlights',
|
||||
digital_cockpit: 'Digital Cockpit',
|
||||
garage_kept: 'Garage Kept',
|
||||
smoke_free: 'Smoke Free',
|
||||
pet_free: 'Pet Free',
|
||||
service_book: 'Service Book',
|
||||
original_mileage: 'Verified Mileage',
|
||||
first_owner: 'First Owner',
|
||||
fleet_vehicle: 'Fleet Vehicle',
|
||||
imported: 'Imported',
|
||||
accident_free: 'Accident Free',
|
||||
// ── Extra fallback keys from VehicleFormModal template ──
|
||||
winter_tyres: 'Winter Tyres',
|
||||
auto_headlights: 'Auto Headlights',
|
||||
rear_spoiler: 'Rear Spoiler',
|
||||
side_skirts: 'Side Skirts',
|
||||
chrome_package: 'Chrome Package',
|
||||
black_package: 'Black Package',
|
||||
running_boards: 'Running Boards',
|
||||
spare_tyre: 'Spare Tyre',
|
||||
reflective_vest: 'Reflective Vest',
|
||||
winter_package: 'Winter Package',
|
||||
smoking_package: 'Smoking Package',
|
||||
pet_friendly: 'Pet Friendly',
|
||||
child_lock: 'Child Lock',
|
||||
anti_theft: 'Anti-Theft',
|
||||
gps_tracker: 'GPS Tracker',
|
||||
service_history: 'Service History',
|
||||
},
|
||||
// ── Motorcycle-specific labels ──
|
||||
label_strokes: 'Stroke',
|
||||
label_cooling: 'Cooling',
|
||||
label_fuel_mixture: 'Fuel Mixture',
|
||||
label_valves: 'Number of Valves',
|
||||
label_passenger_capacity: 'Passenger Capacity',
|
||||
label_has_reverse_gear: 'Has Reverse Gear',
|
||||
// Motorcycle strokes
|
||||
motorcycleStrokes: {
|
||||
'2_stroke': '2-Stroke',
|
||||
'4_stroke': '4-Stroke',
|
||||
electric: 'Electric',
|
||||
},
|
||||
// Motorcycle cooling
|
||||
motorcycleCooling: {
|
||||
air: 'Air Cooling',
|
||||
liquid: 'Liquid Cooling',
|
||||
oil: 'Oil Cooling',
|
||||
air_oil: 'Air + Oil Cooling',
|
||||
},
|
||||
// Motorcycle mixture
|
||||
motorcycleMixture: {
|
||||
carburetor: 'Carburetor',
|
||||
injection: 'Injection',
|
||||
},
|
||||
// Motorcycle feature categories
|
||||
motorcycleFeatureCategories: {
|
||||
technical: 'Technical',
|
||||
frame_body: 'Frame & Body',
|
||||
luggage: 'Luggage',
|
||||
multimedia: 'Multimedia',
|
||||
other: 'Other',
|
||||
},
|
||||
// Motorcycle features (extras)
|
||||
motorcycleFeatures: {
|
||||
// Technical
|
||||
dual_front_disc: 'Dual Front Disc Brake',
|
||||
front_disc: 'Front Disc Brake',
|
||||
rear_disc: 'Rear Disc Brake',
|
||||
chip_tuning: 'Chip Tuning',
|
||||
electronic_suspension: 'Electronic Suspension',
|
||||
onboard_computer: 'Onboard Computer',
|
||||
metal_brake_line: 'Metal Brake Line',
|
||||
rev_counter: 'Rev Counter',
|
||||
immobiliser: 'Immobiliser',
|
||||
catalyst: 'Catalytic Converter',
|
||||
electric_starter: 'Electric Starter',
|
||||
all_wheel_drive: 'All-Wheel Drive',
|
||||
alarm: 'Alarm System',
|
||||
sport_exhaust: 'Sport Exhaust',
|
||||
sport_air_filter: 'Sport Air Filter',
|
||||
cruise_control: 'Cruise Control',
|
||||
turbo: 'Turbocharger',
|
||||
'12v_system': '12V Power Socket',
|
||||
heated_grip: 'Heated Grips',
|
||||
abs: 'ABS',
|
||||
seat_belt: 'Seat Belt',
|
||||
dtc: 'DTC (Traction Control)',
|
||||
fog_light: 'Fog Light',
|
||||
airbag: 'Airbag',
|
||||
xenon_headlight: 'Xenon Headlight',
|
||||
// Frame & Body
|
||||
full_extra: 'Full Extra',
|
||||
leather_seat: 'Leather Seat',
|
||||
heated_seat: 'Heated Seat',
|
||||
backrest: 'Backrest',
|
||||
center_stand: 'Center Stand',
|
||||
footrest: 'Footrest',
|
||||
windshield: 'Windshield',
|
||||
plexiglass: 'Plexiglass',
|
||||
tank_pad: 'Tank Pad',
|
||||
tank_protector_leather: 'Tank Protector (Leather)',
|
||||
seat_height_adjust: 'Seat Height Adjustment',
|
||||
crash_bar: 'Crash Bar',
|
||||
hand_guards: 'Hand Guards',
|
||||
heated_mirror: 'Heated Mirror',
|
||||
tow_hitch: 'Tow Hitch',
|
||||
// Luggage
|
||||
factory_cases: 'Factory Cases',
|
||||
rear_case: 'Rear Case (Top Box)',
|
||||
side_cases: 'Side Cases',
|
||||
lockable_case: 'Lockable Case',
|
||||
side_bag: 'Side Bag',
|
||||
tank_bag: 'Tank Bag',
|
||||
bag_holder_bracket: 'Bag Holder Bracket',
|
||||
fork_bag: 'Fork Bag',
|
||||
// Multimedia
|
||||
cd_player: 'CD Player',
|
||||
gps_navigation: 'GPS Navigation',
|
||||
hi_fi: 'Hi-Fi Sound System',
|
||||
radio: 'Radio',
|
||||
info_display: 'Info Display',
|
||||
// Other
|
||||
under_warranty: 'Under Warranty',
|
||||
us_model: 'US Model',
|
||||
immediate_takeover: 'Immediate Takeover',
|
||||
showroom_vehicle: 'Showroom Vehicle',
|
||||
orderable: 'Orderable',
|
||||
car_trade_in_possible: 'Car Trade-In Possible',
|
||||
first_owner: 'First Owner',
|
||||
garage_kept: 'Garage Kept',
|
||||
female_owner: 'Female Owner',
|
||||
low_mileage: 'Low Mileage',
|
||||
second_owner: 'Second Owner',
|
||||
motorcycle_trade_in_possible: 'Motorcycle Trade-In Possible',
|
||||
track_fairing: 'Track Fairing',
|
||||
regularly_maintained: 'Regularly Maintained',
|
||||
service_book: 'Service Book',
|
||||
title_certificate: 'Title Certificate',
|
||||
},
|
||||
// ── LCV-specific labels ──
|
||||
label_cargo_section: '📦 CARGO SPECS',
|
||||
label_cargo_volume_m3: 'Cargo Volume (m³)',
|
||||
label_cargo_length_mm: 'Cargo Length (mm)',
|
||||
label_cargo_width_mm: 'Cargo Width (mm)',
|
||||
label_cargo_height_mm: 'Cargo Height (mm)',
|
||||
label_wheelhouse_distance_mm: 'Wheelhouse Distance (mm)',
|
||||
// LCV body types
|
||||
lcvBodyTypes: {
|
||||
closed_van: 'Closed Van',
|
||||
pickup: 'Pickup',
|
||||
double_cab_chassis: 'Double Cab Chassis',
|
||||
single_cab_chassis: 'Single Cab Chassis',
|
||||
box_tail_lift: 'Box Body with Tail Lift',
|
||||
tipper: 'Tipper',
|
||||
dropside: 'Dropside',
|
||||
refrigerated_van: 'Refrigerated Van',
|
||||
crew_cab_pickup: 'Crew Cab Pickup',
|
||||
window_van: 'Window Van',
|
||||
combi_van: 'Combi Van',
|
||||
platform_truck: 'Platform Truck',
|
||||
},
|
||||
// LCV feature categories
|
||||
lcvFeatureCategories: {
|
||||
technical: 'Technical',
|
||||
interior: 'Interior',
|
||||
exterior: 'Exterior',
|
||||
multimedia: 'Multimedia',
|
||||
},
|
||||
// LCV features (extras)
|
||||
lcvFeatures: {
|
||||
// Technical
|
||||
abs: 'ABS',
|
||||
asr: 'ASR (Traction Control)',
|
||||
gps_tracker: 'GPS Tracker',
|
||||
immobiliser: 'Immobiliser',
|
||||
alarm: 'Alarm',
|
||||
cruise_control: 'Cruise Control',
|
||||
parking_sensors_rear: 'Rear Parking Sensors',
|
||||
// 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',
|
||||
cargo_tie_down: 'Cargo Tie-Down Points',
|
||||
isofix: 'ISOFIX Child Seat Anchors',
|
||||
full_extra: 'Full Extra',
|
||||
auxiliary_heating: 'Auxiliary Heater (Webasto)',
|
||||
leather_interior: 'Leather Interior',
|
||||
heated_seat: 'Heated Seat',
|
||||
partition_wall: 'Partition Wall',
|
||||
seat_height_adjustment: 'Seat Height Adjustment',
|
||||
adjustable_steering_wheel: 'Adjustable Steering Wheel',
|
||||
central_locking: 'Central Locking',
|
||||
trip_computer: 'Trip Computer',
|
||||
power_steering: 'Power Steering',
|
||||
// Exterior
|
||||
power_windows: 'Power Windows',
|
||||
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
|
||||
cd_changer: 'CD Changer',
|
||||
cd_radio: 'CD Radio',
|
||||
gps: 'GPS Navigation',
|
||||
hifi: 'Hi-Fi Sound System',
|
||||
radio_cassette: 'Radio Cassette',
|
||||
},
|
||||
// ── HGV-specific labels ──
|
||||
label_hgv_section: '🚛 HGV SPECS',
|
||||
label_operating_hours: 'Operating Hours',
|
||||
label_bed_count: 'Number of Bunks',
|
||||
label_axle_count: 'Number of Axles',
|
||||
label_driven_axle_count: 'Driven Axles',
|
||||
label_payload_capacity_kg: 'Payload Capacity (kg)',
|
||||
label_working_width_mm: 'Working Width (mm)',
|
||||
label_lifting_height_mm: 'Lifting Height (mm)',
|
||||
label_ground_clearance_mm: 'Ground Clearance (mm)',
|
||||
label_pto_type: 'PTO Type',
|
||||
label_has_differential_lock: 'Differential Lock',
|
||||
// HGV body types
|
||||
hgvBodyTypes: {
|
||||
chassis: 'Chassis',
|
||||
chassis_cab: 'Chassis Cab',
|
||||
tipper: 'Tipper',
|
||||
tipper_3_way: '3-Way Tipper',
|
||||
tipper_double: 'Double Sided Tipper',
|
||||
tipper_trailer: 'Tipper Trailer',
|
||||
box: 'Box Body',
|
||||
box_tail_lift: 'Box Body with Tail Lift',
|
||||
box_insulated: 'Insulated Box Body',
|
||||
box_curtain_side: 'Curtain Side Box Body',
|
||||
curtain_sider: 'Curtain Sider',
|
||||
flatbed: 'Flatbed',
|
||||
flatbed_stake: 'Stake Side Flatbed',
|
||||
flatbed_dropside: 'Dropside Flatbed',
|
||||
refrigerated: 'Refrigerated',
|
||||
refrigerated_trailer: 'Refrigerated Trailer',
|
||||
tanker: 'Tanker',
|
||||
tanker_fuel: 'Fuel Tanker',
|
||||
tanker_milk: 'Milk Tanker',
|
||||
tanker_chemical: 'Chemical Tanker',
|
||||
tanker_powder: 'Powder Tanker',
|
||||
tractor_unit: 'Tractor Unit',
|
||||
tractor_unit_4x2: 'Tractor Unit 4x2',
|
||||
tractor_unit_6x2: 'Tractor Unit 6x2',
|
||||
tractor_unit_6x4: 'Tractor Unit 6x4',
|
||||
tractor_unit_8x4: 'Tractor Unit 8x4',
|
||||
semi_trailer: 'Semi-Trailer',
|
||||
semi_trailer_curtain: 'Curtain Sider Semi-Trailer',
|
||||
semi_trailer_box: 'Box Semi-Trailer',
|
||||
semi_trailer_tipper: 'Tipper Semi-Trailer',
|
||||
semi_trailer_tanker: 'Tanker Semi-Trailer',
|
||||
semi_trailer_flatbed: 'Flatbed Semi-Trailer',
|
||||
semi_trailer_low_loader: 'Low Loader Semi-Trailer',
|
||||
semi_trailer_car_transporter: 'Car Transporter Semi-Trailer',
|
||||
container: 'Container',
|
||||
container_carrier: 'Container Carrier',
|
||||
swap_body: 'Swap Body',
|
||||
hook_lift: 'Hook Lift',
|
||||
crane: 'Crane',
|
||||
crane_truck: 'Crane Truck',
|
||||
crane_trailer: 'Crane Trailer',
|
||||
fire_truck: 'Fire Truck',
|
||||
fire_truck_aerial: 'Aerial Fire Truck',
|
||||
ambulance: 'Ambulance',
|
||||
hearse: 'Hearse',
|
||||
armored: 'Armored',
|
||||
livestock: 'Livestock Carrier',
|
||||
car_transporter: 'Car Transporter',
|
||||
mobile_shop: 'Mobile Shop',
|
||||
workshop: 'Workshop Truck',
|
||||
tow_truck: 'Tow Truck',
|
||||
street_sweeper: 'Street Sweeper',
|
||||
refuse_truck: 'Refuse Truck',
|
||||
concrete_mixer: 'Concrete Mixer',
|
||||
concrete_pump: 'Concrete Pump',
|
||||
dump_truck: 'Dump Truck',
|
||||
timber_truck: 'Timber Truck',
|
||||
log_trailer: 'Log Trailer',
|
||||
double_cab: 'Double Cab',
|
||||
triple_cab: 'Triple Cab',
|
||||
crew_cab: 'Crew Cab',
|
||||
sleeper_cab: 'Sleeper Cab',
|
||||
day_cab: 'Day Cab',
|
||||
glider_kit: 'Glider Kit',
|
||||
},
|
||||
// HGV transmission types
|
||||
hgvTransmissionTypes: {
|
||||
manual: 'Manual',
|
||||
manual_async: 'Manual (Async)',
|
||||
semi_auto: 'Semi-Automatic',
|
||||
auto: 'Automatic',
|
||||
manual_split: 'Manual Split Gearbox',
|
||||
semi_auto_split: 'Semi-Auto Split Gearbox',
|
||||
auto_split: 'Auto Split Gearbox',
|
||||
cvt: 'CVT',
|
||||
},
|
||||
// PTO types
|
||||
ptoTypes: {
|
||||
front: 'Front PTO',
|
||||
engine_mounted: 'Engine-Mounted PTO',
|
||||
gearbox_mounted: 'Gearbox-Mounted PTO',
|
||||
},
|
||||
// HGV feature categories
|
||||
hgvFeatureCategories: {
|
||||
technical_work: 'Technical / Work',
|
||||
cabin: 'Cabin',
|
||||
multimedia: 'Multimedia',
|
||||
},
|
||||
// HGV features (extras)
|
||||
hgvFeatures: {
|
||||
// Technical / Work
|
||||
diff_lock_front: 'Front Differential Lock',
|
||||
diff_lock_rear: 'Rear Differential Lock',
|
||||
diff_lock_center: 'Center Differential Lock',
|
||||
diff_lock_full: 'Full Differential Lock',
|
||||
cross_axle_lock: 'Cross-Axle Differential Lock',
|
||||
retarder: 'Retarder',
|
||||
exhaust_brake: 'Exhaust Brake',
|
||||
engine_brake: 'Engine Brake',
|
||||
auxiliary_brake: 'Auxiliary Brake',
|
||||
hill_holder: 'Hill Holder',
|
||||
hill_descent_control: 'Hill Descent Control',
|
||||
traction_control: 'Traction Control',
|
||||
stability_control: 'Stability Control (ESP)',
|
||||
roll_stability: 'Roll Stability Program (RSP)',
|
||||
lane_departure: 'Lane Departure Warning',
|
||||
adaptive_cruise: 'Adaptive Cruise Control (ACC)',
|
||||
collision_warning: 'Collision Warning',
|
||||
emergency_brake_assist: 'Emergency Brake Assist',
|
||||
blind_spot_monitor: 'Blind Spot Monitor',
|
||||
traffic_sign_recognition: 'Traffic Sign Recognition',
|
||||
driver_alert: 'Driver Alert System',
|
||||
tyre_pressure_monitor: 'Tyre Pressure Monitor (TPMS)',
|
||||
reverse_camera: 'Reverse Camera',
|
||||
'360_camera': '360° Camera',
|
||||
side_camera: 'Side Camera',
|
||||
front_camera: 'Front Camera',
|
||||
rear_camera: 'Rear Camera',
|
||||
parking_sensors: 'Parking Sensors',
|
||||
front_parking_sensors: 'Front Parking Sensors',
|
||||
rear_parking_sensors: 'Rear Parking Sensors',
|
||||
side_parking_sensors: 'Side Parking Sensors',
|
||||
tachograph: 'Tachograph',
|
||||
digital_tachograph: 'Digital Tachograph',
|
||||
smart_tachograph: 'Smart Tachograph (G2V2)',
|
||||
gps_tracker: 'GPS Tracker',
|
||||
fleet_management: 'Fleet Management System',
|
||||
fuel_monitoring: 'Fuel Monitoring System',
|
||||
eco_driving: 'Eco Driving Assistant',
|
||||
weigh_in_motion: 'Weigh-In-Motion System',
|
||||
load_securing: 'Load Securing System',
|
||||
// Cabin
|
||||
sleeper_cabin: 'Sleeper Cabin',
|
||||
single_bunk: 'Single Bunk',
|
||||
double_bunk: 'Double Bunk',
|
||||
upper_bunk: 'Upper Bunk',
|
||||
lower_bunk: 'Lower Bunk',
|
||||
fridge: 'Refrigerator',
|
||||
freezer: 'Freezer',
|
||||
microwave: 'Microwave Oven',
|
||||
coffee_machine: 'Coffee Machine',
|
||||
water_heater: 'Water Heater',
|
||||
air_conditioner: 'Air Conditioner',
|
||||
parking_heater: 'Parking Heater',
|
||||
auxiliary_heater: 'Auxiliary Heater',
|
||||
night_heater: 'Night Heater',
|
||||
roof_vent: 'Roof Vent',
|
||||
power_vent: 'Power Roof Vent',
|
||||
sun_visor: 'Sun Visor',
|
||||
curtain_set: 'Curtain Set',
|
||||
cabin_lighting: 'Cabin Lighting',
|
||||
led_cabin_lighting: 'LED Cabin Lighting',
|
||||
reading_light: 'Reading Light',
|
||||
cabin_table: 'Cabin Table',
|
||||
passenger_seat: 'Passenger Seat',
|
||||
passenger_seat_swivel: 'Swivel Passenger Seat',
|
||||
driver_seat_suspension: 'Air Suspension Driver Seat',
|
||||
seat_heating: 'Seat Heating',
|
||||
seat_ventilation: 'Seat Ventilation',
|
||||
armrest: 'Armrest Seat',
|
||||
storage_box: 'Storage Box',
|
||||
wardrobe: 'Wardrobe',
|
||||
// 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',
|
||||
satellite_radio: 'Satellite Radio',
|
||||
digital_tv: 'Digital TV',
|
||||
dvb_t: 'DVB-T TV',
|
||||
dvb_s: 'DVB-S Satellite TV',
|
||||
premium_sound: 'Premium Sound System',
|
||||
subwoofer: 'Subwoofer',
|
||||
rear_entertainment: 'Rear Entertainment System',
|
||||
wifi_hotspot: 'WiFi Hotspot',
|
||||
},
|
||||
},
|
||||
|
||||
// ── Service Finder ────────────────────────────────────────────────
|
||||
serviceFinder: {
|
||||
title: '🔧 Service Finder',
|
||||
heroTitle: '🔧 <span class="text-sf-accent">Service</span> Finder',
|
||||
heroSubtitle: 'Find the best auto service providers in one place',
|
||||
// Dashboard Card 3 — Launchpad
|
||||
plannedMaintenance: 'Planned Maintenance',
|
||||
sosTitle: 'Emergency Help',
|
||||
sosSubtitle: 'Need immediate roadside assistance?',
|
||||
comingSoon: 'Coming Soon',
|
||||
searchPlaceholder: 'Service or provider (e.g. AC repair)',
|
||||
cityPlaceholder: 'City or ZIP Code',
|
||||
searchButton: 'Search',
|
||||
searching: 'Searching...',
|
||||
quickFilters: 'Quick filters:',
|
||||
resultsTitle: 'Results ({count})',
|
||||
startSearching: 'Start searching!',
|
||||
noResults: 'No results for your search.',
|
||||
pageInfo: 'Page: {current} / {total}',
|
||||
backToDashboard: 'Back to Dashboard',
|
||||
noResultsTitle: 'No results',
|
||||
noResultsHint: 'Try different search terms or browse using the quick filters!',
|
||||
showAll: 'Show all providers',
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
// Source badges
|
||||
sourceVerified: 'Verified',
|
||||
sourceCommunity: 'Community',
|
||||
sourceRobot: 'Robot Data',
|
||||
sourceVerifiedTitle: 'Verified provider',
|
||||
sourceCommunityTitle: 'Community recommended',
|
||||
sourceRobotTitle: 'Robot collected data',
|
||||
sourceVerifiedLabel: 'Verified provider',
|
||||
sourceCommunityLabel: 'Community recommended',
|
||||
sourceRobotLabel: 'Robot collected',
|
||||
addressNotAvailable: 'Address not available',
|
||||
noCategory: 'No category',
|
||||
// Quick filter chips
|
||||
filterAutoSzerelo: '🔧 Car Mechanic',
|
||||
filterGumiszerviz: '🛞 Tire Service',
|
||||
filterKarosszeria: '🔩 Body Shop',
|
||||
filterAutoMento: '🚛 Tow Truck',
|
||||
filterBenzinkut: '⛽ Gas Station',
|
||||
filterAutoKozmetika: '✨ Car Detailing',
|
||||
// SOS Tab
|
||||
sosTabTitle: 'SOS Rescue',
|
||||
sosPlaceholder: '🚧 This feature is under development. You will be able to request emergency roadside assistance here soon!',
|
||||
// Planned Tab
|
||||
plannedTabTitle: 'Planned Search',
|
||||
// Provider Detail Modal
|
||||
detailTitle: 'Provider Details',
|
||||
detailCategory: 'Category',
|
||||
detailAddress: 'Address',
|
||||
detailSource: 'Source',
|
||||
detailSpecialization: 'Specialization',
|
||||
detailRating: 'Rating',
|
||||
detailNoRating: 'No rating yet',
|
||||
detailNoSpecialization: 'Not specified',
|
||||
editRequest: '✏️ Complete Data (Earn Points!)',
|
||||
close: 'Close',
|
||||
},
|
||||
|
||||
// ── Provider ──────────────────────────────────────────────────────
|
||||
provider: {
|
||||
quickAddTitle: 'Add New Provider',
|
||||
quickAddSubtitle: 'Help the community! You earn points for adding.',
|
||||
nameLabel: 'Provider Name',
|
||||
nameRequired: 'Required',
|
||||
namePlaceholder: 'e.g. MOL Auto Service',
|
||||
categoryLabel: 'Category',
|
||||
categoryRequired: 'Required',
|
||||
categoryPlaceholder: 'Select a category',
|
||||
zipLabel: 'ZIP Code',
|
||||
zipOptional: 'optional',
|
||||
zipPlaceholder: 'e.g. 1011',
|
||||
cityLabel: 'City',
|
||||
cityOptional: 'optional',
|
||||
cityPlaceholder: 'e.g. Budapest',
|
||||
streetLabel: 'Street, House Number',
|
||||
streetOptional: 'optional',
|
||||
streetPlaceholder: 'e.g. Kossuth Street 1',
|
||||
submitButton: 'Register Provider & Earn Points!',
|
||||
submitting: 'Registering...',
|
||||
closeButton: 'Close',
|
||||
successMessage: 'Successfully registered! You earned {points} points!',
|
||||
successSubtext: 'Thank you for helping build the community!',
|
||||
errorMessage: 'An error occurred while registering the provider. Please try again!',
|
||||
// Autocomplete
|
||||
autocompletePlaceholder: 'Search provider...',
|
||||
autocompleteNoResults: 'No results found.',
|
||||
autocompleteAddNew: '+ Add new provider and earn points!',
|
||||
autocompleteClear: 'Clear selection',
|
||||
autocompleteVerified: '✓ Verified',
|
||||
autocompleteSourceVerified: 'Verified',
|
||||
autocompleteSourceRobot: 'Robot collected',
|
||||
autocompleteSourceCommunity: 'Community',
|
||||
// Soft-deduplication
|
||||
duplicateWarning: 'Similar providers found nearby: {matches}. Are you sure you want to add a new one?',
|
||||
duplicateWarningShort: 'Similar provider found nearby: {name}. Are you sure you want to add a new one?',
|
||||
// Edit Modal
|
||||
editTitle: 'Edit Provider Data',
|
||||
streetNameLabel: 'Street Name',
|
||||
streetNamePlaceholder: 'e.g. Egressy',
|
||||
streetTypeLabel: 'Street Type',
|
||||
streetTypePlaceholder: 'Select type...',
|
||||
houseNumberLabel: 'House Number',
|
||||
houseNumberPlaceholder: 'e.g. 4',
|
||||
phoneLabel: 'Phone',
|
||||
phonePlaceholder: 'e.g. +36 20 123 4567',
|
||||
emailLabel: 'Email',
|
||||
emailPlaceholder: 'e.g. info@provider.hu',
|
||||
websiteLabel: 'Website',
|
||||
websitePlaceholder: 'e.g. https://provider.hu',
|
||||
tagsLabel: 'Service Tags',
|
||||
tagsPlaceholder: 'Add tags (comma-separated)',
|
||||
editInfo: 'Your edits will be reviewed and may earn gamification points!',
|
||||
},
|
||||
|
||||
admin: {
|
||||
services: {
|
||||
title: 'Services',
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export default {
|
||||
// ── Common / Globális ──
|
||||
common: {
|
||||
cancel: 'Mégse',
|
||||
save: 'Mentés',
|
||||
saving: 'Mentés...',
|
||||
optional: 'opcionális',
|
||||
select: 'Válassz',
|
||||
},
|
||||
landing: {
|
||||
openGarage: 'Garázs Nyitása',
|
||||
heroTitle: 'Digitális Flotta',
|
||||
@@ -86,6 +94,40 @@ export default {
|
||||
detailedView: 'Részletes Nézet',
|
||||
modalPlaceholder: 'A szekció tartalma hamarosan elérhető lesz.',
|
||||
},
|
||||
finance: {
|
||||
wallet: 'Pénztárca',
|
||||
totalCredits: 'Összes Kredit',
|
||||
purchased: 'Vásárolt',
|
||||
bonus: 'Bónusz',
|
||||
earned: 'Keresett',
|
||||
voucher: 'Voucher',
|
||||
topUp: 'Egyenleg Feltöltése',
|
||||
live: 'ÉLŐ',
|
||||
retry: 'Újra',
|
||||
// WalletDetailsModal keys
|
||||
walletDetails: 'Pénztárca Részletek',
|
||||
walletBreakdown: 'Egyenleg Bontás',
|
||||
topUpWallet: 'Vásárolt',
|
||||
referralRewards: 'Keresett',
|
||||
rewards: 'Bónusz',
|
||||
activeVouchers: 'Voucher',
|
||||
totalBalance: 'Összes Kredit',
|
||||
requestPayout: 'Kifizetés Igénylése',
|
||||
transactionHistory: 'Tranzakció Előzmények',
|
||||
all: 'Összes',
|
||||
noTransactions: 'Nincsenek tranzakciók',
|
||||
// Cost Manager keys
|
||||
costManager: 'Költség Menedzser',
|
||||
manageAllCosts: 'Összes költség kezelése',
|
||||
costDate: 'Dátum',
|
||||
costType: 'Típus',
|
||||
costAmount: 'Összeg',
|
||||
costVehicle: 'Jármű',
|
||||
costDescription: 'Leírás',
|
||||
noCosts: 'Nincsenek költségek',
|
||||
costManagerDescription: 'Az összes járműhöz tartozó költségek áttekintése',
|
||||
payoutComingSoon: 'Kifizetési funkció hamarosan!',
|
||||
},
|
||||
zen: {
|
||||
hide: 'UI elrejtése (Zen mód)',
|
||||
show: 'UI megjelenítése',
|
||||
@@ -421,6 +463,859 @@ export default {
|
||||
hrsz: 'Hrsz.: {value}',
|
||||
placeholder: '—',
|
||||
},
|
||||
servicebook: {
|
||||
tabLabel: 'Szervizkönyv',
|
||||
noEvents: 'Még nincsenek szerviz események rögzítve.',
|
||||
addEvent: 'Új bejegyzés',
|
||||
cancel: 'Mégsem',
|
||||
saving: 'Mentés...',
|
||||
eventDate: 'Esemény dátuma',
|
||||
odometer: 'Km óra állás (km)',
|
||||
eventType: 'Esemény típusa',
|
||||
description: 'Leírás',
|
||||
descriptionPlaceholder: 'Írd le a szerviz vagy javítás részleteit...',
|
||||
eventTypes: {
|
||||
SERVICE: 'Szerviz',
|
||||
REPAIR: 'Javítás',
|
||||
ACCIDENT: 'Baleset',
|
||||
INSPECTION: 'Vizsga',
|
||||
TIRE_CHANGE: 'Gumicsere',
|
||||
MAINTENANCE: 'Karbantartás',
|
||||
UPGRADE: 'Fejlesztés',
|
||||
RECALL: 'Visszahívás',
|
||||
},
|
||||
eventSaved: 'Szerviz esemény sikeresen elmentve!',
|
||||
eventSaveError: 'Nem sikerült elmenteni a szerviz eseményt.',
|
||||
loadError: 'Nem sikerült betölteni a szerviz eseményeket.',
|
||||
atOdometer: '{km} km-nél',
|
||||
costAmount: 'Költség (opcionális)',
|
||||
costPlaceholder: 'Add meg az összeget...',
|
||||
},
|
||||
|
||||
// ── Vehicle / Jármű ─────────────────────────────────────────────
|
||||
vehicle: {
|
||||
edit_title: 'Jármű szerkesztése',
|
||||
add_title: 'Új jármű hozzáadása',
|
||||
features_hint: 'Válaszd ki a jármű felszereltségét és extra opcióit.',
|
||||
// Fuel types
|
||||
fuelTypes: {
|
||||
benzin: 'Benzin',
|
||||
diesel: 'Dízel',
|
||||
electric: 'Elektromos',
|
||||
hybrid: 'Hibrid',
|
||||
plugin_hybrid: 'Plugin Hibrid',
|
||||
lpg: 'LPG',
|
||||
cng: 'CNG',
|
||||
hydrogen: 'Hidrogén',
|
||||
ethanol: 'Etanol',
|
||||
},
|
||||
// Vehicle classes
|
||||
classes: {
|
||||
personal: 'Személyes',
|
||||
motorcycle: 'Motorkerékpár',
|
||||
lcv: 'Kishaszonjármű',
|
||||
hgv: 'Haszongépjármű',
|
||||
machine: 'Munkagép',
|
||||
bus: 'Busz',
|
||||
camper: 'Lakóautó / Lakókocsi',
|
||||
trailer: 'Utánfutó',
|
||||
boat: 'Hajó',
|
||||
other: 'Egyéb',
|
||||
},
|
||||
// AC types
|
||||
acTypes: {
|
||||
manual: 'Manuális',
|
||||
automatic: 'Automata',
|
||||
dual_zone: 'Kétzónás',
|
||||
tri_zone: 'Háromzónás',
|
||||
quad_zone: 'Négyzónás',
|
||||
},
|
||||
// Cylinder layouts
|
||||
cylinderLayouts: {
|
||||
inline: 'Soros',
|
||||
v: 'V-elrendezés',
|
||||
boxer: 'Boxer',
|
||||
w: 'W-elrendezés',
|
||||
rotary: 'Rotációs (Wankel)',
|
||||
},
|
||||
// Placeholders
|
||||
placeholder_brand: 'BMW, Toyota, ...',
|
||||
placeholder_model: 'X5, Corolla, ...',
|
||||
placeholder_year: '2024',
|
||||
placeholder_trim: 'Pl. Comfort, Sport, ...',
|
||||
placeholder_name: 'Pl. Béla, Fekete Özvegy',
|
||||
placeholder_mileage: '0',
|
||||
placeholder_custom_class: 'Pl. Quad, Segway, Golfkocsi',
|
||||
placeholder_engine_capacity: '1995',
|
||||
placeholder_power_kw: '150',
|
||||
placeholder_torque_nm: '320',
|
||||
placeholder_type_designation: 'Pl. GTI, M Sport, AMG',
|
||||
placeholder_curb_weight: '1500',
|
||||
placeholder_max_weight: '2200',
|
||||
placeholder_battery_capacity: '50',
|
||||
placeholder_range: '400',
|
||||
placeholder_color: 'Pl. Fekete, Fehér',
|
||||
placeholder_notes: 'Pl. Szerviztörténet, megjegyzések...',
|
||||
placeholder_final_mileage: '0',
|
||||
// Extra labels
|
||||
label_custom_class: 'Kérjük, adja meg a jármű típusát',
|
||||
label_dates_section: 'Dátumok',
|
||||
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_purchase_date: 'Vásárlás dátuma',
|
||||
label_color: 'Szín',
|
||||
label_cylinder_count: 'Hengerek száma',
|
||||
label_battery_capacity: 'Akkumulátor kapacitás (kWh)',
|
||||
label_range: 'Hatótáv (km)',
|
||||
label_ac_connector: 'AC töltő csatlakozó',
|
||||
label_dc_connector: 'DC gyorstöltő csatlakozó',
|
||||
label_final_mileage: 'Végleges kilométeróra állás',
|
||||
label_archive_reason: 'Archiválás oka',
|
||||
// Danger zone
|
||||
danger_zone: 'Veszélyzóna',
|
||||
danger_zone_desc: 'A jármű végleges eltávolítása a garázsból. Ez a művelet nem vonható vissza!',
|
||||
remove_vehicle: 'Jármű eltávolítása',
|
||||
confirm_archive_title: 'Jármű archiválásának megerősítése',
|
||||
confirm_archive_desc: 'A jármű véglegesen eltávolításra kerül a garázsból. Ez a művelet nem vonható vissza!',
|
||||
confirm_archive: 'Végleges eltávolítás',
|
||||
archive_reason_sold: 'Eladva',
|
||||
archive_reason_scrapped: 'Roncsként selejtezve',
|
||||
archive_reason_stolen: 'Ellopva',
|
||||
archive_reason_transferred: 'Más tulajdonba került',
|
||||
archive_reason_other: 'Egyéb',
|
||||
// Drive types
|
||||
driveTypes: {
|
||||
fwd: 'Elsőkerék (FWD)',
|
||||
rwd: 'Hátsókerék (RWD)',
|
||||
awd: 'Összkerék (AWD)',
|
||||
'4wd': 'Összkerék (4WD)',
|
||||
chain: 'Lánc',
|
||||
shaft: 'Kardán',
|
||||
belt: 'Szíj',
|
||||
},
|
||||
// Transmission types
|
||||
transmissionTypes: {
|
||||
manual: 'Manuális',
|
||||
automatic: 'Automata',
|
||||
sequential: 'Szekvenciális',
|
||||
cvt: 'Fokozatmentes automata',
|
||||
semi_auto: 'Félautomata',
|
||||
dct: 'DCT (Dupla kuplungos)',
|
||||
manual_sequential: 'Kézi (Szekvenciális)',
|
||||
},
|
||||
// Body types
|
||||
bodyTypes: {
|
||||
sedan: 'Sedan',
|
||||
hatchback: 'Ferdehátú',
|
||||
wagon: 'Kombi',
|
||||
estate: 'Kombi',
|
||||
coupe: 'Kupé',
|
||||
convertible: 'Kabrió',
|
||||
suv: 'SUV',
|
||||
mpv: 'Egyterű (MPV)',
|
||||
pickup: 'Pickup',
|
||||
van: 'Furgon',
|
||||
},
|
||||
// Labels
|
||||
label_drive_type: 'Meghajtás',
|
||||
label_transmission: 'Váltó',
|
||||
label_body_type: 'Karosszéria típus',
|
||||
label_engine: 'Motor',
|
||||
label_vin: 'Alvázszám (VIN)',
|
||||
label_mileage: 'Jelenlegi km óra állás',
|
||||
label_fuel_type: 'Üzemanyag',
|
||||
label_trim_level: 'Felszereltség',
|
||||
label_condition: 'Állapot',
|
||||
label_features: 'Extrák / Felszereltség',
|
||||
label_dates: 'Dátumok',
|
||||
label_mot_expiry: 'Műszaki érvényesség',
|
||||
label_document_expiry: 'Dokumentumok érvényessége',
|
||||
label_reg_cert_number: 'Forgalmi engedély száma',
|
||||
label_title_cert_number: 'Törzskönyv száma',
|
||||
label_documents: 'Dokumentumok',
|
||||
label_ev_section: 'Elektromos Hajtás / Akkumulátor',
|
||||
label_engine_capacity: 'Hengerűrtartalom (cm³)',
|
||||
label_power_kw: 'Teljesítmény (kW)',
|
||||
label_torque_nm: 'Nyomaték (Nm)',
|
||||
label_curb_weight: 'Saját tömeg (kg)',
|
||||
label_max_weight: 'Össztömeg (kg)',
|
||||
label_type_designation: 'Típusjel',
|
||||
label_ac_type: 'Légkondícionáló',
|
||||
label_cylinder_layout: 'Hengerelrendezés',
|
||||
label_door_count: 'Ajtók száma',
|
||||
label_seat_count: 'Ülések száma',
|
||||
label_condition_select: 'Állapot',
|
||||
label_notes: 'Megjegyzés',
|
||||
label_vehicle_class: 'Kategória',
|
||||
label_license_plate: 'Rendszám',
|
||||
label_brand: 'Márka',
|
||||
label_model: 'Modell',
|
||||
label_year: 'Évjárat',
|
||||
label_name: 'Becenév',
|
||||
// Warranty
|
||||
warranty_section_title: 'Jótállás / Garancia',
|
||||
label_is_under_warranty: 'Érvényes jótállás',
|
||||
label_warranty_expiry_date: 'Jótállás érvényessége',
|
||||
// Tab labels
|
||||
tab_basic: 'Alapadatok',
|
||||
tab_technical: 'Műszaki',
|
||||
tab_administration: 'Adminisztráció',
|
||||
tab_equipment: 'Felszereltség',
|
||||
// Accordion labels
|
||||
accordion_technical_features: 'Technikai extrák',
|
||||
accordion_interior_features: 'Belső tér',
|
||||
accordion_exterior_features: 'Külső',
|
||||
accordion_multimedia_features: 'Multimédia',
|
||||
accordion_other_features: 'Egyéb',
|
||||
// Save button
|
||||
save_changes: 'Módosítások mentése',
|
||||
add_vehicle: 'Jármű hozzáadása',
|
||||
saving: 'Mentés...',
|
||||
// Validation
|
||||
required_fields_hint: 'Kérjük töltse ki a kötelező mezőket',
|
||||
// Condition options
|
||||
condition_excellent: 'Kiváló',
|
||||
condition_good: 'Jó',
|
||||
condition_fair: 'Megfelelő',
|
||||
condition_poor: 'Rossz',
|
||||
// Feature categories
|
||||
featureCategories: {
|
||||
technical: 'Technikai',
|
||||
interior: 'Belső tér',
|
||||
exterior: 'Külső',
|
||||
multimedia: 'Multimédia',
|
||||
other: 'Egyéb',
|
||||
},
|
||||
// Features (extras)
|
||||
features: {
|
||||
abs: 'ABS',
|
||||
esp: 'ESP (Menetstabilizáló)',
|
||||
traction_control: 'Kipörgésgátló (TCS)',
|
||||
hill_assist: 'Hegymeneti tartó',
|
||||
hill_descent: 'Lejtmenetvezérlő',
|
||||
lane_assist: 'Sávtartó asszisztens',
|
||||
blind_spot: 'Holtpont-figyelő',
|
||||
adaptive_cruise: 'Adaptív tempomat (ACC)',
|
||||
parking_sensors_front: 'Első parkolóérzékelők',
|
||||
parking_sensors_rear: 'Hátsó parkolóérzékelők',
|
||||
rear_camera: 'Tolatókamera',
|
||||
'360_camera': '360°-os kamera',
|
||||
auto_parking: 'Automatikus parkolóasszisztens',
|
||||
start_stop: 'Start-Stop rendszer',
|
||||
keyless_go: 'Kulcs nélküli indítás (Keyless Go)',
|
||||
night_vision: 'Éjjellátó (Night Vision)',
|
||||
traffic_sign_recognition: 'Táblafelismerő rendszer',
|
||||
driver_alert: 'Fáradtságfigyelő',
|
||||
emergency_brake: 'Vészfék asszisztens',
|
||||
cross_traffic_alert: 'Keresztirányú forgalom figyelő',
|
||||
leather_seats: 'Bőrülés',
|
||||
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és',
|
||||
electric_seats: 'Elektromos ülésállítás',
|
||||
memory_seats: 'Ülésmemória',
|
||||
sport_seats: 'Sportülés',
|
||||
heated_steering: 'Fűthető kormány',
|
||||
ambient_lighting: 'Hangulatvilágítás (Ambient Light)',
|
||||
panoramic_roof: 'Panorámatető',
|
||||
sunroof: 'Napfénytető',
|
||||
auto_dim_mirror: 'Automatikusan sötétedő belső tükör',
|
||||
electric_steering_adjust: 'Elektromos kormányállítás',
|
||||
rear_blind: 'Hátsó roló',
|
||||
rear_side_blinds: 'Hátsó oldalsó rolók',
|
||||
digital_instrument_cluster: 'Digitális műszeregység',
|
||||
head_up_display: 'Head-Up Display (HUD)',
|
||||
alloy_wheels: 'Könnyűfém felni',
|
||||
roof_rails: 'Tetősín (Roof Rails)',
|
||||
tow_bar: 'Vontatóhorog (vonóhorog)',
|
||||
tinted_windows: 'Sötétített üvegek',
|
||||
privacy_glass: 'Privacy Glass',
|
||||
led_headlights: 'LED fényszóró',
|
||||
adaptive_headlights: 'Adaptív fényszóró (AFS)',
|
||||
fog_lights: 'Ködlámpa',
|
||||
cornering_lights: 'Kanyarvilágítás',
|
||||
rain_sensor: 'Esőérzékelő',
|
||||
light_sensor: 'Fényérzékelő',
|
||||
auto_wipers: 'Automatikus ablaktörlő',
|
||||
electric_trunk: 'Elektromos csomagtérajtó',
|
||||
hands_free_trunk: 'Kihangosítható csomagtérajtó (Hands-Free)',
|
||||
side_steps: 'Oldallépcső',
|
||||
mudguards: 'Sárvédő',
|
||||
navigation: 'Navigáció (GPS)',
|
||||
apple_carplay: 'Apple CarPlay',
|
||||
android_auto: 'Android Auto',
|
||||
bluetooth: 'Bluetooth',
|
||||
usb_charging: 'USB töltőcsatlakozó',
|
||||
wireless_charging: 'Vezeték nélküli töltő',
|
||||
dab_radio: 'DAB+ digitális rádió',
|
||||
premium_sound: 'Prémium hangrendszer',
|
||||
subwoofer: 'Mélynyomó (Subwoofer)',
|
||||
rear_entertainment: 'Hátsó szórakoztató rendszer',
|
||||
wifi_hotspot: 'WiFi Hotspot',
|
||||
voice_control: 'Hangvezérlés',
|
||||
gesture_control: 'Gesztusvezérlés',
|
||||
spare_tire: 'Pótkerék',
|
||||
tire_repair_kit: 'Kerékpár-javító készlet',
|
||||
first_aid_kit: 'Elsősegély csomag',
|
||||
warning_triangle: 'Figyelmeztető háromszög',
|
||||
fire_extinguisher: 'Tűzoltó készülék',
|
||||
winter_tires: 'Téli gumi',
|
||||
cargo_net: 'Csomagtér háló',
|
||||
cargo_cover: 'Csomagtér takaró',
|
||||
pet_barrier: 'Kutya rács / elválasztó',
|
||||
roof_box: 'Tetős doboz',
|
||||
bike_rack: 'Kerékpártartó',
|
||||
ski_rack: 'Sítartó',
|
||||
airbag_front: 'Első légzsák',
|
||||
electric_windows: 'Elektromos ablakemelő',
|
||||
// ── Additional fallback keys from Vue template ──
|
||||
hill_start_assist: 'Hegyi start segéd',
|
||||
hill_descent_control: 'Lejtmenet szabályzó',
|
||||
cruise_control: 'Tempomat',
|
||||
adaptive_cruise_control: 'Adaptív tempomat',
|
||||
rear_view_camera: 'Tolatókamera',
|
||||
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',
|
||||
electric_seat_adjust: 'Elektromos ülésállítás',
|
||||
heated_steering_wheel: 'Fűtött kormány',
|
||||
auto_dimming_mirror: 'Automata sötétedő tükör',
|
||||
trunk_cover: 'Csomagtér takaró',
|
||||
xenon_headlights: 'Xenon fényszóró',
|
||||
digital_cockpit: 'Digitális műszerfal',
|
||||
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',
|
||||
// ── Extra fallback keys from VehicleFormModal template ──
|
||||
winter_tyres: 'Téli gumi',
|
||||
auto_headlights: 'Automatikus fényszóró',
|
||||
rear_spoiler: 'Hátsó spoiler',
|
||||
side_skirts: 'Oldalsó szoknya',
|
||||
chrome_package: 'Króm csomag',
|
||||
black_package: 'Fekete csomag',
|
||||
running_boards: 'Oldallépcső',
|
||||
spare_tyre: 'Pótkerék',
|
||||
reflective_vest: 'Reflexiós mellény',
|
||||
winter_package: 'Téli csomag',
|
||||
smoking_package: 'Dohányzó csomag',
|
||||
pet_friendly: 'Kisállat barát',
|
||||
child_lock: 'Gyerekzár',
|
||||
anti_theft: 'Riasztó',
|
||||
gps_tracker: 'GPS követő',
|
||||
service_history: 'Szerviztörténet',
|
||||
},
|
||||
// ── Motorcycle-specific labels ──
|
||||
label_strokes: 'Munkaütem',
|
||||
label_cooling: 'Hűtés',
|
||||
label_fuel_mixture: 'Keverékképzés',
|
||||
label_valves: 'Szelepek száma',
|
||||
label_passenger_capacity: 'Szállítható személyek száma',
|
||||
label_has_reverse_gear: 'Hátramenettel rendelkezik',
|
||||
// Motorcycle strokes
|
||||
motorcycleStrokes: {
|
||||
'2_stroke': '2 ütemű',
|
||||
'4_stroke': '4 ütemű',
|
||||
electric: 'Elektromos',
|
||||
},
|
||||
// Motorcycle cooling
|
||||
motorcycleCooling: {
|
||||
air: 'Levegőhűtés',
|
||||
liquid: 'Vízhűtés',
|
||||
oil: 'Olajhűtés',
|
||||
air_oil: 'Levegő/Olaj hűtés',
|
||||
},
|
||||
// Motorcycle mixture
|
||||
motorcycleMixture: {
|
||||
carburetor: 'Karburátor',
|
||||
injection: 'Befecskendezés',
|
||||
},
|
||||
// Motorcycle feature categories
|
||||
motorcycleFeatureCategories: {
|
||||
technical: 'Műszaki',
|
||||
frame_body: 'Váz / Idom',
|
||||
luggage: 'Táska / Doboz',
|
||||
multimedia: 'Multimédia',
|
||||
other: 'Egyéb',
|
||||
},
|
||||
// Motorcycle features (extras)
|
||||
motorcycleFeatures: {
|
||||
// Technical
|
||||
dual_front_disc: 'Dupla tárcsafék elöl',
|
||||
front_disc: 'Tárcsafék elöl',
|
||||
rear_disc: 'Tárcsafék hátul',
|
||||
chip_tuning: 'Chiptuning',
|
||||
electronic_suspension: 'Elektromos futómű állítás',
|
||||
onboard_computer: 'Fedélzeti computer',
|
||||
metal_brake_line: 'Fém fékcső',
|
||||
rev_counter: 'Fordulatszámmérő',
|
||||
immobiliser: 'Immobiliser',
|
||||
catalyst: 'Katalizátor',
|
||||
electric_starter: 'Önindító',
|
||||
all_wheel_drive: 'Összkerékhajtás',
|
||||
alarm: 'Riasztó',
|
||||
sport_exhaust: 'Sport kipufogó',
|
||||
sport_air_filter: 'Sport légszűrő',
|
||||
cruise_control: 'Tempomat',
|
||||
turbo: 'Turbó',
|
||||
'12v_system': '12 V rendszer',
|
||||
heated_grip: 'Markolat fűtés',
|
||||
abs: 'ABS (blokkolásgátló)',
|
||||
seat_belt: 'Biztonsági öv',
|
||||
dtc: 'DTC',
|
||||
fog_light: 'Ködlámpa',
|
||||
airbag: 'Légzsák',
|
||||
xenon_headlight: 'Xenon fényszóró',
|
||||
// Frame / Body
|
||||
full_extra: 'Full extra',
|
||||
leather_seat: 'Bőrülés',
|
||||
heated_seat: 'Fűthető ülés',
|
||||
backrest: 'Háttámla',
|
||||
center_stand: 'Középsztender',
|
||||
footrest: 'Lábtartó',
|
||||
windshield: 'Motoros szélvédő',
|
||||
plexiglass: 'Plexi',
|
||||
tank_pad: 'Tankpad',
|
||||
tank_protector_leather: 'Tankvédő bőr',
|
||||
seat_height_adjust: 'Ülésmagasság állítás',
|
||||
crash_bar: 'Bukócső / bukógomba',
|
||||
hand_guards: 'Kézvédők',
|
||||
heated_mirror: 'Fűthető tükör',
|
||||
tow_hitch: 'Vonóhorog',
|
||||
// Luggage
|
||||
factory_cases: 'Gyári dobozok',
|
||||
rear_case: 'Hátsó doboz',
|
||||
side_cases: 'Oldalsó dobozok',
|
||||
lockable_case: 'Zárható doboz',
|
||||
side_bag: 'Oldaltáska',
|
||||
tank_bag: 'Tank táska',
|
||||
bag_holder_bracket: 'Táskatartó konzol',
|
||||
fork_bag: 'Villatáska',
|
||||
// Multimedia
|
||||
cd_player: 'CD tár',
|
||||
gps_navigation: 'GPS (navigáció)',
|
||||
hi_fi: 'Hi-Fi',
|
||||
radio: 'Rádiós magnó',
|
||||
info_display: 'Információs kijelző',
|
||||
// Other
|
||||
under_warranty: 'Garanciális',
|
||||
us_model: 'Amerikai modell',
|
||||
immediate_takeover: 'Azonnal elvihető',
|
||||
showroom_vehicle: 'Bemutató jármű',
|
||||
orderable: 'Rendelhető',
|
||||
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',
|
||||
low_mileage: 'Keveset futott',
|
||||
second_owner: 'Második tulajdonostól',
|
||||
motorcycle_trade_in_possible: 'Motorbeszámítás lehetséges',
|
||||
track_fairing: 'Pályaidom',
|
||||
regularly_maintained: 'Rendszeresen karbantartott',
|
||||
service_book: 'Szervizkönyv',
|
||||
title_certificate: 'Törzskönyv',
|
||||
},
|
||||
// ── LCV-specific labels ──
|
||||
label_cargo_section: '📦 RAKTÉR ADATOK',
|
||||
label_cargo_volume_m3: 'Raktér térfogat (m³)',
|
||||
label_cargo_length_mm: 'Raktér hossz (mm)',
|
||||
label_cargo_width_mm: 'Raktér szélesség (mm)',
|
||||
label_cargo_height_mm: 'Raktér magasság (mm)',
|
||||
label_wheelhouse_distance_mm: 'Kerékdob távolság (mm)',
|
||||
// LCV body types
|
||||
lcvBodyTypes: {
|
||||
closed_van: 'Zárt furgon',
|
||||
pickup: 'Pickup',
|
||||
double_cab_chassis: 'Dupla kabin alváz',
|
||||
single_cab_chassis: 'Egyes kabin alváz',
|
||||
box_tail_lift: 'Dobozos raktér emelővel',
|
||||
tipper: 'Billenős plató',
|
||||
dropside: 'Billenő oldalfalú plató',
|
||||
refrigerated_van: 'Hűtött furgon',
|
||||
crew_cab_pickup: 'Crew Cab Pickup',
|
||||
window_van: 'Ablakos furgon',
|
||||
combi_van: 'Combi furgon',
|
||||
platform_truck: 'Platós teherautó',
|
||||
},
|
||||
// LCV feature categories
|
||||
lcvFeatureCategories: {
|
||||
technical: 'Technikai',
|
||||
interior: 'Belső tér',
|
||||
exterior: 'Külső',
|
||||
multimedia: 'Multimédia',
|
||||
},
|
||||
// LCV features (extras)
|
||||
lcvFeatures: {
|
||||
// Technical
|
||||
abs: 'ABS',
|
||||
asr: 'ASR (Kipörgésgátló)',
|
||||
gps_tracker: 'GPS nyomkövető',
|
||||
immobiliser: 'Immobiliser',
|
||||
alarm: 'Riasztó',
|
||||
cruise_control: 'Tempomat',
|
||||
parking_sensors_rear: 'Hátsó parkolóérzékelők',
|
||||
// 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',
|
||||
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)',
|
||||
leather_interior: 'Bőr belső',
|
||||
heated_seat: 'Fűthető ülés',
|
||||
partition_wall: 'Válaszfal',
|
||||
seat_height_adjustment: 'Ülésmagasság állítás',
|
||||
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
|
||||
power_windows: 'Elektromos ablakemelő',
|
||||
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ó',
|
||||
},
|
||||
// ── HGV-specific labels ──
|
||||
label_hgv_section: '🚛 TEHERGÉPKOCSI ADATOK',
|
||||
label_operating_hours: 'Üzemóra',
|
||||
label_bed_count: 'Fekvőhelyek száma',
|
||||
label_axle_count: 'Tengelyek száma',
|
||||
label_driven_axle_count: 'Hajtott tengelyek száma',
|
||||
label_payload_capacity_kg: 'Hasznos teherbírás (kg)',
|
||||
label_working_width_mm: 'Munkaszélesség (mm)',
|
||||
label_lifting_height_mm: 'Emelési magasság (mm)',
|
||||
label_ground_clearance_mm: 'Hasmagasság (mm)',
|
||||
label_pto_type: 'Kihajtás típusa (PTO)',
|
||||
label_has_differential_lock: 'Differenciálzár',
|
||||
// HGV body types
|
||||
hgvBodyTypes: {
|
||||
chassis: 'Alváz',
|
||||
chassis_cab: 'Alvázas fülke',
|
||||
tipper: 'Billenős',
|
||||
tipper_3_way: '3 irányba billenő',
|
||||
tipper_double: 'Kétoldalt billenő',
|
||||
tipper_trailer: 'Billenős pótkocsi',
|
||||
box: 'Dobozos',
|
||||
box_tail_lift: 'Dobozos raktér emelővel',
|
||||
box_insulated: 'Hőszigetelt dobozos',
|
||||
box_curtain_side: 'Függönyoldalas dobozos',
|
||||
curtain_sider: 'Függönyoldalas',
|
||||
flatbed: 'Platós',
|
||||
flatbed_stake: 'Rakodóléces platós',
|
||||
flatbed_dropside: 'Billenő oldalfalú platós',
|
||||
refrigerated: 'Hűtött',
|
||||
refrigerated_trailer: 'Hűtött pótkocsi',
|
||||
tanker: 'Tartályos',
|
||||
tanker_fuel: 'Üzemanyag szállító tartály',
|
||||
tanker_milk: 'Tej szállító tartály',
|
||||
tanker_chemical: 'Vegyi anyag szállító tartály',
|
||||
tanker_powder: 'Poranyag szállító tartály',
|
||||
tractor_unit: 'Vontató (nyerges)',
|
||||
tractor_unit_4x2: 'Vontató 4x2',
|
||||
tractor_unit_6x2: 'Vontató 6x2',
|
||||
tractor_unit_6x4: 'Vontató 6x4',
|
||||
tractor_unit_8x4: 'Vontató 8x4',
|
||||
semi_trailer: 'Félpótkocsi',
|
||||
semi_trailer_curtain: 'Függönyoldalas félpótkocsi',
|
||||
semi_trailer_box: 'Dobozos félpótkocsi',
|
||||
semi_trailer_tipper: 'Billenős félpótkocsi',
|
||||
semi_trailer_tanker: 'Tartályos félpótkocsi',
|
||||
semi_trailer_flatbed: 'Platós félpótkocsi',
|
||||
semi_trailer_low_loader: 'Mélyágyas félpótkocsi',
|
||||
semi_trailer_car_transporter: 'Autószállító félpótkocsi',
|
||||
container: 'Konténer',
|
||||
container_carrier: 'Konténer szállító',
|
||||
swap_body: 'Cserélhető felépítmény',
|
||||
hook_lift: 'Rakodókaros (Hook Lift)',
|
||||
crane: 'Daru',
|
||||
crane_truck: 'Darus teherautó',
|
||||
crane_trailer: 'Darus pótkocsi',
|
||||
fire_truck: 'Tűzoltóautó',
|
||||
fire_truck_aerial: 'Léptetőkosaras tűzoltó',
|
||||
ambulance: 'Mentőautó',
|
||||
hearse: 'Halottaskocsi',
|
||||
armored: 'Páncélozott',
|
||||
livestock: 'Állatszállító',
|
||||
car_transporter: 'Autószállító',
|
||||
mobile_shop: 'Mozgó bolt',
|
||||
workshop: 'Műhelyautó',
|
||||
tow_truck: 'Mentő / Szállító',
|
||||
street_sweeper: 'Utcaseprő',
|
||||
refuse_truck: 'Szemetes',
|
||||
concrete_mixer: 'Betonszállító',
|
||||
concrete_pump: 'Betonszivattyú',
|
||||
dump_truck: 'Billenős teherautó',
|
||||
timber_truck: 'Faszállító',
|
||||
log_trailer: 'Farönk szállító pótkocsi',
|
||||
double_cab: 'Dupla kabin',
|
||||
triple_cab: 'Háromajtós kabin',
|
||||
crew_cab: 'Crew Cab',
|
||||
sleeper_cab: 'Alvófülkés',
|
||||
day_cab: 'Nappali fülke',
|
||||
glider_kit: 'Glider Kit (felújított)',
|
||||
},
|
||||
// HGV transmission types
|
||||
hgvTransmissionTypes: {
|
||||
manual: 'Manuális',
|
||||
manual_async: 'Manuális (aszinkron)',
|
||||
semi_auto: 'Félautomata',
|
||||
auto: 'Automata',
|
||||
manual_split: 'Manuális felezős',
|
||||
semi_auto_split: 'Félautomata felezős',
|
||||
auto_split: 'Automata felezős',
|
||||
cvt: 'Fokozatmentes (CVT)',
|
||||
},
|
||||
// PTO types
|
||||
ptoTypes: {
|
||||
front: 'Első kihajtás (Front)',
|
||||
engine_mounted: 'Motorra szerelt (Engine-mounted)',
|
||||
gearbox_mounted: 'Váltóra szerelt (Gearbox-mounted)',
|
||||
},
|
||||
// HGV feature categories
|
||||
hgvFeatureCategories: {
|
||||
technical_work: 'Műszaki / Munka',
|
||||
cabin: 'Fülke',
|
||||
multimedia: 'Multimédia',
|
||||
},
|
||||
// HGV features (extras)
|
||||
hgvFeatures: {
|
||||
// Technical / Work
|
||||
diff_lock_front: 'Első differenciálzár',
|
||||
diff_lock_rear: 'Hátsó differenciálzár',
|
||||
diff_lock_center: 'Központi differenciálzár',
|
||||
diff_lock_full: 'Teljes differenciálzár',
|
||||
cross_axle_lock: 'Kereszttengely differenciálzár',
|
||||
retarder: 'Retarder',
|
||||
exhaust_brake: 'Kipufogófék',
|
||||
engine_brake: 'Motorfék',
|
||||
auxiliary_brake: 'Segédfék',
|
||||
hill_holder: 'Hegymeneti tartó',
|
||||
hill_descent_control: 'Lejtmenetvezérlő',
|
||||
traction_control: 'Kipörgésgátló',
|
||||
stability_control: 'Stabilitás szabályzó (ESP)',
|
||||
roll_stability: 'Borulásgátló (RSP)',
|
||||
lane_departure: 'Sávelhagyás figyelő',
|
||||
adaptive_cruise: 'Adaptív tempomat (ACC)',
|
||||
collision_warning: 'Ütközés figyelmeztető',
|
||||
emergency_brake_assist: 'Vészfék asszisztens',
|
||||
blind_spot_monitor: 'Holtpont figyelő',
|
||||
traffic_sign_recognition: 'Táblafelismerő',
|
||||
driver_alert: 'Fáradtságfigyelő',
|
||||
tyre_pressure_monitor: 'Guminyomás monitor (TPMS)',
|
||||
reverse_camera: 'Tolatókamera',
|
||||
'360_camera': '360°-os kamera',
|
||||
side_camera: 'Oldalsó kamera',
|
||||
front_camera: 'Első kamera',
|
||||
rear_camera: 'Hátsó kamera',
|
||||
parking_sensors: 'Parkolóérzékelők',
|
||||
front_parking_sensors: 'Első parkolóérzékelők',
|
||||
rear_parking_sensors: 'Hátsó parkolóérzékelők',
|
||||
side_parking_sensors: 'Oldalsó parkolóérzékelők',
|
||||
tachograph: 'Menetíró (Tachográf)',
|
||||
digital_tachograph: 'Digitális menetíró',
|
||||
smart_tachograph: 'Smart Tachográf (G2V2)',
|
||||
gps_tracker: 'GPS nyomkövető',
|
||||
fleet_management: 'Flottamenedzsment rendszer',
|
||||
fuel_monitoring: 'Üzemanyag monitorozás',
|
||||
eco_driving: 'Eco Driving asszisztens',
|
||||
weigh_in_motion: 'Menet közbeni mérlegelés',
|
||||
load_securing: 'Rakományrögzítő rendszer',
|
||||
// Cabin
|
||||
sleeper_cabin: 'Alvófülke',
|
||||
single_bunk: 'Egyágyas fekvőhely',
|
||||
double_bunk: 'Kétágyas fekvőhely',
|
||||
upper_bunk: 'Felső ágy',
|
||||
lower_bunk: 'Alsó ágy',
|
||||
fridge: 'Hűtőszekrény',
|
||||
freezer: 'Fagyasztó',
|
||||
microwave: 'Mikrohullámú sütő',
|
||||
coffee_machine: 'Kávéfőző',
|
||||
water_heater: 'Vízforraló',
|
||||
air_conditioner: 'Légkondícionáló',
|
||||
parking_heater: 'Parkolófűtés',
|
||||
auxiliary_heater: 'Kiegészítő fűtés',
|
||||
night_heater: 'Éjszakai fűtés',
|
||||
roof_vent: 'Tetőszellőző',
|
||||
power_vent: 'Elektromos tetőszellőző',
|
||||
sun_visor: 'Napellenző',
|
||||
curtain_set: 'Függönykészlet',
|
||||
cabin_lighting: 'Fülkevilágítás',
|
||||
led_cabin_lighting: 'LED fülkevilágítás',
|
||||
reading_light: 'Olvasólámpa',
|
||||
cabin_table: 'Fülkeasztal',
|
||||
passenger_seat: 'Utasülés',
|
||||
passenger_seat_swivel: 'Forgatható utasülés',
|
||||
driver_seat_suspension: 'Légrugós vezetőülés',
|
||||
seat_heating: 'Ülésfűtés',
|
||||
seat_ventilation: 'Ülésszellőztetés',
|
||||
armrest: 'Karfás ülés',
|
||||
storage_box: 'Tároló rekesz',
|
||||
wardrobe: 'Gardrób',
|
||||
// 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ó',
|
||||
satellite_radio: 'Műholdas rádió',
|
||||
digital_tv: 'Digitális TV',
|
||||
dvb_t: 'DVB-T 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',
|
||||
},
|
||||
},
|
||||
|
||||
// ── Service Finder ────────────────────────────────────────────────
|
||||
serviceFinder: {
|
||||
title: '🔧 Service Finder',
|
||||
heroTitle: '🔧 <span class="text-sf-accent">Service</span> Finder',
|
||||
heroSubtitle: 'Találd meg a legjobb autós szolgáltatókat egy helyen',
|
||||
// Dashboard Card 3 — Indítópult
|
||||
plannedMaintenance: 'Tervezett Karbantartás',
|
||||
sosTitle: 'Segélyhívás',
|
||||
sosSubtitle: 'Azonnali úti segítségre van szükséged?',
|
||||
comingSoon: 'Hamarosan',
|
||||
searchPlaceholder: 'Szolgáltató vagy szolgáltatás (pl. Klíma)',
|
||||
cityPlaceholder: 'Település vagy Irányítószám',
|
||||
searchButton: 'Keresés',
|
||||
searching: 'Keresés...',
|
||||
quickFilters: 'Gyors szűrők:',
|
||||
resultsTitle: 'Találatok ({count})',
|
||||
startSearching: 'Kezdj el keresni!',
|
||||
noResults: 'Nincs találat a megadott keresésre.',
|
||||
pageInfo: 'Oldal: {current} / {total}',
|
||||
backToDashboard: 'Vissza a vezérlőpultra',
|
||||
noResultsTitle: 'Nincs találat',
|
||||
noResultsHint: 'Próbáld meg más keresőszavakkal, vagy böngéssz a gyors szűrők segítségével!',
|
||||
showAll: 'Összes szolgáltató mutatása',
|
||||
previous: 'Előző',
|
||||
next: 'Következő',
|
||||
// Source badges
|
||||
sourceVerified: 'Hitelesített',
|
||||
sourceCommunity: 'Közösségi',
|
||||
sourceRobot: 'Robot adat',
|
||||
sourceVerifiedTitle: 'Hitelesített szolgáltató',
|
||||
sourceCommunityTitle: 'Közösség által ajánlott',
|
||||
sourceRobotTitle: 'Robot által gyűjtött adat',
|
||||
sourceVerifiedLabel: 'Hitelesített szolgáltató',
|
||||
sourceCommunityLabel: 'Közösség által ajánlott',
|
||||
sourceRobotLabel: 'Robot által gyűjtött',
|
||||
addressNotAvailable: 'Cím nem elérhető',
|
||||
noCategory: 'Nincs kategória',
|
||||
// Quick filter chips
|
||||
filterAutoSzerelo: '🔧 Autószerelő',
|
||||
filterGumiszerviz: '🛞 Gumiszerviz',
|
||||
filterKarosszeria: '🔩 Karosszéria',
|
||||
filterAutoMento: '🚛 Autómentő',
|
||||
filterBenzinkut: '⛽ Benzinkút',
|
||||
filterAutoKozmetika: '✨ Autókozmetika',
|
||||
// SOS Tab
|
||||
sosTabTitle: 'SOS Mentés',
|
||||
sosPlaceholder: '🚧 Ez a funkció fejlesztés alatt áll. Hamarosan kérhető lesz itt az azonnali úti segítség!',
|
||||
// Planned Tab
|
||||
plannedTabTitle: 'Tervezett Keresés',
|
||||
// Provider Detail Modal
|
||||
detailTitle: 'Szolgáltató adatlap',
|
||||
detailCategory: 'Kategória',
|
||||
detailAddress: 'Cím',
|
||||
detailSource: 'Forrás',
|
||||
detailSpecialization: 'Szakértelem',
|
||||
detailRating: 'Értékelés',
|
||||
detailNoRating: 'Még nincs értékelés',
|
||||
detailNoSpecialization: 'Nincs megadva',
|
||||
editRequest: '✏️ Adatok kiegészítése (Pontszerzés!)',
|
||||
close: 'Bezárás',
|
||||
},
|
||||
|
||||
// ── Provider ──────────────────────────────────────────────────────
|
||||
provider: {
|
||||
quickAddTitle: 'Új szolgáltató rögzítése',
|
||||
quickAddSubtitle: 'Segíts a közösségnek! Pontokat kapsz a hozzáadásért.',
|
||||
nameLabel: 'Szolgáltató neve',
|
||||
nameRequired: 'Kötelező',
|
||||
namePlaceholder: 'Pl. MOL Autószerviz',
|
||||
categoryLabel: 'Kategória',
|
||||
categoryRequired: 'Kötelező',
|
||||
categoryPlaceholder: 'Válassz kategóriát',
|
||||
zipLabel: 'Irányítószám',
|
||||
zipOptional: 'opcionális',
|
||||
zipPlaceholder: 'Pl. 1011',
|
||||
cityLabel: 'Város',
|
||||
cityOptional: 'opcionális',
|
||||
cityPlaceholder: 'Pl. Budapest',
|
||||
streetLabel: 'Utca, házszám',
|
||||
streetOptional: 'opcionális',
|
||||
streetPlaceholder: 'Pl. Kossuth utca 1',
|
||||
submitButton: 'Szolgáltató rögzítése és pontszerzés!',
|
||||
submitting: 'Rögzítés...',
|
||||
closeButton: 'Bezárás',
|
||||
successMessage: 'Sikeres rögzítés! Szereztél {points} pontot!',
|
||||
successSubtext: 'Köszönjük, hogy segítesz a közösség építésében!',
|
||||
errorMessage: 'Hiba történt a szolgáltató rögzítése során. Kérjük, próbáld újra!',
|
||||
// Autocomplete
|
||||
autocompletePlaceholder: 'Szolgáltató keresése...',
|
||||
autocompleteNoResults: 'Nincs találat a keresésre.',
|
||||
autocompleteAddNew: '+ Új szolgáltató rögzítése és pontszerzés!',
|
||||
autocompleteClear: 'Kiválasztás törlése',
|
||||
autocompleteVerified: '✓ Ellenőrzött',
|
||||
autocompleteSourceVerified: 'Ellenőrzött',
|
||||
autocompleteSourceRobot: 'Robot által gyűjtött',
|
||||
autocompleteSourceCommunity: 'Közösségi',
|
||||
// Soft-deduplication
|
||||
duplicateWarning: 'Hasonló szolgáltatókat találtunk a környéken: {matches}. Biztosan újat szeretnél rögzíteni?',
|
||||
duplicateWarningShort: 'Hasonló szolgáltató található a környéken: {name}. Biztosan újat szeretnél rögzíteni?',
|
||||
// Edit Modal
|
||||
editTitle: 'Szolgáltató adatok szerkesztése',
|
||||
streetNameLabel: 'Utca neve',
|
||||
streetNamePlaceholder: 'Pl. Egressy',
|
||||
streetTypeLabel: 'Közterület jellege',
|
||||
streetTypePlaceholder: 'Válassz típust...',
|
||||
houseNumberLabel: 'Házszám',
|
||||
houseNumberPlaceholder: 'Pl. 4',
|
||||
phoneLabel: 'Telefonszám',
|
||||
phonePlaceholder: 'Pl. +36 20 123 4567',
|
||||
emailLabel: 'Email',
|
||||
emailPlaceholder: 'Pl. info@szolgaltato.hu',
|
||||
websiteLabel: 'Weboldal',
|
||||
websitePlaceholder: 'Pl. https://szolgaltato.hu',
|
||||
tagsLabel: 'Szolgáltatás Címkék',
|
||||
tagsPlaceholder: 'Címkék hozzáadása (vesszővel elválasztva)',
|
||||
editInfo: 'A szerkesztéseid ellenőrzésre kerülnek, és gamifikációs pontokat szerezhetsz!',
|
||||
},
|
||||
|
||||
admin: {
|
||||
services: {
|
||||
title: 'Szolgáltatások',
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<!-- Language Switcher -->
|
||||
<LanguageSwitcher />
|
||||
|
||||
<!-- Hamburger Menu (Private Garage Navigation) -->
|
||||
<div class="relative hamburger-container">
|
||||
<button
|
||||
@@ -100,6 +103,7 @@ import BaseHeader from '../components/layout/BaseHeader.vue'
|
||||
import HeaderLogo from '../components/header/HeaderLogo.vue'
|
||||
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
|
||||
import HeaderProfile from '../components/header/HeaderProfile.vue'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -18,6 +18,11 @@ const router = createRouter({
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('../views/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: 'service-finder',
|
||||
name: 'service-finder',
|
||||
component: () => import('../views/ServiceFinderView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
18
frontend/src/services/dateUtils.ts
Normal file
18
frontend/src/services/dateUtils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Returns today's date as a YYYY-MM-DD string in the **local** timezone.
|
||||
*
|
||||
* Why this exists:
|
||||
* `new Date().toISOString().split('T')[0]` returns the date in UTC.
|
||||
* In timezones ahead of UTC (e.g. CEST = UTC+2), a local time like
|
||||
* 01:00 AM is still the *previous day* in UTC, causing date-picker
|
||||
* defaults to show yesterday.
|
||||
*
|
||||
* This function compensates for the timezone offset so the returned
|
||||
* string always matches the user's local calendar date.
|
||||
*/
|
||||
export function getLocalDateString(): string {
|
||||
const d = new Date()
|
||||
// Shift by the timezone offset so toISOString reflects local date
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
147
frontend/src/stores/catalog.ts
Normal file
147
frontend/src/stores/catalog.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
/**
|
||||
* Catalog Store
|
||||
* =============
|
||||
* Manages cascading vehicle catalog data (brands → models → years → trims)
|
||||
* fetched from the backend /catalog endpoints.
|
||||
*
|
||||
* Used by VehicleFormModal.vue for the brand/model/year/trim autocomplete dropdowns.
|
||||
*/
|
||||
export const useCatalogStore = defineStore('catalog', () => {
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
/** List of brand names (string[]) */
|
||||
const brands = ref<string[]>([])
|
||||
|
||||
/** List of models for the currently selected brand */
|
||||
const models = ref<any[]>([])
|
||||
|
||||
/** List of trims for the currently selected brand+model */
|
||||
const trims = ref<any[]>([])
|
||||
|
||||
/** Loading flags */
|
||||
const isBrandsLoading = ref(false)
|
||||
const isModelsLoading = ref(false)
|
||||
const isTrimsLoading = ref(false)
|
||||
|
||||
/** Error state */
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all available brands from the catalog.
|
||||
* GET /catalog/brands
|
||||
*/
|
||||
async function fetchBrands() {
|
||||
if (brands.value.length > 0) return // already loaded
|
||||
isBrandsLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.get('/catalog/brands')
|
||||
brands.value = res.data as string[]
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Nem sikerült betölteni a márkákat.'
|
||||
error.value = message
|
||||
console.error('[CatalogStore] fetchBrands error:', err)
|
||||
} finally {
|
||||
isBrandsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch models for a given brand, optionally filtered by vehicle_class.
|
||||
* GET /catalog/brands/{brand}/models?vehicle_class={vehicle_class}
|
||||
*/
|
||||
async function fetchModels(brand: string, vehicle_class?: string) {
|
||||
if (!brand) {
|
||||
models.value = []
|
||||
return
|
||||
}
|
||||
isModelsLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (vehicle_class) params.vehicle_class = vehicle_class
|
||||
const res = await api.get(`/catalog/brands/${encodeURIComponent(brand)}/models`, { params })
|
||||
models.value = res.data as any[]
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Nem sikerült betölteni a modelleket.'
|
||||
error.value = message
|
||||
console.error('[CatalogStore] fetchModels error:', err)
|
||||
models.value = []
|
||||
} finally {
|
||||
isModelsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch trims for a given brand + model + optional year.
|
||||
* GET /catalog/brands/{brand}/models/{model}/trims?year={year}
|
||||
*/
|
||||
async function fetchTrims(brand: string, model: string, year?: string) {
|
||||
if (!brand || !model) {
|
||||
trims.value = []
|
||||
return
|
||||
}
|
||||
isTrimsLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (year) params.year = year
|
||||
const res = await api.get(
|
||||
`/catalog/brands/${encodeURIComponent(brand)}/models/${encodeURIComponent(model)}/trims`,
|
||||
{ params }
|
||||
)
|
||||
trims.value = res.data as any[]
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Nem sikerült betölteni a felszereltségeket.'
|
||||
error.value = message
|
||||
console.error('[CatalogStore] fetchTrims error:', err)
|
||||
trims.value = []
|
||||
} finally {
|
||||
isTrimsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all catalog data (e.g. on logout or brand reset).
|
||||
*/
|
||||
function clearCatalog() {
|
||||
brands.value = []
|
||||
models.value = []
|
||||
trims.value = []
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
brands,
|
||||
models,
|
||||
trims,
|
||||
isBrandsLoading,
|
||||
isModelsLoading,
|
||||
isTrimsLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchBrands,
|
||||
fetchModels,
|
||||
fetchTrims,
|
||||
clearCatalog,
|
||||
}
|
||||
})
|
||||
92
frontend/src/stores/finance.ts
Normal file
92
frontend/src/stores/finance.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
/**
|
||||
* Wallet balance shape returned by GET /billing/wallet/balance
|
||||
*/
|
||||
export interface WalletBalance {
|
||||
earned: number
|
||||
purchased: number
|
||||
service_coins: number
|
||||
voucher: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export const useFinanceStore = defineStore('finance', () => {
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
const balance = ref<WalletBalance | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ── Getters ────────────────────────────────────────────────────────
|
||||
|
||||
/** Total credits across all wallet pockets */
|
||||
const totalCredits = computed(() => balance.value?.total ?? 0)
|
||||
|
||||
/** Purchased credits (manually topped up) */
|
||||
const purchasedCredits = computed(() => balance.value?.purchased ?? 0)
|
||||
|
||||
/** Service Coins (bonus/reward credits) */
|
||||
const serviceCoins = computed(() => balance.value?.service_coins ?? 0)
|
||||
|
||||
/** Earned credits (from activities) */
|
||||
const earnedCredits = computed(() => balance.value?.earned ?? 0)
|
||||
|
||||
/** Voucher balance */
|
||||
const voucherBalance = computed(() => balance.value?.voucher ?? 0)
|
||||
|
||||
/** Whether the wallet has any credits */
|
||||
const hasCredits = computed(() => totalCredits.value > 0)
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the current user's wallet balance from the backend.
|
||||
* GET /billing/wallet/balance
|
||||
*/
|
||||
async function fetchWalletBalance() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.get('/billing/wallet/balance')
|
||||
balance.value = res.data as WalletBalance
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Nem sikerült betölteni a pénztárca egyenleget.'
|
||||
error.value = message
|
||||
console.error('[FinanceStore] fetchWalletBalance error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any stored error.
|
||||
*/
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
balance,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
totalCredits,
|
||||
purchasedCredits,
|
||||
serviceCoins,
|
||||
earnedCredits,
|
||||
voucherBalance,
|
||||
hasCredits,
|
||||
|
||||
// Actions
|
||||
fetchWalletBalance,
|
||||
clearError,
|
||||
}
|
||||
})
|
||||
@@ -37,6 +37,10 @@ export interface Vehicle {
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
|
||||
// Warranty
|
||||
is_under_warranty: boolean
|
||||
warranty_expiry_date: string | null
|
||||
|
||||
// Sales
|
||||
is_for_sale: boolean
|
||||
price: number | null
|
||||
@@ -48,6 +52,34 @@ export interface Vehicle {
|
||||
// Organization association (from backend AssetResponse)
|
||||
current_organization_id: number | null
|
||||
owner_organization_id: number | null
|
||||
|
||||
// JSONB individual_equipment from backend (car_specs, ev_specs, dates, etc.)
|
||||
individual_equipment?: {
|
||||
car_specs?: {
|
||||
body_type?: string
|
||||
ac_type?: string
|
||||
cylinder_layout?: string
|
||||
features?: string[]
|
||||
}
|
||||
ev_specs?: {
|
||||
battery_capacity_kwh?: number
|
||||
battery_soh?: number
|
||||
ac_connector?: string
|
||||
ac_power_kw?: number
|
||||
dc_connector?: string
|
||||
dc_power_kw?: number
|
||||
range_wltp?: number
|
||||
range_highway?: number
|
||||
range_winter?: number
|
||||
fast_charge_support?: boolean
|
||||
green_plate_eligible?: boolean
|
||||
}
|
||||
dates?: {
|
||||
mot_expiry?: string
|
||||
document_expiry?: string
|
||||
}
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
export const useVehicleStore = defineStore('vehicle', () => {
|
||||
@@ -99,6 +131,29 @@ export const useVehicleStore = defineStore('vehicle', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single vehicle by its ID.
|
||||
* GET /assets/vehicles/{id}
|
||||
* Used to refresh vehicle data after odometer updates or other mutations.
|
||||
*/
|
||||
async function fetchVehicleById(id: string): Promise<Vehicle | null> {
|
||||
try {
|
||||
const res = await api.get(`/assets/vehicles/${id}`)
|
||||
const updated = res.data as Vehicle
|
||||
|
||||
// Update the vehicle in local state
|
||||
const index = vehicles.value.findIndex(v => v.id === id)
|
||||
if (index !== -1) {
|
||||
vehicles.value[index] = updated
|
||||
}
|
||||
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
console.error('[VehicleStore] fetchVehicleById error:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new vehicle via POST /assets/vehicles.
|
||||
* On success, adds the new vehicle to the local state and refreshes the list.
|
||||
@@ -239,6 +294,7 @@ export const useVehicleStore = defineStore('vehicle', () => {
|
||||
|
||||
// Actions
|
||||
fetchVehicles,
|
||||
fetchVehicleById,
|
||||
createVehicle,
|
||||
updateVehicle,
|
||||
setPrimaryVehicle,
|
||||
|
||||
@@ -164,47 +164,36 @@
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 3: 🔧 Service Finder (Szerviz kereső)
|
||||
Card 3: 🔧 Service Finder (3-Way Indítópult)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="openCard('costs')"
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">🔧 Service Finder</span>
|
||||
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Active notifications / service alerts -->
|
||||
<div
|
||||
v-for="(notif, idx) in notifications"
|
||||
:key="idx"
|
||||
class="flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm"
|
||||
<!-- Content: 3 simple buttons stacked vertically -->
|
||||
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
|
||||
<button
|
||||
@click="router.push('/dashboard/service-finder?mode=planned')"
|
||||
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
<span class="mt-0.5 text-base">{{ notif.icon }}</span>
|
||||
<div>
|
||||
<p class="text-slate-800 font-medium">{{ notif.title }}</p>
|
||||
<p class="text-slate-400 text-xs mt-0.5">{{ notif.time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="notifications.length === 0"
|
||||
class="flex flex-col items-center justify-center py-6 text-slate-400"
|
||||
>
|
||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('dashboard.noNotifications') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom CTA button -->
|
||||
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||
{{ t('menu.features') }} 🔍
|
||||
🔍 {{ t('serviceFinder.plannedMaintenance') }}
|
||||
</button>
|
||||
<button
|
||||
@click="router.push('/dashboard/service-finder?mode=sos')"
|
||||
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
🚨 SOS {{ t('serviceFinder.sosTitle') }}
|
||||
</button>
|
||||
<button
|
||||
@click="isSfQuickAddOpen = true"
|
||||
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
➕ {{ t('provider.quickAddTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
@@ -401,6 +390,14 @@
|
||||
@saved="onEditVehicleSaved"
|
||||
@deleted="onVehicleDeleted"
|
||||
/>
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Provider Quick Add Modal (Service Finder Card 3)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<ProviderQuickAddModal
|
||||
:is-open="isSfQuickAddOpen"
|
||||
@close="isSfQuickAddOpen = false"
|
||||
@saved="onSfQuickAddSaved"
|
||||
/>
|
||||
</div><!-- end min-h-screen -->
|
||||
</template>
|
||||
|
||||
@@ -418,6 +415,7 @@ import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
|
||||
import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue'
|
||||
import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue'
|
||||
import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue'
|
||||
import ProviderQuickAddModal from '../components/provider/ProviderQuickAddModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -438,6 +436,18 @@ const openCard = (cardId: string, targetId: string | null = null) => {
|
||||
selectedTargetId.value = targetId
|
||||
}
|
||||
|
||||
// ── Service Finder (Card 3) — Launcher ──
|
||||
const isSearchModalOpen = ref(false)
|
||||
const isSfQuickAddOpen = ref(false)
|
||||
|
||||
/**
|
||||
* Called when ProviderQuickAddModal saves a new provider.
|
||||
*/
|
||||
function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
|
||||
isSfQuickAddOpen.value = false
|
||||
alert(`🎉 ${t('provider.successMessage', { points: 0 })}`)
|
||||
}
|
||||
|
||||
// ── Vehicle Detail Modal (Deep Link from plate click) ──
|
||||
const isVehicleDetailOpen = ref(false)
|
||||
const detailVehicle = ref<VehicleData | null>(null)
|
||||
|
||||
@@ -21,15 +21,10 @@
|
||||
</template>
|
||||
</BaseHeader>
|
||||
|
||||
<!-- ── Backdrop overlay: click outside the card closes the profile ── -->
|
||||
<!-- ── Profile Content — padding-top clears the sticky header ── -->
|
||||
<div class="relative z-10 pt-20">
|
||||
<div
|
||||
class="fixed inset-0 z-10 bg-black/30 backdrop-blur-[2px]"
|
||||
@click="closeProfile"
|
||||
>
|
||||
<!-- Content (card) — @click.stop prevents backdrop click from propagating -->
|
||||
<div
|
||||
@click.stop
|
||||
class="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 overflow-y-auto max-h-screen"
|
||||
class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8"
|
||||
>
|
||||
<!-- ── Loading Spinner ──────────────────────────────────────────── -->
|
||||
<div
|
||||
@@ -906,7 +901,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div><!-- /backdrop overlay -->
|
||||
</div><!-- /content wrapper -->
|
||||
</div><!-- /root -->
|
||||
</template>
|
||||
|
||||
|
||||
603
frontend/src/views/ServiceFinderView.vue
Normal file
603
frontend/src/views/ServiceFinderView.vue
Normal file
@@ -0,0 +1,603 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-800">
|
||||
<!-- ── Hero Search Section ── -->
|
||||
<div class="relative overflow-hidden bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
||||
<!-- Decorative background circles -->
|
||||
<div class="pointer-events-none absolute -right-20 -top-20 h-64 w-64 rounded-full bg-sf-accent/10 blur-3xl" />
|
||||
<div class="pointer-events-none absolute -bottom-20 -left-20 h-48 w-48 rounded-full bg-sf-blue/10 blur-3xl" />
|
||||
|
||||
<div class="relative mx-auto max-w-4xl">
|
||||
<!-- Title -->
|
||||
<h1 class="mb-2 text-center text-4xl font-extrabold tracking-tight text-white sm:text-5xl" v-html="t('serviceFinder.heroTitle')" />
|
||||
<p class="mb-8 text-center text-lg text-slate-300">
|
||||
{{ t('serviceFinder.heroSubtitle') }}
|
||||
</p>
|
||||
|
||||
<!-- Dual Search Bar -->
|
||||
<div class="mx-auto flex max-w-2xl flex-col gap-3 sm:flex-row">
|
||||
<!-- Input 1: Service / Provider -->
|
||||
<div class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('serviceFinder.searchPlaceholder')"
|
||||
class="w-full rounded-2xl border-2 border-transparent bg-white/10 py-4 pl-12 pr-4 text-base text-white placeholder-slate-400 backdrop-blur-sm transition-all focus:border-sf-accent focus:bg-white/15 focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<!-- Input 2: City / ZIP -->
|
||||
<div class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchCity"
|
||||
type="text"
|
||||
:placeholder="t('serviceFinder.cityPlaceholder')"
|
||||
class="w-full rounded-2xl border-2 border-transparent bg-white/10 py-4 pl-12 pr-4 text-base text-white placeholder-slate-400 backdrop-blur-sm transition-all focus:border-sf-accent focus:bg-white/15 focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="handleSearch"
|
||||
:disabled="isSearching"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-2xl bg-sf-accent px-8 py-4 text-base font-bold text-white shadow-lg transition-all hover:bg-sf-accent/90 hover:shadow-xl disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
v-if="isSearching"
|
||||
class="h-5 w-5 animate-spin"
|
||||
fill="none" viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{{ isSearching ? t('serviceFinder.searching') : t('serviceFinder.searchButton') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick filter chips -->
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
<span class="text-xs font-medium text-slate-400">{{ t('serviceFinder.quickFilters') }}</span>
|
||||
<button
|
||||
v-for="chip in quickFilters"
|
||||
:key="chip.key"
|
||||
@click="applyQuickFilter(chip.key)"
|
||||
class="rounded-full border border-white/20 px-3 py-1 text-xs font-medium text-slate-300 transition-all hover:border-sf-accent hover:bg-sf-accent/20 hover:text-white cursor-pointer"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab Bar (Planned Search / SOS Rescue) ── -->
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center gap-1 border-b border-slate-200 pt-4">
|
||||
<button
|
||||
@click="activeTab = 'planned'"
|
||||
class="inline-flex items-center gap-2 rounded-t-xl px-5 py-3 text-sm font-semibold transition-all cursor-pointer"
|
||||
:class="activeTab === 'planned'
|
||||
? 'bg-white text-sf-accent shadow-sm border border-b-0 border-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'"
|
||||
>
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{{ t('serviceFinder.plannedTabTitle') }}
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'sos'"
|
||||
class="inline-flex items-center gap-2 rounded-t-xl px-5 py-3 text-sm font-semibold transition-all cursor-pointer"
|
||||
:class="activeTab === 'sos'
|
||||
? 'bg-white text-red-600 shadow-sm border border-b-0 border-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'"
|
||||
>
|
||||
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
{{ t('serviceFinder.sosTabTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab Content ── -->
|
||||
|
||||
<!-- ── Tab: Planned Search (existing search UI) ── -->
|
||||
<template v-if="activeTab === 'planned'">
|
||||
<!-- ── Results Section ── -->
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<!-- Results header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800">
|
||||
{{ hasSearched ? t('serviceFinder.resultsTitle', { count: totalResults }) : t('serviceFinder.startSearching') }}
|
||||
</h2>
|
||||
<p v-if="hasSearched" class="text-sm text-slate-500">
|
||||
{{ totalResults === 0 ? t('serviceFinder.noResults') : t('serviceFinder.pageInfo', { current: currentPage, total: totalPages }) }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Back to Dashboard -->
|
||||
<button
|
||||
@click="goBack"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition-all hover:bg-slate-50 hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
{{ t('serviceFinder.backToDashboard') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<div
|
||||
v-if="isSearching"
|
||||
class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="n in 6"
|
||||
:key="n"
|
||||
class="animate-pulse rounded-2xl bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div class="mb-3 h-5 w-3/4 rounded bg-slate-200" />
|
||||
<div class="mb-2 h-4 w-1/2 rounded bg-slate-200" />
|
||||
<div class="mb-4 h-4 w-2/3 rounded bg-slate-200" />
|
||||
<div class="flex gap-2">
|
||||
<div class="h-6 w-16 rounded-full bg-slate-200" />
|
||||
<div class="h-6 w-20 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="hasSearched && !isSearching && results.length === 0"
|
||||
class="flex flex-col items-center justify-center rounded-2xl bg-white py-20 shadow-sm"
|
||||
>
|
||||
<span class="mb-4 text-6xl">🔍</span>
|
||||
<h3 class="mb-2 text-xl font-bold text-slate-700">{{ t('serviceFinder.noResultsTitle') }}</h3>
|
||||
<p class="mb-6 max-w-md text-center text-slate-500">
|
||||
{{ t('serviceFinder.noResultsHint') }}
|
||||
</p>
|
||||
<button
|
||||
@click="searchQuery = ''; searchCity = ''; handleSearch()"
|
||||
class="rounded-xl bg-sf-accent/10 px-6 py-3 text-sm font-semibold text-sf-accent transition-all hover:bg-sf-accent/20 cursor-pointer"
|
||||
>
|
||||
{{ t('serviceFinder.showAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results Grid (clickable cards) -->
|
||||
<div
|
||||
v-if="!isSearching && results.length > 0"
|
||||
class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="provider in results"
|
||||
:key="`${provider.source}-${provider.id}`"
|
||||
class="group relative flex flex-col overflow-hidden rounded-2xl bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl cursor-pointer"
|
||||
@click="openDetail(provider)"
|
||||
>
|
||||
<!-- Source badge (top-right corner) -->
|
||||
<div class="absolute right-3 top-3 z-10">
|
||||
<span
|
||||
v-if="provider.source === 'verified_org'"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 shadow-sm"
|
||||
:title="t('serviceFinder.sourceVerifiedTitle')"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ t('serviceFinder.sourceVerified') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="provider.source === 'crowd_added'"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-semibold text-amber-700 shadow-sm"
|
||||
:title="t('serviceFinder.sourceCommunityTitle')"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
|
||||
</svg>
|
||||
{{ t('serviceFinder.sourceCommunity') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-600 shadow-sm"
|
||||
:title="t('serviceFinder.sourceRobotTitle')"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ t('serviceFinder.sourceRobot') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card body -->
|
||||
<div class="flex flex-1 flex-col p-5">
|
||||
<!-- Company name -->
|
||||
<h3 class="mb-2 text-lg font-bold text-slate-800 group-hover:text-sf-accent transition-colors">
|
||||
{{ provider.name }}
|
||||
</h3>
|
||||
|
||||
<!-- Address (P1 CRITICAL ALIGN: atomizált címmezőkből építkezünk) -->
|
||||
<div class="mb-3 flex items-start gap-2 text-sm text-slate-500">
|
||||
<svg class="mt-0.5 h-4 w-4 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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
{{ formatCardAddress(provider) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Specialization chips / tags -->
|
||||
<div
|
||||
v-if="provider.specialization && provider.specialization.length > 0"
|
||||
class="mb-4 flex flex-wrap gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-for="(tag, idx) in provider.specialization.slice(0, 5)"
|
||||
:key="idx"
|
||||
class="inline-flex items-center rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
<span
|
||||
v-if="provider.specialization.length > 5"
|
||||
class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-500"
|
||||
>
|
||||
+{{ provider.specialization.length - 5 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Category badge (fő szolgáltatás - mindig megjelenik) -->
|
||||
<div class="mb-3">
|
||||
<span
|
||||
v-if="provider.category"
|
||||
class="inline-flex items-center rounded-md bg-sf-accent/10 px-2.5 py-1 text-xs font-semibold text-sf-accent"
|
||||
>
|
||||
{{ provider.category }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center rounded-md bg-slate-50 px-2.5 py-1 text-xs text-slate-300 border border-dashed border-slate-200"
|
||||
>
|
||||
{{ t('serviceFinder.noCategory') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
<div
|
||||
v-if="provider.rating"
|
||||
class="mb-3 flex items-center gap-1"
|
||||
>
|
||||
<svg
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="h-4 w-4"
|
||||
:class="star <= Math.round(provider.rating!) ? 'text-amber-400' : 'text-slate-200'"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span class="ml-1 text-xs font-medium text-slate-500">{{ provider.rating.toFixed(1) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Spacer to push footer down -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Card footer: source indicator -->
|
||||
<div class="flex items-center gap-2 border-t border-slate-100 pt-3 text-xs text-slate-400">
|
||||
<span
|
||||
class="inline-flex h-2 w-2 rounded-full"
|
||||
:class="{
|
||||
'bg-emerald-500': provider.source === 'verified_org',
|
||||
'bg-amber-500': provider.source === 'crowd_added',
|
||||
'bg-slate-400': provider.source === 'staged_data',
|
||||
}"
|
||||
/>
|
||||
{{ provider.source === 'verified_org' ? t('serviceFinder.sourceVerifiedLabel') : provider.source === 'crowd_added' ? t('serviceFinder.sourceCommunityLabel') : t('serviceFinder.sourceRobotLabel') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="totalPages > 1 && !isSearching"
|
||||
class="mt-8 flex items-center justify-center gap-2"
|
||||
>
|
||||
<button
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
class="inline-flex items-center gap-1 rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition-all hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ t('serviceFinder.previous') }}
|
||||
</button>
|
||||
|
||||
<span class="rounded-xl bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">
|
||||
{{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
class="inline-flex items-center gap-1 rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition-all hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{{ t('serviceFinder.next') }}
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template><!-- end planned tab -->
|
||||
|
||||
<!-- ── Tab: SOS Rescue (placeholder) ── -->
|
||||
<template v-if="activeTab === 'sos'">
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col items-center justify-center rounded-2xl bg-white py-20 shadow-sm">
|
||||
<span class="mb-4 text-6xl">🚧</span>
|
||||
<h3 class="mb-2 text-xl font-bold text-slate-700">{{ t('serviceFinder.sosTabTitle') }}</h3>
|
||||
<p class="mb-6 max-w-md text-center text-slate-500">
|
||||
{{ t('serviceFinder.sosPlaceholder') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template><!-- end sos tab -->
|
||||
|
||||
<!-- ── Provider Detail Modal ── -->
|
||||
<ProviderDetailModal
|
||||
:is-open="detailModalOpen"
|
||||
:provider="selectedProvider"
|
||||
@close="detailModalOpen = false"
|
||||
@edit-request="handleEditRequest"
|
||||
/>
|
||||
|
||||
<!-- ── Provider Edit Modal ── -->
|
||||
<ProviderEditModal
|
||||
:is-open="editModalOpen"
|
||||
:provider="selectedProvider"
|
||||
@close="editModalOpen = false"
|
||||
@saved="handleEditSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ServiceFinderView.vue — Dedikált, teljes képernyős Szervizkereső Oldal.
|
||||
*
|
||||
* Két beviteli mező: szolgáltatás/szolgáltató (searchQuery) + település (searchCity).
|
||||
* A kártyák kattinthatóak, megnyitják a ProviderDetailModal-t.
|
||||
* Minden szöveg i18n-ből jön.
|
||||
*
|
||||
* @see backend/app/api/v1/endpoints/providers.py — search endpoint
|
||||
* @see backend/app/schemas/provider.py — ProviderSearchResult schema
|
||||
* @see backend/app/services/provider_service.py — search_providers() logic
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/axios'
|
||||
import ProviderDetailModal from '@/components/provider/ProviderDetailModal.vue'
|
||||
import ProviderEditModal from '@/components/provider/ProviderEditModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Types matching backend ProviderSearchResult ──
|
||||
// @see backend/app/schemas/provider.py:21-33
|
||||
interface ProviderSearchResult {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
specialization: string[]
|
||||
city: string | null
|
||||
address: string | null
|
||||
source: 'verified_org' | 'staged_data' | 'crowd_added'
|
||||
is_verified: boolean
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
interface ProviderSearchResponse {
|
||||
results: ProviderSearchResult[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
// ── Quick filter chips (labels from i18n, computed for reactivity) ──
|
||||
const quickFilters = computed(() => [
|
||||
{ key: 'auto_szerelo', label: t('serviceFinder.filterAutoSzerelo') },
|
||||
{ key: 'gumiszerviz', label: t('serviceFinder.filterGumiszerviz') },
|
||||
{ key: 'karosszerialakatos', label: t('serviceFinder.filterKarosszeria') },
|
||||
{ key: 'autómentő', label: t('serviceFinder.filterAutoMento') },
|
||||
{ key: 'benzinkút', label: t('serviceFinder.filterBenzinkut') },
|
||||
{ key: 'autókozmetika', label: t('serviceFinder.filterAutoKozmetika') },
|
||||
])
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// ── Active Tab (from route.query.mode or default 'planned') ──
|
||||
const activeTab = ref<'planned' | 'sos'>('planned')
|
||||
|
||||
// ── Reactive state ──
|
||||
const searchQuery = ref('')
|
||||
const searchCity = ref('')
|
||||
const results = ref<ProviderSearchResult[]>([])
|
||||
const totalResults = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(20)
|
||||
const isSearching = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
|
||||
// ── Detail Modal State ──
|
||||
const detailModalOpen = ref(false)
|
||||
const editModalOpen = ref(false)
|
||||
const selectedProvider = ref<ProviderSearchResult | null>(null)
|
||||
|
||||
// ── Computed ──
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / perPage.value)))
|
||||
|
||||
// ── Initialize tab from route query ──
|
||||
onMounted(() => {
|
||||
const mode = route.query.mode
|
||||
if (mode === 'sos') {
|
||||
activeTab.value = 'sos'
|
||||
} else {
|
||||
activeTab.value = 'planned'
|
||||
}
|
||||
})
|
||||
|
||||
// ── Search logic (passes both q and city) ──
|
||||
async function handleSearch() {
|
||||
isSearching.value = true
|
||||
hasSearched.value = true
|
||||
currentPage.value = 1
|
||||
|
||||
try {
|
||||
const offset = 0
|
||||
const params: Record<string, any> = {
|
||||
limit: perPage.value,
|
||||
offset,
|
||||
}
|
||||
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
|
||||
if (searchCity.value.trim()) params.city = searchCity.value.trim()
|
||||
|
||||
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
|
||||
results.value = res.data.results || []
|
||||
totalResults.value = res.data.total || 0
|
||||
} catch (err: any) {
|
||||
console.error('[ServiceFinderView] Search error:', err)
|
||||
results.value = []
|
||||
totalResults.value = 0
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quick filter click ──
|
||||
function applyQuickFilter(key: string) {
|
||||
searchQuery.value = key
|
||||
// NE töröljük a várost! A chip csak a kategóriát állítsa be.
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// ── Pagination ──
|
||||
async function goToPage(page: number) {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
currentPage.value = page
|
||||
isSearching.value = true
|
||||
|
||||
try {
|
||||
const offset = (page - 1) * perPage.value
|
||||
const params: Record<string, any> = {
|
||||
limit: perPage.value,
|
||||
offset,
|
||||
}
|
||||
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
|
||||
if (searchCity.value.trim()) params.city = searchCity.value.trim()
|
||||
|
||||
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
|
||||
results.value = res.data.results || []
|
||||
totalResults.value = res.data.total || 0
|
||||
} catch (err: any) {
|
||||
console.error('[ServiceFinderView] Pagination error:', err)
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail Modal ──
|
||||
function openDetail(provider: ProviderSearchResult) {
|
||||
selectedProvider.value = provider
|
||||
detailModalOpen.value = true
|
||||
}
|
||||
|
||||
function handleEditRequest(provider: ProviderSearchResult) {
|
||||
// FEATURE (2026-06-17): Detail modal "Adatok kiegészítése" gomb → Edit modal
|
||||
detailModalOpen.value = false
|
||||
selectedProvider.value = provider
|
||||
editModalOpen.value = true
|
||||
console.log('[ServiceFinderView] Edit requested for:', provider.name)
|
||||
}
|
||||
|
||||
function handleEditSaved(payload: { id: number }) {
|
||||
// FEATURE (2026-06-17): Az Edit modal @saved eseménye.
|
||||
// A backend PUT /providers/{id} már lefutott, itt frissítjük a listát.
|
||||
console.log('[ServiceFinderView] Provider saved, id:', payload.id)
|
||||
editModalOpen.value = false
|
||||
// Re-run search to reflect changes
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
/**
|
||||
* P1 CRITICAL ALIGN (2026-06-17): Kártyán megjelenő cím formázása
|
||||
* az atomizált címmezőkből. Metódus, mert a v-for ciklusban hívjuk
|
||||
* provider-enként, nem computed property.
|
||||
*
|
||||
* Formátum: "irányítószám Város, utca típus házszám"
|
||||
* Ha nincs atomizált adat, visszaesik a backend által összefűzött address-re.
|
||||
*/
|
||||
function formatCardAddress(p: ProviderSearchResult): string {
|
||||
if (!p) return ''
|
||||
|
||||
// Atomizált címmezőkből építkezünk
|
||||
const parts: string[] = []
|
||||
if (p.address_zip) parts.push(p.address_zip)
|
||||
if (p.city) parts.push(p.city)
|
||||
|
||||
const streetParts: string[] = []
|
||||
if (p.address_street_name) streetParts.push(p.address_street_name)
|
||||
if (p.address_street_type) streetParts.push(p.address_street_type)
|
||||
if (p.address_house_number) streetParts.push(p.address_house_number)
|
||||
if (streetParts.length > 0) parts.push(streetParts.join(' '))
|
||||
|
||||
if (parts.length > 0) return parts.join(', ')
|
||||
|
||||
// Fallback: backend által összefűzött address
|
||||
return p.address || t('serviceFinder.addressNotAvailable')
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
function goBack() {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Smooth fade-in animation for cards */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.grid > div {
|
||||
animation: fadeInUp 0.3s ease-out forwards;
|
||||
}
|
||||
.grid > div:nth-child(2) { animation-delay: 0.05s; }
|
||||
.grid > div:nth-child(3) { animation-delay: 0.1s; }
|
||||
.grid > div:nth-child(4) { animation-delay: 0.15s; }
|
||||
.grid > div:nth-child(5) { animation-delay: 0.2s; }
|
||||
.grid > div:nth-child(6) { animation-delay: 0.25s; }
|
||||
</style>
|
||||
271
plans/logic_spec_provider_taxonomy_search.md
Normal file
271
plans/logic_spec_provider_taxonomy_search.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# 🏗️ Logic Spec: Provider Taxonomy Audit & Smart Search API
|
||||
|
||||
## 1. Modul Célja és Masterbook 2.0 Illeszkedés
|
||||
|
||||
**Mérföldkő:** Crowdsourced Partnerkereső (Marketplace Engine)
|
||||
**Cél:** A szolgáltatói kategóriarendszer (taxonómia) feltöltése és egy okos, egyesített kereső API (search + quick-add) megvalósítása a crowdsourced partnerkereséshez.
|
||||
|
||||
### Masterbook 2.0 Kapcsolódás
|
||||
- **Epic:** Marketplace & Service Discovery (08_Marketplace)
|
||||
- **Robot Kapcsolat:** A meglévő `service_staging` adatok (robotok által gyűjtött) publikusan kereshetővé tétele
|
||||
- **Üzleti Logika:** Bárki (még nem verifikált) gyorsan felvehet egy szolgáltatót, ami automatikusan a `service_staging` táblába kerül, ahol a robotok később feldolgozhatják
|
||||
|
||||
## 2. Adatbázis Audit Eredmények
|
||||
|
||||
### Meglévő Táblák Állapota
|
||||
|
||||
| Tábla | Séma | Állapot | Rekordok |
|
||||
|-------|------|---------|----------|
|
||||
| `expertise_tags` | `marketplace` | **ÜRES** - nincs egyetlen kategória sem | 0 |
|
||||
| `service_specialties` | `marketplace` | **ÜRES** - egyszerű (id, parent_id, name, slug) | 0 |
|
||||
| `service_providers` | `marketplace` | **ÜRES** - crowdsourced provider tábla | 0 |
|
||||
| `organizations` (org_type='service_provider') | `fleet` | **NINCS** ilyen rekord | 0 |
|
||||
| `service_staging` | `marketplace` | Van benne ~2000+ rekord (robot gyűjtés) | ~2000 |
|
||||
|
||||
### Használandó Meglévő Struktúrák
|
||||
|
||||
1. **`marketplace.expertise_tags`** - A kategória mestertábla (twin: name_hu/name_en, search_keywords JSONB). Ez a legalkalmasabb.
|
||||
2. **`fleet.organizations.aliases`** - JSONB mező a szolgáltató alternatív neveihez (már létezik!)
|
||||
3. **`fleet.organizations.tags`** - JSONB mező a crowdsourced címkékhez (már létezik!)
|
||||
4. **`marketplace.service_staging`** - Robotok által gyűjtött adatok (már létezik adatokkal)
|
||||
5. **`marketplace.service_providers`** - Crowdsourced provider tábla (üres, de használható)
|
||||
|
||||
## 3. Technikai Terv
|
||||
|
||||
### 3.1 Seed Script: `backend/app/scripts/seed_expertise_tags.py`
|
||||
|
||||
**Fájl:** `backend/app/scripts/seed_expertise_tags.py`
|
||||
|
||||
Feltölti az `expertise_tags` táblát az alábbi alapkategóriákkal:
|
||||
|
||||
| key | name_hu | name_en | category | search_keywords |
|
||||
|-----|---------|---------|----------|-----------------|
|
||||
| auto_szerelo | Autószerelő | Car Mechanic | vehicle_service | ["szerelő", "autószerelő", "mechanic", "garage"] |
|
||||
| motor_szerelo | Motorkerékpár szerelő | Motorcycle Mechanic | vehicle_service | ["motor", "motorszerelő", "motorcycle"] |
|
||||
| gumiszerviz | Gumiszerviz | Tire Service | vehicle_service | ["gumi", "gumis", "tire", "tyre", "abroncs"] |
|
||||
| karosszerialakatos | Karosszérialakatos | Body Shop | body_paint | ["karosszéria", "lakatos", "bodyshop"] |
|
||||
| fényező | Fényező | Painter | body_paint | ["fényező", "fényezés", "painter", "spray"] |
|
||||
| autómentő | Autómentő / Motormentő | Tow Truck / Roadside | roadside | ["mentő", "autómentő", "tow", "roadside"] |
|
||||
| benzinkút | Benzinkút | Gas Station | fuel | ["benzin", "kút", "gas station", "fuel"] |
|
||||
| alkatrész_kereskedés | Alkatrész kereskedés | Parts Store | parts | ["alkatrész", "parts", "spares"] |
|
||||
| vizsgaállomás | Vizsgaállomás | Inspection Station | inspection | ["vizsga", "műszaki", "inspection", "MOT"] |
|
||||
| autókozmetika | Autókozmetika | Car Detailing | detailing | ["kozmetika", "detailing", "takarítás"] |
|
||||
| egyéb | Egyéb szolgáltató | Other Service | other | ["egyéb", "other", "szolgáltató"] |
|
||||
|
||||
**Futtatás:** `docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_tags.py`
|
||||
|
||||
### 3.2 API Végpont: `GET /api/v1/providers/search`
|
||||
|
||||
**Fájl:** `backend/app/api/v1/endpoints/providers.py`
|
||||
|
||||
#### Végpont Specifikáció
|
||||
|
||||
```
|
||||
GET /api/v1/providers/search?q={keresőszó}&category={kategória}&city={város}&limit={limit}&offset={offset}
|
||||
```
|
||||
|
||||
#### Keresés Logikája (három tábla egyesítése)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[GET /providers/search?q=kereső] --> B{Ellenőrizd a q paramétert}
|
||||
B -->|Van| C[Három tábla egyesített keresése]
|
||||
B -->|Nincs| D[Kategória/Város szűrés, ha van]
|
||||
|
||||
C --> E1[1. fleet.organizations]
|
||||
C --> E2[2. marketplace.service_staging]
|
||||
C --> E3[3. marketplace.service_providers]
|
||||
|
||||
E1 --> F1{org_type = service_provider}
|
||||
F1 --> G1[ILIKE a name-re]
|
||||
F1 --> G2[ILIKE az aliases JSONB-ben]
|
||||
|
||||
E2 --> G3[ILIKE a name-re]
|
||||
E2 --> G4[ILIKE a city-re]
|
||||
|
||||
E3 --> G5[ILIKE a name-re]
|
||||
|
||||
D --> H[Közös UNION / egységesítés]
|
||||
H --> I[Forrás jelölés: verified_org / staged_data / crowd_added]
|
||||
I --> J[Rendezes + lapozás]
|
||||
J --> K[Response]
|
||||
```
|
||||
|
||||
#### Response Schema (ProviderSearchResult)
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "MOL Autószerviz",
|
||||
"category": null,
|
||||
"specialization": ["autószerelő", "gumiszerviz"],
|
||||
"city": "Budapest",
|
||||
"address": "Budapest, Kossuth u. 1",
|
||||
"source": "verified_org",
|
||||
"is_verified": true,
|
||||
"rating": 4.5
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"page": 1,
|
||||
"per_page": 20
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 API Végpont: `POST /api/v1/providers/quick-add`
|
||||
|
||||
**Fájl:** `backend/app/api/v1/endpoints/providers.py`
|
||||
|
||||
#### Végpont Specifikáció
|
||||
|
||||
```
|
||||
POST /api/v1/providers/quick-add
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Teszt Autószerviz",
|
||||
"category_id": 1,
|
||||
"city": "Budapest",
|
||||
"street": "Kossuth utca 1",
|
||||
"tags": ["olcsó", "gyors"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Folyamat
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[POST /providers/quick-add] --> B[Authentikáció: current_user]
|
||||
B --> C[Validáció: name kötelező]
|
||||
C --> D{Kategória ID ellenőrzése}
|
||||
D -->|Létezik| E[Létrehozás: Organization]
|
||||
D -->|Nem létezik| F[400: Invalid category]
|
||||
|
||||
E --> G[Organization létrehozása]
|
||||
G --> H[org_type = service_provider]
|
||||
H --> I[is_verified = False]
|
||||
I --> J[tags = megadott tömbből]
|
||||
|
||||
J --> K[ServiceProfile létrehozása]
|
||||
K --> L[expertise_tags kapcsolat]
|
||||
|
||||
L --> M[Branch létrehozása a címmel]
|
||||
M --> N[commit]
|
||||
N --> O[Response: quick-add success]
|
||||
```
|
||||
|
||||
#### Request Schema (ProviderQuickAddIn)
|
||||
|
||||
| Mező | Típus | Kötelező | Leírás |
|
||||
|------|-------|----------|--------|
|
||||
| name | string | Igen | Szolgáltató neve (2-200 karakter) |
|
||||
| category_id | int | Igen | Kategória ID (expertise_tags.id) |
|
||||
| city | string | Nem | Város |
|
||||
| street | string | Nem | Utca/házszám |
|
||||
| tags | string[] | Nem | Címkék (pl. ["olcsó", "gyors"]) |
|
||||
|
||||
#### Response Schema (ProviderQuickAddResponse)
|
||||
|
||||
| Mező | Típus | Leírás |
|
||||
|------|-------|--------|
|
||||
| id | int | Az új szervezet ID-ja |
|
||||
| name | string | A szolgáltató neve |
|
||||
| status | string | "pending_verification" |
|
||||
| message | string | Sikeres üzenet |
|
||||
|
||||
### 3.4 Router Regisztráció
|
||||
|
||||
A `providers.py`-t be kell regisztrálni az `api.py`-ban:
|
||||
|
||||
```python
|
||||
from app.api.v1.endpoints import providers
|
||||
|
||||
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
||||
```
|
||||
|
||||
### 3.5 Szükséges Schémák
|
||||
|
||||
**Fájl:** `backend/app/schemas/provider.py` (ÚJ)
|
||||
|
||||
```python
|
||||
class ProviderSearchResult(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
category: Optional[str] = None
|
||||
specialization: List[str] = []
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
source: str # "verified_org" | "staged_data" | "crowd_added"
|
||||
is_verified: bool = False
|
||||
rating: Optional[float] = None
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class ProviderSearchResponse(BaseModel):
|
||||
results: List[ProviderSearchResult]
|
||||
total: int
|
||||
page: int = 1
|
||||
per_page: int = 20
|
||||
|
||||
class ProviderQuickAddIn(BaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=200)
|
||||
category_id: int = Field(..., ge=1)
|
||||
city: Optional[str] = Field(None, max_length=100)
|
||||
street: Optional[str] = Field(None, max_length=255)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
class ProviderQuickAddResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
status: str = "pending_verification"
|
||||
message: str = "Szolgáltató sikeresen rögzítve. Ellenőrzés alatt."
|
||||
```
|
||||
|
||||
### 3.6 Service Réteg
|
||||
|
||||
**Fájl:** `backend/app/services/provider_service.py` (ÚJ)
|
||||
|
||||
A service réteg tartalmazza:
|
||||
- `search_providers(db, q, category, city, limit, offset)` - Egyesített keresés
|
||||
- `quick_add_provider(db, data, user_id)` - Gyors szolgáltató felvétel
|
||||
|
||||
## 4. Adatmodell Változások
|
||||
|
||||
### 4.1 NINCS új modell vagy migráció!
|
||||
A meglévő modellek és adatbázis táblák TELJESEN lefedik a funkcionalitást:
|
||||
- `marketplace.expertise_tags` - már létezik, csak adat kell
|
||||
- `fleet.organizations` - már van `aliases`, `tags`, `org_type` mező
|
||||
- `marketplace.service_staging` - már létezik adatokkal
|
||||
- `marketplace.service_providers` - már létezik (üres)
|
||||
|
||||
### 4.2 Csak Seed Adat Kell
|
||||
A `seed_expertise_tags.py` INSERT utasításokkal tölti fel az `expertise_tags` táblát.
|
||||
|
||||
## 5. Megvalósítási Terv
|
||||
|
||||
### 5.1 Végrehajtandó Lépések
|
||||
|
||||
| # | Lépés | Fájl | Leírás |
|
||||
|---|-------|------|--------|
|
||||
| 1 | Seed script | `backend/app/scripts/seed_expertise_tags.py` | Feltölti a 11 alapkategóriát |
|
||||
| 2 | Seed futtatás | Terminál | `docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_tags.py` |
|
||||
| 3 | Pydantic schémák | `backend/app/schemas/provider.py` (ÚJ) | ProviderSearchResult, ProviderQuickAddIn |
|
||||
| 4 | Service réteg | `backend/app/services/provider_service.py` (ÚJ) | search_providers, quick_add_provider |
|
||||
| 5 | Endpointok | `backend/app/api/v1/endpoints/providers.py` | GET /search, POST /quick-add |
|
||||
| 6 | Router regisztráció | `backend/app/api/v1/api.py` | providers router hozzáadása |
|
||||
| 7 | Ellenőrző teszt | Terminál | API hívások tesztelése |
|
||||
|
||||
### 5.2 Függőségek
|
||||
- **Bemenet:** Meglévő modell réteg (Organization, ServiceStaging, ServiceProvider, ExpertiseTag)
|
||||
- **Kimenet:** A `/api/v1/providers/search` és `/api/v1/providers/quick-add` végpontok
|
||||
- **Blokkoló:** Nincs - minden meglévő kód használható
|
||||
|
||||
## 6. Admin Kontroll
|
||||
|
||||
A Seed script FUTTATHATÓ többször is (idempotens) - `ON CONFLICT DO NOTHING` klauzulával.
|
||||
|
||||
A kategóriák később az Admin felületen keresztül módosíthatók/bővíthetők.
|
||||
|
||||
## 7. Geo-Logika
|
||||
A keresés jelenleg NEM tartalmaz PostGIS távolságszűrést (az külön endpoint: `/search/match`). A `/providers/search` csak szöveges keresés (ILIKE).
|
||||
123
plans/logic_spec_provider_update_fix.md
Normal file
123
plans/logic_spec_provider_update_fix.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 🔧 Logic Spec: Provider Update & Search Fix Csomag
|
||||
|
||||
## 🎯 Cél
|
||||
A szolgáltató cégek adatainak rögzítésében és szerkesztésében fellépő hibák javítása, valamint az adatok részletes rögzíthetőségének biztosítása.
|
||||
|
||||
## 📋 Problémák Összefoglalása
|
||||
|
||||
### 1. PUT /api/v1/providers/{id} → 404 Not Found (✅ MEGOLDVA)
|
||||
- **Root Cause**: A `sf_api` konténer nem volt újraindítva a providers modul kódfrissítése után. A `pre_start.sh` fájlban `uvicorn` `--reload` flag nélkül fut.
|
||||
- **Javítás**: `docker compose restart sf_api` (végrehajtva)
|
||||
- **Verifikáció**: PUT /providers/58 → 200 OK
|
||||
|
||||
### 2. GET /api/v1/providers/search → 500 Internal Server Error (❌ JAVÍTANDÓ)
|
||||
- **Hiba**: `AttributeError: Neither 'BinaryExpression' object nor 'Comparator' object has an attribute 'astext'`
|
||||
- **Hibás kód**: [`provider_service.py:207`](../backend/app/services/provider_service.py:207)
|
||||
```python
|
||||
(Organization.external_integration_config["source"].astext == "crowdsourced", literal("crowd_added")),
|
||||
```
|
||||
- **Root Cause**: Az [`external_integration_config`](../backend/app/models/marketplace/organization.py:109) `JSON` típusú oszlop. A `["source"]` subscript `BinaryExpression`-t ad vissza, amelyen NINCS `.astext`.
|
||||
- **Javítás**: `cast` használata:
|
||||
```python
|
||||
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
|
||||
```
|
||||
|
||||
### 3. Adat-healing: Régi címformátumú Organization rekordok (❌ JAVÍTANDÓ)
|
||||
- **Probléma**: Az Organization id=58 (`Autónyíri Kft.`) adatai a régi formátumban:
|
||||
- `street_name = "Egressy u. 4."` (nem atomizált)
|
||||
- `address_street_name = NULL`, `address_street_type = NULL`, `address_house_number = NULL`
|
||||
- `zip = NULL`
|
||||
- **Következmény**: A frontend DetailModal üres címet mutat.
|
||||
- **Javítás**: Adat-healing script a `street_name` mezőből atomizált komponensek kinyerésére.
|
||||
|
||||
### 4. Multi-source Update Probléma (❌ JAVÍTANDÓ)
|
||||
- **Probléma**: Az [`update_provider`](../backend/app/services/provider_service.py:477) CSAK `Organization`-ben keres. A search UNION-nal dolgozza fel a szolgáltatókat 3 forrásból (Organization, ServiceStaging, ServiceProvider).
|
||||
- **Javítás**: Multi-source update logika.
|
||||
|
||||
### 5. Szerver Restart Workflow Hiánya (❌ JAVÍTANDÓ)
|
||||
- **Probléma**: A [`pre_start.sh`](../backend/app/scripts/pre_start.sh) `--reload` nélkül indul.
|
||||
- **Javítás**: `--reload` flag fejlesztői módban.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Érintett Fájlok
|
||||
|
||||
| Fájl | Változtatás | Prioritás |
|
||||
|------|-------------|-----------|
|
||||
| [`provider_service.py:207`](../backend/app/services/provider_service.py:207) | `.astext` → `cast()` | **KRITIKUS** |
|
||||
| [`provider_service.py:477-562`](../backend/app/services/provider_service.py:477) | Multi-source update | **MAGAS** |
|
||||
| Új: `heal_provider_addresses.py` | Adat-healing script | **MAGAS** |
|
||||
| [`pre_start.sh`](../backend/app/scripts/pre_start.sh) | `--reload` dev módban | **ALACSONY** |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Részletes Javítási Terv
|
||||
|
||||
### 1. `.astext` → `cast()` javítás
|
||||
|
||||
**Fájl**: [`provider_service.py`](../backend/app/services/provider_service.py:207)
|
||||
|
||||
**Jelenlegi:**
|
||||
```python
|
||||
(Organization.external_integration_config["source"].astext == "crowdsourced", literal("crowd_added")),
|
||||
```
|
||||
|
||||
**Javított:**
|
||||
```python
|
||||
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
|
||||
```
|
||||
|
||||
### 2. Multi-source Update Logika
|
||||
|
||||
**Fájl**: [`provider_service.py:477`](../backend/app/services/provider_service.py:477)
|
||||
|
||||
Az `update_provider` ellenőrizze mindhárom forrást:
|
||||
1. `db.get(Organization, provider_id)` → meglévő logika
|
||||
2. `db.get(ServiceStaging, provider_id)` → migrálás Organization-be
|
||||
3. `db.get(ServiceProvider, provider_id)` → migrálás Organization-be
|
||||
4. Ha egyikben sem → ValueError
|
||||
|
||||
### 3. Adat-healing Script
|
||||
|
||||
**Fájl**: `backend/app/scripts/heal_provider_addresses.py`
|
||||
|
||||
1. Lekérdezni Organization rekordokat, ahol `org_type='service_provider'` ÉS `address_street_name IS NULL` ÉS `street_name IS NOT NULL`
|
||||
2. Regex: `r'^([^\d]+?)\s+(u\.|utca|út|tér|köz|sor|körút|liget|part|fasor|sétány|park|híd|sugárút|rakpart|dűlő|telep|szőlő)\s*(.*)$'`
|
||||
3. Frissíteni az atomizált mezőket
|
||||
4. Naplózás
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tesztelési Terv
|
||||
|
||||
### Teszt 1: search működés
|
||||
```bash
|
||||
docker compose exec sf_api python3 -c "
|
||||
import httpx, asyncio
|
||||
async def t():
|
||||
async with httpx.AsyncClient(base_url='http://localhost:8000') as c:
|
||||
r = await c.post('/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'})
|
||||
t = r.json()['access_token']
|
||||
r2 = await c.get('/api/v1/providers/search', params={'q': 'Dunakeszi', 'limit': 5}, headers={'Authorization': f'Bearer {t}'})
|
||||
print(f'Search: {r2.status_code}')
|
||||
print(r2.text[:500])
|
||||
asyncio.run(t())
|
||||
"
|
||||
```
|
||||
|
||||
### Teszt 2: PUT működés
|
||||
```bash
|
||||
docker compose exec sf_api python3 -c "
|
||||
import httpx, asyncio
|
||||
async def t():
|
||||
async with httpx.AsyncClient(base_url='http://localhost:8000') as c:
|
||||
r = await c.post('/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'})
|
||||
t = r.json()['access_token']
|
||||
r2 = await c.put('/api/v1/providers/58',
|
||||
json={'address_zip': '2120', 'city': 'Dunakeszi', 'name': 'Autónyíri Kft.'},
|
||||
headers={'Authorization': f'Bearer {t}'})
|
||||
print(f'PUT: {r2.status_code}')
|
||||
print(r2.text)
|
||||
asyncio.run(t())
|
||||
"
|
||||
```
|
||||
341
tests/active/test_bidirectional_sync.py
Normal file
341
tests/active/test_bidirectional_sync.py
Normal file
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bidirectional Sync Verification Test
|
||||
Tests both directions of the Service Book ↔ Costs integration:
|
||||
1. Event→Cost: POST /events with cost_amount creates linked AssetCost
|
||||
2. Cost→Event: POST /expenses/ with MAINTENANCE category creates linked AssetEvent
|
||||
3. Transaction integrity: single commit for paired records
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
|
||||
async def login():
|
||||
"""Login as admin user."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
r = await client.post("/api/v1/auth/login", json={
|
||||
"email": "admin@profibot.hu",
|
||||
"password": "Admin123!"
|
||||
})
|
||||
if r.status_code != 200:
|
||||
print(f"❌ Login failed: {r.status_code} {r.text}")
|
||||
return None
|
||||
data = r.json()
|
||||
return data.get("access_token") or data.get("token")
|
||||
|
||||
async def test_direction_1_event_to_cost(token, asset_id):
|
||||
"""
|
||||
DIRECTION 1: Event → Cost
|
||||
POST /assets/{asset_id}/events with cost_amount > 0
|
||||
Should auto-create AssetCost and link via cost_id
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("🧪 DIRECTION 1: Event → Cost (POST /events with cost_amount)")
|
||||
print("=" * 70)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
# Create a SERVICE event with cost_amount
|
||||
r = await client.post(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": "SERVICE",
|
||||
"description": "BIDIRECTIONAL TEST - Event→Cost direction",
|
||||
"odometer_reading": 25000,
|
||||
"cost_amount": 45000,
|
||||
"currency": "HUF",
|
||||
"event_date": "2026-06-15T12:00:00Z"
|
||||
}
|
||||
)
|
||||
|
||||
if r.status_code != 201:
|
||||
print(f"❌ FAIL: POST event returned {r.status_code}")
|
||||
print(f" Response: {r.text}")
|
||||
return False
|
||||
|
||||
data = r.json()
|
||||
event_id = data.get("id")
|
||||
cost_id = data.get("cost_id")
|
||||
cost_amount = data.get("cost_amount")
|
||||
currency = data.get("currency")
|
||||
|
||||
print(f" Event ID: {event_id}")
|
||||
print(f" Cost ID: {cost_id}")
|
||||
print(f" Cost amount: {cost_amount}")
|
||||
print(f" Currency: {currency}")
|
||||
|
||||
# Verify cost_id is set (AssetEvent → AssetCost link)
|
||||
if not cost_id:
|
||||
print("❌ FAIL: cost_id is None - AssetEvent not linked to AssetCost")
|
||||
return False
|
||||
|
||||
print(f" ✅ PASS: AssetEvent.cost_id = {cost_id} (linked to AssetCost)")
|
||||
|
||||
# Verify cost_amount is returned
|
||||
if cost_amount != 45000:
|
||||
print(f"❌ FAIL: Expected cost_amount=45000, got {cost_amount}")
|
||||
return False
|
||||
|
||||
print(f" ✅ PASS: cost_amount = {cost_amount} HUF")
|
||||
|
||||
# Verify the linked cost exists in DB
|
||||
r2 = await client.get(
|
||||
f"/api/v1/assets/{asset_id}/costs",
|
||||
headers=headers
|
||||
)
|
||||
if r2.status_code == 200:
|
||||
costs = r2.json()
|
||||
linked_cost = [c for c in costs if str(c.get("id")) == str(cost_id)]
|
||||
if linked_cost:
|
||||
print(f" ✅ PASS: Linked AssetCost found in costs list")
|
||||
print(f" Amount: {linked_cost[0].get('amount_net')} {linked_cost[0].get('currency')}")
|
||||
else:
|
||||
print(f" ⚠️ Linked cost not found in costs list (may be filtered)")
|
||||
|
||||
print(f" ✅ DIRECTION 1 PASSED!")
|
||||
return True
|
||||
|
||||
|
||||
async def test_direction_2_cost_to_event(token, asset_id):
|
||||
"""
|
||||
DIRECTION 2: Cost → Event
|
||||
POST /expenses/ with MAINTENANCE category (category_id=2)
|
||||
Should auto-create AssetEvent linked via cost_id
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("🧪 DIRECTION 2: Cost → Event (POST /expenses/ with MAINTENANCE)")
|
||||
print("=" * 70)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
# Create a MAINTENANCE cost via expenses endpoint
|
||||
r = await client.post(
|
||||
"/api/v1/expenses/",
|
||||
headers=headers,
|
||||
json={
|
||||
"asset_id": str(asset_id),
|
||||
"category_id": 2, # MAINTENANCE
|
||||
"amount_net": 120000,
|
||||
"currency": "HUF",
|
||||
"date": "2026-06-15T14:00:00Z",
|
||||
"description": "BIDIRECTIONAL TEST - Cost→Event direction",
|
||||
"mileage_at_cost": 26000
|
||||
}
|
||||
)
|
||||
|
||||
if r.status_code != 201:
|
||||
print(f"❌ FAIL: POST expense returned {r.status_code}")
|
||||
print(f" Response: {r.text}")
|
||||
return False
|
||||
|
||||
data = r.json()
|
||||
cost_id = data.get("id")
|
||||
event_id = data.get("event_id")
|
||||
|
||||
print(f" Cost ID: {cost_id}")
|
||||
print(f" Auto-created Event ID: {event_id}")
|
||||
|
||||
# Verify event_id is set (expense endpoint auto-created AssetEvent)
|
||||
if not event_id:
|
||||
print("❌ FAIL: event_id is None - AssetEvent was NOT auto-created for MAINTENANCE cost")
|
||||
return False
|
||||
|
||||
print(f" ✅ PASS: AssetEvent auto-created with id={event_id}")
|
||||
|
||||
# Verify the linked event exists
|
||||
r2 = await client.get(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers
|
||||
)
|
||||
if r2.status_code == 200:
|
||||
events = r2.json()
|
||||
linked_event = [e for e in events if str(e.get("id")) == str(event_id)]
|
||||
if linked_event:
|
||||
ev = linked_event[0]
|
||||
print(f" ✅ PASS: Auto-created AssetEvent found in events list")
|
||||
print(f" Event type: {ev.get('event_type')}")
|
||||
print(f" Cost ID: {ev.get('cost_id')}")
|
||||
print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}")
|
||||
|
||||
# Verify the event is linked back to the cost
|
||||
if str(ev.get("cost_id")) == str(cost_id):
|
||||
print(f" ✅ PASS: Bidirectional link verified (cost_id matches)")
|
||||
else:
|
||||
print(f" ❌ FAIL: cost_id mismatch - event.cost_id={ev.get('cost_id')} != cost.id={cost_id}")
|
||||
return False
|
||||
else:
|
||||
print(f" ⚠️ Auto-created event not found in events list")
|
||||
|
||||
print(f" ✅ DIRECTION 2 PASSED!")
|
||||
return True
|
||||
|
||||
|
||||
async def test_direction_3_maintenance_endpoint(token, asset_id):
|
||||
"""
|
||||
DIRECTION 3: Maintenance endpoint (POST /assets/{id}/maintenance)
|
||||
Should create both AssetCost and AssetEvent in a single transaction
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("🧪 DIRECTION 3: Maintenance endpoint (POST /{asset_id}/maintenance)")
|
||||
print("=" * 70)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
r = await client.post(
|
||||
f"/api/v1/assets/{asset_id}/maintenance",
|
||||
headers=headers,
|
||||
json={
|
||||
"date": "2026-06-15T16:00:00Z",
|
||||
"odometer": 27000,
|
||||
"description": "BIDIRECTIONAL TEST - Maintenance endpoint",
|
||||
"cost": 85000,
|
||||
"currency": "HUF"
|
||||
}
|
||||
)
|
||||
|
||||
if r.status_code != 201:
|
||||
print(f"❌ FAIL: POST maintenance returned {r.status_code}")
|
||||
print(f" Response: {r.text}")
|
||||
return False
|
||||
|
||||
data = r.json()
|
||||
cost_id = data.get("id")
|
||||
print(f" Cost ID: {cost_id}")
|
||||
print(f" Amount: {data.get('amount_net')} {data.get('currency')}")
|
||||
print(f" ✅ PASS: Maintenance record created")
|
||||
|
||||
# Verify the linked event exists
|
||||
r2 = await client.get(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers
|
||||
)
|
||||
if r2.status_code == 200:
|
||||
events = r2.json()
|
||||
# Find the event linked to this cost
|
||||
linked_event = [e for e in events if str(e.get("cost_id")) == str(cost_id)]
|
||||
if linked_event:
|
||||
ev = linked_event[0]
|
||||
print(f" ✅ PASS: Linked AssetEvent found")
|
||||
print(f" Event ID: {ev.get('id')}")
|
||||
print(f" Event type: {ev.get('event_type')}")
|
||||
print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}")
|
||||
else:
|
||||
print(f" ⚠️ No event linked to this cost (may use different cost_id format)")
|
||||
|
||||
print(f" ✅ DIRECTION 3 PASSED!")
|
||||
return True
|
||||
|
||||
|
||||
async def test_summary_report(token, asset_id):
|
||||
"""
|
||||
Final summary: show all events and costs for the asset
|
||||
"""
|
||||
print("\n" + "=" * 70)
|
||||
print("📊 FINAL SUMMARY REPORT")
|
||||
print("=" * 70)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
# Get all events
|
||||
r1 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers)
|
||||
r2 = await client.get(f"/api/v1/assets/{asset_id}/costs", headers=headers)
|
||||
|
||||
if r1.status_code == 200 and r2.status_code == 200:
|
||||
events = r1.json()
|
||||
costs = r2.json()
|
||||
|
||||
print(f"\n Total Events: {len(events)}")
|
||||
print(f" Total Costs: {len(costs)}")
|
||||
|
||||
# Count linked records
|
||||
linked_events = [e for e in events if e.get("cost_id")]
|
||||
linked_costs = [c for c in costs if c.get("event_id")]
|
||||
|
||||
print(f" Events WITH cost_id link: {len(linked_events)}")
|
||||
print(f" Costs WITH event_id link: {len(linked_costs)}")
|
||||
|
||||
# Show bidirectional test events
|
||||
print(f"\n --- Bidirectional Test Events ---")
|
||||
for ev in events:
|
||||
desc = ev.get("description") or ""
|
||||
if "BIDIRECTIONAL TEST" in desc:
|
||||
print(f" 📋 {ev.get('event_type')}: cost_id={ev.get('cost_id')}, amount={ev.get('cost_amount')} {ev.get('currency')}")
|
||||
|
||||
print(f"\n --- Bidirectional Test Costs ---")
|
||||
for c in costs:
|
||||
data = c.get("data", {})
|
||||
desc = data.get("description", "") if isinstance(data, dict) else ""
|
||||
if "BIDIRECTIONAL TEST" in desc:
|
||||
print(f" 💰 category={c.get('category_id')}, amount={c.get('amount_net')} {c.get('currency')}, event_id={c.get('event_id')}")
|
||||
|
||||
print(f"\n ✅ SUMMARY: Bidirectional sync is working correctly!")
|
||||
return True
|
||||
else:
|
||||
print(f" ❌ Failed to get summary data")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🔄 BIDIRECTIONAL SYNC VERIFICATION TEST")
|
||||
print("=" * 70)
|
||||
|
||||
# Login
|
||||
print("\n--- LOGIN ---")
|
||||
token = await login()
|
||||
if not token:
|
||||
print("❌ Login failed!")
|
||||
return
|
||||
print(f" ✅ Token obtained")
|
||||
|
||||
# Get test-000 vehicle
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
r = await client.get("/api/v1/assets/vehicles", headers=headers)
|
||||
if r.status_code != 200:
|
||||
print(f"❌ Failed to get vehicles: {r.status_code}")
|
||||
return
|
||||
|
||||
vehicles = r.json()
|
||||
test_vehicle = None
|
||||
for v in vehicles:
|
||||
if v.get("license_plate") == "test-000":
|
||||
test_vehicle = v
|
||||
break
|
||||
|
||||
if not test_vehicle:
|
||||
print("❌ test-000 vehicle not found!")
|
||||
return
|
||||
|
||||
asset_id = test_vehicle["id"]
|
||||
print(f" ✅ Using test-000: {asset_id}")
|
||||
print(f" Brand: {test_vehicle.get('brand')} {test_vehicle.get('model')}")
|
||||
|
||||
# Run all direction tests
|
||||
results = []
|
||||
|
||||
results.append(await test_direction_1_event_to_cost(token, asset_id))
|
||||
results.append(await test_direction_2_cost_to_event(token, asset_id))
|
||||
results.append(await test_direction_3_maintenance_endpoint(token, asset_id))
|
||||
|
||||
# Summary
|
||||
await test_summary_report(token, asset_id)
|
||||
|
||||
# Final result
|
||||
print("\n" + "=" * 70)
|
||||
if all(results):
|
||||
print("🎉 ALL BIDIRECTIONAL SYNC TESTS PASSED!")
|
||||
else:
|
||||
print(f"❌ SOME TESTS FAILED: {sum(1 for r in results if not r)}/{len(results)} failed")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
282
tests/active/test_service_book_api.py
Normal file
282
tests/active/test_service_book_api.py
Normal file
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Service Book API E2E Test
|
||||
Tests GET and POST /assets/{asset_id}/events endpoints.
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
from sqlalchemy import select, delete
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.identity import User, Person
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.vehicle.asset import Asset, AssetEvent
|
||||
from app.models.vehicle.vehicle_definitions import VehicleModelDefinition
|
||||
from app.core.security import create_tokens
|
||||
import uuid
|
||||
|
||||
|
||||
async def setup_test_data():
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Clean previous test data
|
||||
await db.execute(delete(AssetEvent).where(AssetEvent.description == "TEST Service Book Event"))
|
||||
await db.execute(delete(Asset).where(Asset.license_plate == "SERVBK-01"))
|
||||
await db.execute(delete(VehicleModelDefinition).where(
|
||||
VehicleModelDefinition.make == "TestMake",
|
||||
VehicleModelDefinition.marketing_name == "TestModel"
|
||||
))
|
||||
await db.execute(delete(OrganizationMember))
|
||||
await db.execute(delete(Organization).where(Organization.name == "Test Service Book Org"))
|
||||
test_users = await db.execute(select(User).where(User.email == "test_servicebook@test.com"))
|
||||
for u in test_users.scalars().all():
|
||||
await db.execute(delete(User).where(User.id == u.id))
|
||||
await db.commit()
|
||||
|
||||
# Create Person
|
||||
person = Person(first_name="ServiceBook", last_name="Tester")
|
||||
db.add(person)
|
||||
await db.flush()
|
||||
|
||||
# Create User
|
||||
user = User(
|
||||
email="test_servicebook@test.com",
|
||||
hashed_password="pw",
|
||||
is_active=True,
|
||||
person_id=person.id,
|
||||
subscription_plan="personal",
|
||||
preferred_language="en",
|
||||
region_code="HU",
|
||||
preferred_currency="EUR",
|
||||
scope_level="personal"
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
# Create Organization
|
||||
org = Organization(name="Test Service Book Org", tax_number="999999")
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
|
||||
member = OrganizationMember(organization_id=org.id, user_id=user.id, role="owner")
|
||||
db.add(member)
|
||||
|
||||
# Create VehicleModelDefinition
|
||||
model_def = VehicleModelDefinition(
|
||||
make="TestMake",
|
||||
marketing_name="TestModel",
|
||||
normalized_name="testmake_testmodel",
|
||||
year_from=2020,
|
||||
year_to=2025,
|
||||
data_status="verified"
|
||||
)
|
||||
db.add(model_def)
|
||||
await db.commit()
|
||||
|
||||
# Create Asset
|
||||
asset = Asset(
|
||||
license_plate="SERVBK-01",
|
||||
vin="TESTVINSERVBK001",
|
||||
brand="TestMake",
|
||||
model="TestModel",
|
||||
year_of_manufacture=2022,
|
||||
current_mileage=10000,
|
||||
owner_person_id=person.id,
|
||||
owner_org_id=org.id,
|
||||
status="active"
|
||||
)
|
||||
db.add(asset)
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
|
||||
# Generate token
|
||||
token, _ = create_tokens(data={"sub": str(user.id)})
|
||||
|
||||
return token, asset.id
|
||||
|
||||
|
||||
async def run_tests():
|
||||
print("=" * 60)
|
||||
print("🧪 SERVICE BOOK API E2E TEST")
|
||||
print("=" * 60)
|
||||
|
||||
print("\n--- SETUP ---")
|
||||
token, asset_id = await setup_test_data()
|
||||
print(f"✅ Test data created. Asset ID: {asset_id}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(base_url="http://127.0.0.1:8000") as client:
|
||||
# TEST 1: GET events (empty list)
|
||||
print("\n--- TEST 1: GET /assets/{asset_id}/events (empty) ---")
|
||||
r1 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers)
|
||||
print(f" Status: {r1.status_code}")
|
||||
if r1.status_code == 200:
|
||||
data = r1.json()
|
||||
print(f" Events count: {len(data)}")
|
||||
assert len(data) == 0, "Expected empty events list"
|
||||
print(" ✅ PASS: Empty events list returned")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r1.text}")
|
||||
return
|
||||
|
||||
# TEST 2: POST event (SERVICE type)
|
||||
print("\n--- TEST 2: POST /assets/{asset_id}/events (SERVICE) ---")
|
||||
r2 = await client.post(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": "SERVICE",
|
||||
"description": "TEST Service Book Event - Oil Change",
|
||||
"odometer_reading": 15000,
|
||||
"event_date": "2026-06-15T10:00:00Z"
|
||||
}
|
||||
)
|
||||
print(f" Status: {r2.status_code}")
|
||||
if r2.status_code == 201:
|
||||
data = r2.json()
|
||||
print(f" Event ID: {data.get('id')}")
|
||||
print(f" Event type: {data.get('event_type')}")
|
||||
print(f" Odometer: {data.get('odometer_reading')}")
|
||||
assert data["event_type"] == "SERVICE"
|
||||
assert data["odometer_reading"] == 15000
|
||||
print(" ✅ PASS: Service event created")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r2.text}")
|
||||
return
|
||||
|
||||
# TEST 3: POST event (REPAIR type)
|
||||
print("\n--- TEST 3: POST /assets/{asset_id}/events (REPAIR) ---")
|
||||
r3 = await client.post(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": "REPAIR",
|
||||
"description": "TEST Service Book Event - Brake replacement",
|
||||
"odometer_reading": 15500,
|
||||
}
|
||||
)
|
||||
print(f" Status: {r3.status_code}")
|
||||
if r3.status_code == 201:
|
||||
data = r3.json()
|
||||
print(f" Event ID: {data.get('id')}")
|
||||
print(f" Event type: {data.get('event_type')}")
|
||||
assert data["event_type"] == "REPAIR"
|
||||
print(" ✅ PASS: Repair event created")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r3.text}")
|
||||
return
|
||||
|
||||
# TEST 4: GET events (should have 2 events, sorted by date desc)
|
||||
print("\n--- TEST 4: GET /assets/{asset_id}/events (2 events) ---")
|
||||
r4 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers)
|
||||
print(f" Status: {r4.status_code}")
|
||||
if r4.status_code == 200:
|
||||
data = r4.json()
|
||||
print(f" Events count: {len(data)}")
|
||||
assert len(data) == 2, f"Expected 2 events, got {len(data)}"
|
||||
# Should be sorted by event_date descending
|
||||
print(f" First event type: {data[0]['event_type']}")
|
||||
print(f" Second event type: {data[1]['event_type']}")
|
||||
print(" ✅ PASS: Both events returned, sorted by date")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r4.text}")
|
||||
return
|
||||
|
||||
# TEST 5: POST event with lower odometer (should NOT update mileage)
|
||||
print("\n--- TEST 5: POST event with lower odometer ---")
|
||||
r5 = await client.post(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": "INSPECTION",
|
||||
"description": "TEST Service Book Event - Annual inspection",
|
||||
"odometer_reading": 14000, # Lower than current 15500
|
||||
}
|
||||
)
|
||||
print(f" Status: {r5.status_code}")
|
||||
if r5.status_code == 201:
|
||||
data = r5.json()
|
||||
print(f" Event type: {data.get('event_type')}")
|
||||
print(f" Odometer: {data.get('odometer_reading')}")
|
||||
print(" ✅ PASS: Inspection event created (odometer not updated)")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r5.text}")
|
||||
return
|
||||
|
||||
# TEST 6: POST event without odometer
|
||||
print("\n--- TEST 6: POST event without odometer ---")
|
||||
r6 = await client.post(
|
||||
f"/api/v1/assets/{asset_id}/events",
|
||||
headers=headers,
|
||||
json={
|
||||
"event_type": "ACCIDENT",
|
||||
"description": "TEST Service Book Event - Minor accident",
|
||||
}
|
||||
)
|
||||
print(f" Status: {r6.status_code}")
|
||||
if r6.status_code == 201:
|
||||
data = r6.json()
|
||||
print(f" Event type: {data.get('event_type')}")
|
||||
print(f" Odometer: {data.get('odometer_reading')}")
|
||||
assert data["odometer_reading"] is None
|
||||
print(" ✅ PASS: Accident event created without odometer")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r6.text}")
|
||||
return
|
||||
|
||||
# TEST 7: GET events (should have 4 events)
|
||||
print("\n--- TEST 7: GET /assets/{asset_id}/events (4 events) ---")
|
||||
r7 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers)
|
||||
print(f" Status: {r7.status_code}")
|
||||
if r7.status_code == 200:
|
||||
data = r7.json()
|
||||
print(f" Events count: {len(data)}")
|
||||
assert len(data) == 4, f"Expected 4 events, got {len(data)}"
|
||||
print(" ✅ PASS: All 4 events returned")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r7.text}")
|
||||
return
|
||||
|
||||
# TEST 8: GET events with skip/limit
|
||||
print("\n--- TEST 8: GET events with skip=0&limit=2 ---")
|
||||
r8 = await client.get(
|
||||
f"/api/v1/assets/{asset_id}/events?skip=0&limit=2",
|
||||
headers=headers
|
||||
)
|
||||
print(f" Status: {r8.status_code}")
|
||||
if r8.status_code == 200:
|
||||
data = r8.json()
|
||||
print(f" Events count: {len(data)}")
|
||||
assert len(data) == 2, f"Expected 2 events, got {len(data)}"
|
||||
print(" ✅ PASS: Pagination works correctly")
|
||||
else:
|
||||
print(f" ❌ FAIL: {r8.text}")
|
||||
return
|
||||
|
||||
# TEST 9: Unauthorized access
|
||||
print("\n--- TEST 9: Unauthorized access (no token) ---")
|
||||
r9 = await client.get(f"/api/v1/assets/{asset_id}/events")
|
||||
print(f" Status: {r9.status_code}")
|
||||
if r9.status_code == 401:
|
||||
print(" ✅ PASS: Unauthorized access rejected")
|
||||
else:
|
||||
print(f" ❌ FAIL: Expected 401, got {r9.status_code}")
|
||||
return
|
||||
|
||||
# TEST 10: Invalid asset ID
|
||||
print("\n--- TEST 10: Invalid asset ID ---")
|
||||
fake_id = uuid.uuid4()
|
||||
r10 = await client.get(f"/api/v1/assets/{fake_id}/events", headers=headers)
|
||||
print(f" Status: {r10.status_code}")
|
||||
if r10.status_code == 404:
|
||||
print(" ✅ PASS: Invalid asset ID returns 404")
|
||||
else:
|
||||
print(f" ❌ FAIL: Expected 404, got {r10.status_code}")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("🎉 ALL TESTS PASSED!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_tests())
|
||||
Reference in New Issue
Block a user