frontend 2026-06-10 bontva a 2 felület
This commit is contained in:
287
.roo/history.md
287
.roo/history.md
@@ -1,5 +1,79 @@
|
||||
# Service Finder Fejlesztési Történet
|
||||
|
||||
## 2026-06-08 - F5 Refresh Logout Bug Fix & HeaderLogo Contextual Navigation
|
||||
|
||||
### 🎯 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.
|
||||
|
||||
### 🔧 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.
|
||||
|
||||
**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é.
|
||||
|
||||
**3. `frontend/src/components/header/HeaderLogo.vue`:**
|
||||
- `useRoute()` importálva a Vue Router-ből.
|
||||
- `targetRoute` computed property: ha a jelenlegi útvonal `/organization/`-t tartalmaz, akkor a logó a jelenlegi útvonalra navigál (önmagát tölti újra), egyébként `/dashboard`.
|
||||
- A prop-alapú `to` helyett a `:to="targetRoute"` használata a template-ben.
|
||||
|
||||
### ✅ Eredmény
|
||||
- Vite build hiba nélkül lefutott (136 modul, 3.23s).
|
||||
- Nincs több F5 utáni hamis kijelentkezés.
|
||||
- A logó szervezeti oldalon nem dobja ki a felhasználót a dashboard-ra.
|
||||
|
||||
|
||||
## 2026-06-08 - Issue #237: API Endpoints for visual_settings
|
||||
|
||||
### 🎯 Cél
|
||||
Backend API végpontok felkészítése a `visual_settings` módosítására. Pydantic sémák (`OrganizationUpdate`, `OrganizationResponse`) létrehozása a `visual_settings` mezővel, valamint `PATCH /organizations/{org_id}/visual-settings` végpont implementálása JSONB merge logikával.
|
||||
|
||||
### ✅ Implementáció
|
||||
|
||||
#### 1. Pydantic Sémák
|
||||
- **`OrganizationUpdate`**: [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:40) - Új séma `visual_settings: Optional[dict]`, `display_name`, `default_currency`, `language` mezőkkel
|
||||
- **`OrganizationResponse`**: [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:49) - Új DTO séma `visual_settings: Optional[dict]` mezővel, `from_attributes=True` konfigurációval
|
||||
|
||||
#### 2. API Végpont
|
||||
- **`PATCH /organizations/{org_id}/visual-settings`**: [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:207) - Új végpont JSONB merge logikával:
|
||||
- OWNER/ADMIN jogosultság ellenőrzés
|
||||
- Mély merge: a meglévő `visual_settings` kulcsok megmaradnak, csak a kapottak frissülnek
|
||||
- Pl. `{"theme": "dark"}` küldése nem írja felül a `wall_logo_url`-t
|
||||
- Egyéb mezők (`display_name`, `language`, `default_currency`) is frissíthetők
|
||||
|
||||
#### 3. Meglévő Végpontok Bővítése
|
||||
- **`GET /organizations/my`**: [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:203) - `visual_settings` mező hozzáadva a response-hoz
|
||||
- **`PATCH /users/me/preferences`**: [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:333) - Már támogatja a `visual_settings`-et a generikus `exclude_unset=True` logikával
|
||||
|
||||
#### 4. Verifikáció
|
||||
- Python syntax check: minden fájl hibátlanul lefordul
|
||||
- Import teszt: `OrganizationUpdate`, `OrganizationResponse`, `UserUpdate`, `UserResponse` mindegyike tartalmazza a `visual_settings` mezőt
|
||||
|
||||
|
||||
## 2026-06-08 - Issue #228: Add visual_settings to Models
|
||||
|
||||
### 🎯 Cél
|
||||
`visual_settings` JSONB mező hozzáadása a User és Organization modellekhez, a Pydantic sémák frissítése, majd az adatbázis szinkronizálása a sync_engine segítségével.
|
||||
|
||||
### ✅ Implementáció
|
||||
|
||||
#### 1. SQLAlchemy Modellek
|
||||
- **User modell**: [`backend/app/models/identity/identity.py`](backend/app/models/identity/identity.py:157) - `visual_settings` JSONB oszlop hozzáadva a `ui_mode` után, `server_default` JSONB default értékkel (`{"theme": "default", "primary_color": null, "wall_logo_url": null}`)
|
||||
- **Organization modell**: [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:109) - `visual_settings` JSONB oszlop hozzáadva az `external_integration_config` után, azonos default értékkel
|
||||
|
||||
#### 2. Pydantic Sémák
|
||||
- **UserResponse**: [`backend/app/schemas/user.py`](backend/app/schemas/user.py:62) - `visual_settings: Optional[dict]` mező hozzáadva default értékkel
|
||||
- **UserUpdate**: [`backend/app/schemas/user.py`](backend/app/schemas/user.py:107) - `visual_settings: Optional[dict]` mező hozzáadva
|
||||
|
||||
#### 3. Adatbázis Szinkronizálás
|
||||
- `sync_engine` sikeresen lefuttatva: 2 hiányzó oszlopot észlelt és hozzáadott (`identity.users.visual_settings` és `fleet.organizations.visual_settings`)
|
||||
- Verifikáció: mindkét oszlop `jsonb` típusként létezik az adatbázisban
|
||||
|
||||
|
||||
## 2026-06-04 (B) - Magic Link Implementáció (Auto-Login Email Verification)
|
||||
|
||||
### 🎯 Cél
|
||||
@@ -196,3 +270,216 @@ Az [`tests/active/e2e_profile_address.py`](tests/active/e2e_profile_address.py)
|
||||
- Ha be van állítva → `pw.chromium.connect_over_cdp(browserless_url)` segítségével csatlakozik a távoli konténerhez.
|
||||
- Ha nincs beállítva → lokális `pw.chromium.launch()` fallback, a `HEADLESS` env var figyelembevételével.
|
||||
- **Gitea Issue**: #215 - létrehozva, elindítva és lezárva.
|
||||
|
||||
## 2026-06-07 (C) - NAV API UX & Adatbázis Finomhangolás
|
||||
|
||||
### 🎯 Cél
|
||||
A NAV API működik, de a felhasználói élmény és az adatbázis szigorúsága finomhangolást igényelt.
|
||||
|
||||
### ✅ Implementáció
|
||||
|
||||
#### 1. Cégjegyzékszám (company_registry_number) opcionálissá tétele
|
||||
- **Modell**: [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:89) - `reg_number: Mapped[Optional[str]]` már eleve nullable volt
|
||||
- **Schema**: [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:17) - `reg_number: Optional[str] = None` már eleve opcionális volt
|
||||
- **Adatbázis**: A `sync_engine` audit szerint a séma tökéletesen szinkronban van (1017/1017 elem OK)
|
||||
|
||||
#### 2. Duplikáció szűrése a NAV lekérdezés előtt
|
||||
- **Végpont**: [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:124) - `GET /lookup-tax/{tax_number}`
|
||||
- Injektált `db: AsyncSession = Depends(get_db)` függőség
|
||||
- Mielőtt a NAV-hoz fordul, ellenőrzi az adatbázist az adószám első 8 számjegye (törzsszám) alapján
|
||||
- Ha létezik aktív cég (`is_deleted == False`), azonnal `HTTPException(status_code=409, detail="Ez az adószám már regisztrálva van a rendszerben.")` hibát dob
|
||||
|
||||
#### 3. Rövid név okosítása (filler words filtering)
|
||||
- **Service**: [`backend/app/services/nav_service.py`](backend/app/services/nav_service.py:105) - `_format_company_names()` metódus
|
||||
- `filler_words` lista: `["és", "szolgáltató", "kereskedelmi", "üzleti", "tanácsadó", "ipari", "építőipari", "termelő", "fejlesztő", "kivitelező", "általános"]`
|
||||
- A rövid név generálásakor a név szavakra bontva, csak a nem-töltelék szavak maradnak meg
|
||||
- A cégforma rövidítés (pl. "Kft.") a legvégére kerül hozzáfűzésre
|
||||
- Fallback: ha minden szó töltelék volt, az eredeti név marad
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Python import check: `nav_service.py` és `organizations.py` sikeresen importálódott
|
||||
- `sync_engine` audit: 1017/1017 elem OK, 0 hiba, teljes szinkron
|
||||
|
||||
## 2026-06-08 - UI/UX Navigációs Hibák Javítása (3 Bugfix)
|
||||
|
||||
### 🎯 Cél
|
||||
Három UI/UX és navigációs hiba javítása a Vue 3 frontendben: (1) cégnév/"Cégem" gomb navigáció `/company/garage`-ba, (2) Profil oldal bezárása backdrop overlay-re kattintva, (3) Avatar dropdown bezárása kattintáskor az elemen kívül.
|
||||
|
||||
### ✅ Implementáció
|
||||
|
||||
#### 1. ProfileView.vue - Backdrop Overlay
|
||||
**Fájl**: [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:14)
|
||||
- Teljes oldalt lefedő backdrop overlay (`fixed inset-0 z-10 bg-black/30 backdrop-blur-[2px]`) a profil kártya körül
|
||||
- `@click="closeProfile"` a backdrop-on → kattintásra `router.push('/dashboard')`
|
||||
- `@click.stop` a belső kártya `div`-en → megakadályozza a bezáródást a kártyán belüli kattintáskor
|
||||
- `handleEscapeKey()`: Escape billentyűre bezárja a profilt (vagy a jelszó módosító modalt, ha az nyitva van)
|
||||
- `onMounted`/`onUnmounted`: `keydown` eseményfigyelő regisztráció/törlés
|
||||
|
||||
#### 2. DashboardHeader.vue - Cégnév Navigáció
|
||||
**Fájl**: [`frontend/src/components/DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue:157)
|
||||
- Cégnév (`activeCompanyName`) `<button>`-ként jelenik meg, `@click="goToCompanyGarage"`
|
||||
- `goToCompanyGarage()`: bezárja az összes dropdown-t, majd `router.push('/company/garage')`
|
||||
- `handleCompanySelect(orgId)`: ha a szervezet már aktív, akkor is navigál `/company/garage`-ba (nem tér vissza korán)
|
||||
|
||||
#### 3. DashboardHeader.vue - Avatar Dropdown Click-Outside
|
||||
**Fájl**: [`frontend/src/components/DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue:394)
|
||||
- `avatarContainerRef` template ref az avatar konténeren
|
||||
- `handleOutsideClick()`: `avatarContainerRef.value.contains(target)` ellenőrzés → ha a kattintás az avatar konténeren kívül van, bezárja a `isUserMenuOpen` dropdown-t
|
||||
|
||||
#### 4. auth.ts - Store Navigáció Eltávolítása
|
||||
**Fájl**: [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:524)
|
||||
- `switchOrganization()` már nem tartalmaz `router.push()` hívásokat
|
||||
- A navigáció kizárólag a komponensek felelőssége (Separation of Concerns)
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Vite production build: 121 module transformed, 0 hiba, 3.35s alatt
|
||||
|
||||
## 2026-06-08 - Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer Mérföldkő Létrehozása
|
||||
|
||||
### 🎯 Cél
|
||||
Új Gitea mérföldkő (ID: 26) létrehozása a frontend architektúrális átalakításához, valamint a hozzá tartozó 8 issue (ID: #228-#236) létrehozása a `gitea_manager.py` script segítségével.
|
||||
|
||||
### ✅ Elkészült elemek
|
||||
|
||||
#### Mérföldkő
|
||||
- **Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer** (ID: 26)
|
||||
|
||||
#### 1. FÁZIS: Adatbázis és Backend Alapozás
|
||||
- **#228** - 1.1: Adatbázis sémák bővítése vizuális konfigurációval (Skins/Themes) - JSONB mezők User és Organization táblákhoz
|
||||
- **#229** - 1.2: Engedélyezési szintek és Funkciókapcsolók (Feature Flags / RBAC) - ABAC kiterjesztés
|
||||
- **#230** - 1.3: Dinamikus (Adatbázis) és Statikus fordítási rendszer alapjai - Translation tábla
|
||||
|
||||
#### 2. FÁZIS: Frontend Struktúra & Layout Szétválasztás
|
||||
- **#231** - 2.1: Független Layout komponensek felépítése (Vue 3) - PrivateLayout & OrganizationLayout
|
||||
- **#232** - 2.2: Vue Router átírás (Nested Routing) - Hierarchikus útvonalak
|
||||
|
||||
#### 3. FÁZIS: Többnyelvűség (i18n) & Vizuális Témaváltó (Theming Engine)
|
||||
- **#233** - 3.1: i18n audit & Szövegek kiszervezése nyelvi kulcsokra
|
||||
- **#234** - 3.2: Tailwind CSS Változók és Témakezelő implementálása (AppThemeStore)
|
||||
|
||||
#### 4. FÁZIS: Animált UI Készlet
|
||||
- **#235** - 4.1: "App Store" típusú kiterjedő kártya (Expansion Card)
|
||||
- **#236** - 4.2: Kontextuális Vészjelző Rendszer (Villogó Hibajelző Lámpa)
|
||||
|
||||
## 2026-06-08 - Frontend Architektúra 2.0 - Megvalósíthatósági Elemzés
|
||||
|
||||
### 🎯 Cél
|
||||
A létrehozott 8 Gitea issue (#228-#236) kód-szintű felmérése: mi az, ami már létezik, mi hiányzik, és milyen függőségi sorrendben kell implementálni.
|
||||
|
||||
### ✅ Elemzés eredménye
|
||||
|
||||
#### Készültségi Tábla
|
||||
| Issue | Kártya | Kész | Megjegyzés |
|
||||
|-------|--------|------|------------|
|
||||
| #228 | JSONB visual_settings | **0%** | Nincs visual_settings mező semelyik modellben |
|
||||
| #229 | RBAC/ABAC | **40%** | UserRole, OrgUserRole, subscription_plan létezik; feature flag-ok hiányoznak |
|
||||
| #230 | Dinamikus Fordítás | **80%** | Translation modell, vue-i18n, 6 nyelvi fájl, translationService kész |
|
||||
| #231 | Layout komponensek | **60%** | 3 layout létezik, de `<slot />`-ot használnak `<router-view />` helyett |
|
||||
| #232 | Nested Routing | **50%** | 8 route létezik, de flat struktúrában, nincs layout nesting |
|
||||
| #233 | i18n audit | **30%** | 17+ hardcoded szöveg azonosítva 5 komponensben |
|
||||
| #234 | Tailwind Témaváltó | **0%** | Nincs CSS variable, nincs AppThemeStore, nincsenek `dark:` osztályok |
|
||||
| #235 | Expansion Card | **0%** | Nincs dedikált komponens |
|
||||
| #236 | Vészjelző Rendszer | **10%** | hotspot-pulse/ring animációk léteznek, de nincs ContextAlert komponens |
|
||||
|
||||
#### Feltárt fájlok (18+ darab)
|
||||
- Backend: [`identity.py`](backend/app/models/identity/identity.py:122), [`organization.py`](backend/app/models/marketplace/organization.py:35), [`translation.py`](backend/app/models/system/translation.py:8)
|
||||
- Frontend Store: [`auth.ts`](frontend/src/stores/auth.ts:54), [`appModeStore.ts`](frontend/src/stores/appModeStore.ts:6)
|
||||
- Frontend Layout: [`ConsumerLayout.vue`](frontend/src/layouts/ConsumerLayout.vue:1), [`CorporateLayout.vue`](frontend/src/layouts/CorporateLayout.vue:1), [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue:1)
|
||||
- Frontend Router: [`index.ts`](frontend/src/router/index.ts:1)
|
||||
- Frontend i18n: [`hu.ts`](frontend/src/i18n/hu.ts:1), [`en.ts`](frontend/src/i18n/en.ts:1), [`translationService.ts`](frontend/src/services/translationService.ts:5)
|
||||
- Frontend Komponens: [`DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue:1), [`DashboardView.vue`](frontend/src/views/DashboardView.vue:1), [`CompanyGarageView.vue`](frontend/src/views/organization/CompanyGarageView.vue:1)
|
||||
- Frontend Config: [`tailwind.config.js`](frontend/tailwind.config.js:1), [`main.css`](frontend/src/assets/main.css:1), [`main.ts`](frontend/src/main.ts:30)
|
||||
- Frontend Types: [`organization.ts`](frontend/src/types/organization.ts:5)
|
||||
|
||||
#### Ajánlott implementációs sorrend
|
||||
1. **#228** (JSONB) → **#229** (RBAC) → **#234** (Témaváltó)
|
||||
2. **#230** (Fordítás) → **#233** (i18n audit)
|
||||
3. **#231** (Layout) → **#232** (Router) → **#235** (Expansion) → **#236** (Alert)
|
||||
|
||||
#### Dokumentáció
|
||||
- Teljes elemzés: [`docs/frontend_architecture_2_0_feasibility_analysis.md`](docs/frontend_architecture_2_0_feasibility_analysis.md)
|
||||
|
||||
## 2026-06-08 - Frontend Architektúra 2.0 - Hiányzó Alkártyák Létrehozása
|
||||
|
||||
### 🎯 Cél
|
||||
Az elemzés alapján azonosított hiányzó komponensekhez 14 új Gitea alkártya (#237-#250) létrehozása, majd hozzárendelése a "Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer" mérföldkőhöz (ID: 26).
|
||||
|
||||
### ✅ Elkészült elemek
|
||||
|
||||
#### 1. FÁZIS: Adatbázis és Backend Alapozás (Alkártyák)
|
||||
- **#237** - 1.1-A: Backend API végpontok visual_settings kezelésére (GET/PUT /users/me/visual-settings, GET/PUT /organizations/{id}/visual-settings)
|
||||
- **#238** - 1.1-B: Frontend useVisualSettings() composable + UserProfile/OrganizationItem interface bővítés visual_settings mezővel
|
||||
- **#239** - 1.2-A: Backend subscription_service.py + SUBSCRIPTION_FEATURES config dict (allow_custom_skin, allow_advanced_animations)
|
||||
- **#240** - 1.2-B: Frontend useFeatureFlag() composable + auth store bővítés subscription_features mezővel
|
||||
|
||||
#### 2. FÁZIS: Frontend Struktúra & Layout Szétválasztás (Alkártyák)
|
||||
- **#241** - 2.1-A: PrivateLayout komponens kialakítása (ConsumerLayout átnevezés)
|
||||
- **#242** - 2.1-B: OrganizationLayout komponens kialakítása (CorporateLayout átnevezés)
|
||||
- **#243** - 2.2-A: Nested Router struktúra kialakítása (layout → child route)
|
||||
- **#244** - 2.2-B: Route transition guard + breadcrumb komponens
|
||||
|
||||
#### 3. FÁZIS: Többnyelvűség & Témaváltó (Alkártyák)
|
||||
- **#245** - 3.1-A: i18n audit - hardcoded szövegek kiszervezése
|
||||
- **#246** - 3.1-B: i18n audit - hiányzó nyelvi kulcsok pótlása
|
||||
- **#247** - 3.2-A: AppThemeStore implementálása (CSS variable binding)
|
||||
- **#248** - 3.2-B: Tailwind CSS dark mode + skin class binding
|
||||
|
||||
#### 4. FÁZIS: Animált UI Készlet (Alkártyák)
|
||||
- **#249** - 4.1: ExpansionCard komponens (App Store típusú kártya)
|
||||
- **#250** - 4.2: ContextAlert komponens (Vészjelző Rendszer)
|
||||
|
||||
### 🔗 Gitea
|
||||
- **Mérföldkő**: Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer (ID: 26)
|
||||
- **Státusz**: Alkártyák létrehozva, mérföldkőhöz rendelve
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-09 - Széf→Garázs Terminológia Javítás & Regisztrációs Hiba Javítás
|
||||
|
||||
### 🎯 Cél
|
||||
Két kritikus hiba javítása a járművek csatlakoztatása előtt: (1) "Széf" terminológia cseréje "Garázs"-ra a backend kódban, (2) Regisztrációs hiba javítása, ahol az opcionális címmezők kitöltetlenül hagyása csendes hibát okozott.
|
||||
|
||||
### ✅ Javítások
|
||||
|
||||
#### 1. Terminológia Javítás ("Széf" → "Garázs")
|
||||
|
||||
**Elsődleges - Backend organization létrehozás**:
|
||||
- **Fájl**: [`backend/app/services/auth_service.py`](backend/app/services/auth_service.py:253)
|
||||
- **Hiba**: A `complete_kyc()` metódusban a személyes garázs organization `name` mezője `f"{p.last_name} Széfe"` volt hardkódolva
|
||||
- **Javítás**: `f"{p.last_name} Széfe"` → `f"{p.last_name} Garázsa"`
|
||||
- **Hatás**: A regisztráció után a felhasználó a sárga gombban "{Vezetéknév} Garázsa" feliratot látja
|
||||
|
||||
**Másodlagos - Dokumentum archiválás üzenet**:
|
||||
- **Fájl**: [`backend/app/api/v1/endpoints/documents.py`](backend/app/api/v1/endpoints/documents.py:63)
|
||||
- **Hiba**: `"Dokumentum sikeresen archiválva a széfben."`
|
||||
- **Javítás**: `"Dokumentum sikeresen archiválva a garázsban."`
|
||||
|
||||
**Ellenőrzött frontend fájlok (nem volt szükség módosításra)**:
|
||||
- [`frontend/src/i18n/hu.ts`](frontend/src/i18n/hu.ts:38) - Már "garázs"-t használ (`personalGarage`, `privateGarage`, `companyGarageLabel`, `personalGarageLabel`)
|
||||
- [`frontend/src/i18n/en.ts`](frontend/src/i18n/en.ts:41) - Már "garage"-t használ helyesen
|
||||
- [`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue:42) - i18n kulcsokat használ, nincs hardkódolt "széf"
|
||||
- [`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue:10) - Már "garázsa" szerepel a template-ben
|
||||
|
||||
#### 2. Regisztrációs Csendes Hiba Javítása
|
||||
|
||||
**Gyökér Ok**:
|
||||
- **Fájl**: [`backend/app/services/geo_service.py`](backend/app/services/geo_service.py:50)
|
||||
- A `get_or_create_full_address()` metódusban a `street_name`, `street_type` és `house_number` paraméterek **kötelezőek** voltak (`str` típus, nem `Optional[str]`)
|
||||
- A KYC űrlapról ezek a mezők `None` értékkel érkezhetnek (opcionális mezők)
|
||||
- A `UserKYCComplete` Pydantic séma helyesen `Optional[str] = None`-ként deklarálta őket
|
||||
- A frontend helyesen kihagyta az üres mezőket a payload-ból
|
||||
- **A hiba**: A `GeoService.get_or_create_full_address()` nem fogadott el `None` értéket ezekre a paraméterekre
|
||||
|
||||
**Javítás**:
|
||||
- `street_name: str` → `street_name: Optional[str] = None`
|
||||
- `street_type: str` → `street_type: Optional[str] = None`
|
||||
- `house_number: str` → `house_number: Optional[str] = None`
|
||||
|
||||
**Ellenőrzött fájlok (nem volt szükség módosításra)**:
|
||||
- [`backend/app/schemas/auth.py`](backend/app/schemas/auth.py:28) - `UserKYCComplete` séma helyesen `Optional[str] = None`-t használ
|
||||
- [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:411) - A `handleSubmit()` helyesen szűri az üres stringeket
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Vite production build: 121 module transformed, 0 hiba, 3.56s alatt
|
||||
- Python syntax check: minden módosított fájl hibátlan
|
||||
- `sync_engine` futtatva: séma konzisztens
|
||||
@@ -60,7 +60,7 @@ async def upload_document(
|
||||
if doc.status == "processing":
|
||||
response_data["message"] = "🤖 Robot 1 megkezdte a dokumentum elemzését. Értesítjük, ha kész!"
|
||||
else:
|
||||
response_data["message"] = "Dokumentum sikeresen archiválva a széfben."
|
||||
response_data["message"] = "Dokumentum sikeresen archiválva a garázsban."
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import select
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse
|
||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch
|
||||
from app.models.identity import User # JAVÍTVA: Központi Identity modell
|
||||
from app.core.config import settings
|
||||
@@ -39,13 +39,13 @@ async def onboard_organization(
|
||||
detail="Érvénytelen magyar adószám formátum!"
|
||||
)
|
||||
|
||||
# 2. Duplikáció ellenőrzés
|
||||
# 2. Duplikáció ellenőrzés – adószám alapján
|
||||
stmt_exist = select(Organization).where(Organization.tax_number == org_in.tax_number)
|
||||
result_exist = await db.execute(stmt_exist)
|
||||
if result_exist.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Ezzel az adószámmal már regisztráltak céget!"
|
||||
detail="Ez a cég (adószám) már regisztrálva van a rendszerben."
|
||||
)
|
||||
|
||||
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
|
||||
@@ -121,6 +121,56 @@ async def onboard_organization(
|
||||
|
||||
return {"organization_id": new_org.id, "status": new_org.status}
|
||||
|
||||
@router.get("/lookup-tax/{tax_number}")
|
||||
async def lookup_tax_number(
|
||||
tax_number: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Adószám alapú cégadat lekérdezés a hivatalos NAV Online Számla API v3 rendszerén keresztül.
|
||||
A queryTaxpayer végpontot használja valós idejű cégadatok lekérésére.
|
||||
|
||||
Duplikáció ellenőrzés: Mielőtt a NAV-hoz fordulnánk, ellenőrizzük, hogy az adószám
|
||||
első 8 számjegye (törzsszám) alapján létezik-e már aktív cég az adatbázisban.
|
||||
|
||||
Hibakezelés:
|
||||
- 409: Az adószám már regisztrálva van a rendszerben
|
||||
- 503: NAV rendszer nem elérhető (hálózati hiba, DNS, timeout)
|
||||
- 404: Adószám nem található a NAV adatbázisában
|
||||
"""
|
||||
from app.services.nav_service import NavService, NavServiceUnavailableError
|
||||
|
||||
# Duplikáció ellenőrzés: adószám első 8 számjegye (törzsszám) alapján
|
||||
tax_core = tax_number.strip().replace("-", "").replace(" ", "")[:8]
|
||||
stmt_existing = select(Organization).where(
|
||||
Organization.tax_number.like(f"{tax_core}%"),
|
||||
Organization.is_deleted == False,
|
||||
)
|
||||
result_existing = await db.execute(stmt_existing)
|
||||
existing_org = result_existing.scalar_one_or_none()
|
||||
if existing_org:
|
||||
logger.warning(f"Duplikáció észlelve: adószám {tax_core} már létezik (org_id={existing_org.id})")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Ez az adószám már regisztrálva van a rendszerben."
|
||||
)
|
||||
|
||||
try:
|
||||
result = await NavService.query_taxpayer(tax_number)
|
||||
except NavServiceUnavailableError as e:
|
||||
logger.error(f"NAV szolgáltatás hiba: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="A NAV rendszere jelenleg nem elérhető."
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Cég nem található a NAV adatbázisában"
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/my", response_model=List[dict])
|
||||
async def get_my_organizations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -147,10 +197,84 @@ async def get_my_organizations(
|
||||
"country_code": o.country_code,
|
||||
"is_active": o.is_active,
|
||||
"is_deleted": o.is_deleted,
|
||||
"subscription_plan": o.subscription_plan
|
||||
"subscription_plan": o.subscription_plan,
|
||||
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
|
||||
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None
|
||||
}
|
||||
for o in orgs
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{org_id}/visual-settings", response_model=OrganizationResponse)
|
||||
async def update_organization_visual_settings(
|
||||
org_id: int,
|
||||
update_data: OrganizationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Szervezet vizuális beállításainak részleges frissítése (JSONB merge).
|
||||
|
||||
A frontend küldhet csak egyetlen kulcsot is (pl. {"theme": "dark"}),
|
||||
és az nem írja felül null-lal a többi mezőt. A meglévő visual_settings
|
||||
objektum mélyen merge-elődik a beérkező adatokkal.
|
||||
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||||
"""
|
||||
# 1. Jogosultság ellenőrzése
|
||||
stmt_member = select(OrganizationMember).where(
|
||||
(OrganizationMember.organization_id == org_id) &
|
||||
(OrganizationMember.user_id == current_user.id) &
|
||||
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
|
||||
)
|
||||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultságod a szervezet beállításainak módosításához (csak OWNER/ADMIN)."
|
||||
)
|
||||
|
||||
# 2. Szervezet lekérése
|
||||
stmt_org = select(Organization).where(
|
||||
Organization.id == org_id,
|
||||
Organization.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt_org)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Szervezet nem található."
|
||||
)
|
||||
|
||||
# 3. JSONB merge: csak a kapott kulcsokat frissítjük, a többit megtartjuk
|
||||
update_dict = update_data.dict(exclude_unset=True)
|
||||
|
||||
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
|
||||
incoming_vs = update_dict["visual_settings"]
|
||||
current_vs = dict(org.visual_settings) if org.visual_settings else {}
|
||||
# Mély merge: a meglévő kulcsok megmaradnak, csak a kapottak frissülnek
|
||||
current_vs.update(incoming_vs)
|
||||
org.visual_settings = current_vs
|
||||
del update_dict["visual_settings"]
|
||||
|
||||
# 4. Egyéb mezők frissítése (display_name, language, default_currency)
|
||||
for field, value in update_dict.items():
|
||||
if hasattr(org, field) and value is not None:
|
||||
setattr(org, field, value)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a szervezet frissítésekor (org_id={org_id}): {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Adatbázis hiba: {str(e)}"
|
||||
)
|
||||
|
||||
return OrganizationResponse.model_validate(org)
|
||||
# --- B2B MEGHÍVÓ LOGIKA ---
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
@@ -402,8 +402,8 @@ async def update_active_organization(
|
||||
detail="You are not a member of this organization"
|
||||
)
|
||||
|
||||
# Update user's scope_id
|
||||
current_user.scope_id = org_id
|
||||
# Update user's scope_id (stored as string in DB, convert from int)
|
||||
current_user.scope_id = str(org_id) if org_id is not None else None
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
@@ -419,7 +419,7 @@ async def update_active_organization(
|
||||
"role": role_key,
|
||||
"rank": DEFAULT_RANK_MAP.get(role_key, "user"),
|
||||
"scope_level": "organization" if org_id else "personal",
|
||||
"scope_id": org_id,
|
||||
"scope_id": str(org_id) if org_id is not None else None,
|
||||
"person_id": str(current_user.person_id) if current_user.person_id else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,23 @@ class Settings(BaseSettings):
|
||||
return parts
|
||||
return v
|
||||
|
||||
# --- NAV Online Számla API v3 ---
|
||||
NAV_API_USER: str = ""
|
||||
NAV_API_PASSWORD: str = ""
|
||||
NAV_API_SIGN_KEY: str = ""
|
||||
NAV_API_EXCHANGE_KEY: str = ""
|
||||
NAV_API_CALLER_TAX_NUMBER: str = ""
|
||||
NAV_API_BASE_URL: str = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3"
|
||||
|
||||
@field_validator('NAV_API_PASSWORD', 'NAV_API_SIGN_KEY', 'NAV_API_EXCHANGE_KEY', 'NAV_API_CALLER_TAX_NUMBER')
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v:
|
||||
v = v.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
|
||||
v = v[1:-1]
|
||||
return v
|
||||
|
||||
# --- Google OAuth ---
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
|
||||
@@ -154,6 +154,13 @@ class User(Base):
|
||||
preferred_currency: Mapped[str] = mapped_column(String(3), server_default="HUF")
|
||||
ui_mode: Mapped[str] = mapped_column(String(20), server_default="personal")
|
||||
|
||||
visual_settings: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=lambda: {"theme": "default", "primary_color": None, "wall_logo_url": None},
|
||||
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
|
||||
)
|
||||
|
||||
scope_level: Mapped[str] = mapped_column(String(30), server_default="individual")
|
||||
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
|
||||
@@ -106,6 +106,13 @@ class Organization(Base):
|
||||
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
|
||||
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
|
||||
visual_settings: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=lambda: {"theme": "default", "primary_color": None, "wall_logo_url": None},
|
||||
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
|
||||
)
|
||||
|
||||
# A technikai tulajdonos (User fiók - törölhető)
|
||||
owner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Any
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
full_name: str
|
||||
@@ -35,4 +35,34 @@ class CorpOnboardIn(BaseModel):
|
||||
class CorpOnboardResponse(BaseModel):
|
||||
organization_id: int
|
||||
status: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class OrganizationUpdate(BaseModel):
|
||||
"""PATCH séma a szervezet adatainak részleges frissítéséhez.
|
||||
Támogatja a visual_settings JSONB mező biztonságos részleges frissítését."""
|
||||
visual_settings: Optional[dict] = None
|
||||
display_name: Optional[str] = None
|
||||
default_currency: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
|
||||
|
||||
class OrganizationResponse(BaseModel):
|
||||
"""Szervezet adatainak visszaadása (DTO).
|
||||
Tartalmazza a visual_settings mezőt a frontend számára."""
|
||||
id: int
|
||||
name: str
|
||||
full_name: str
|
||||
display_name: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
country_code: str = "HU"
|
||||
status: str
|
||||
org_type: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_deleted: bool = False
|
||||
subscription_plan: str = "FREE"
|
||||
visual_settings: Optional[dict] = None
|
||||
notification_settings: Optional[Any] = None
|
||||
external_integration_config: Optional[Any] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -59,6 +59,7 @@ class UserResponse(UserBase):
|
||||
scope_level: str
|
||||
scope_id: Optional[str] = None
|
||||
ui_mode: str = "personal"
|
||||
visual_settings: Optional[dict] = {"theme": "default", "primary_color": None, "wall_logo_url": None}
|
||||
active_organization_id: Optional[int] = None
|
||||
preferred_language: str = "hu"
|
||||
# Beágyazott személyes adatok (Person + Address)
|
||||
@@ -102,10 +103,11 @@ class UserUpdate(BaseModel):
|
||||
last_name: Optional[str] = None
|
||||
preferred_language: Optional[str] = None
|
||||
ui_mode: Optional[str] = None
|
||||
visual_settings: Optional[dict] = None
|
||||
|
||||
|
||||
class ActiveOrganizationUpdate(BaseModel):
|
||||
organization_id: Optional[str] = None # UUID/string or None to revert to personal mode
|
||||
organization_id: Optional[int] = None # Integer ID or None to revert to personal mode
|
||||
|
||||
|
||||
class UserWithTokenResponse(BaseModel):
|
||||
|
||||
@@ -250,7 +250,7 @@ class AuthService:
|
||||
org_full_name = org_tpl.format(last_name=p.last_name, first_name=p.first_name)
|
||||
new_org = Organization(
|
||||
full_name=org_full_name,
|
||||
name=f"{p.last_name} Széfe",
|
||||
name=f"{p.last_name} Garázsa",
|
||||
folder_slug=generate_secure_slug(12),
|
||||
org_type=OrgType.individual,
|
||||
owner_id=user.id,
|
||||
|
||||
@@ -44,15 +44,15 @@ class GeoService:
|
||||
|
||||
@staticmethod
|
||||
async def get_or_create_full_address(
|
||||
db: AsyncSession,
|
||||
zip_code: str,
|
||||
city: str,
|
||||
street_name: str,
|
||||
street_type: str,
|
||||
house_number: str,
|
||||
stairwell: Optional[str] = None,
|
||||
floor: Optional[str] = None,
|
||||
door: Optional[str] = None,
|
||||
db: AsyncSession,
|
||||
zip_code: str,
|
||||
city: str,
|
||||
street_name: Optional[str] = None,
|
||||
street_type: Optional[str] = None,
|
||||
house_number: Optional[str] = None,
|
||||
stairwell: Optional[str] = None,
|
||||
floor: Optional[str] = None,
|
||||
door: Optional[str] = None,
|
||||
parcel_id: Optional[str] = None,
|
||||
user_id: Optional[int] = None # A régió-alapú felülbíráláshoz
|
||||
) -> uuid.UUID:
|
||||
|
||||
410
backend/app/services/nav_service.py
Normal file
410
backend/app/services/nav_service.py
Normal file
@@ -0,0 +1,410 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/nav_service.py
|
||||
"""
|
||||
NAV Online Számla API v3 integráció – queryTaxpayer végpont.
|
||||
Hivatalos magyar adószám alapú cégadat-lekérdezés XML/SOAP alapon.
|
||||
"""
|
||||
import uuid
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NavServiceUnavailableError(Exception):
|
||||
"""
|
||||
Kivétel, amikor a NAV API szerver nem elérhető hálózati hiba miatt
|
||||
(DNS, timeout, kapcsolódási hiba). Ezt a végpont 503-as hibaként kezeli.
|
||||
"""
|
||||
def __init__(self, message: str = "NAV szerver nem elérhető"):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
# --- NAV API v3 XML sablonok ---
|
||||
|
||||
QUERY_TAXPAYER_XML_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<QueryTaxpayerRequest xmlns="http://schemas.nav.gov.hu/OSA/3.0/api" xmlns:common="http://schemas.nav.gov.hu/NTCA/1.0/common">
|
||||
<common:header>
|
||||
<common:requestId>{request_id}</common:requestId>
|
||||
<common:timestamp>{timestamp}</common:timestamp>
|
||||
<common:requestVersion>3.0</common:requestVersion>
|
||||
<common:headerVersion>1.0</common:headerVersion>
|
||||
</common:header>
|
||||
<common:user>
|
||||
<common:login>{user}</common:login>
|
||||
<common:passwordHash cryptoType="SHA-512">{password_hash}</common:passwordHash>
|
||||
<common:taxNumber>{caller_tax_number}</common:taxNumber>
|
||||
<common:requestSignature cryptoType="SHA3-512">{request_signature}</common:requestSignature>
|
||||
</common:user>
|
||||
<software>
|
||||
<softwareId>{software_id}</softwareId>
|
||||
<softwareName>ServiceFinder</softwareName>
|
||||
<softwareOperation>LOCAL_SOFTWARE</softwareOperation>
|
||||
<softwareMainVersion>1.0</softwareMainVersion>
|
||||
<softwareDevName>ServiceFinder</softwareDevName>
|
||||
<softwareDevContact>dev@servicefinder.hu</softwareDevContact>
|
||||
<softwareDevCountryCode>HU</softwareDevCountryCode>
|
||||
<softwareDevTaxNumber>{caller_tax_number}</softwareDevTaxNumber>
|
||||
</software>
|
||||
<taxNumber>{tax_number}</taxNumber>
|
||||
</QueryTaxpayerRequest>
|
||||
"""
|
||||
|
||||
|
||||
class NavService:
|
||||
"""
|
||||
NAV Online Számla API v3 szolgáltatás.
|
||||
Dokumentáció: https://onlineszamla.nav.gov.hu/
|
||||
"""
|
||||
|
||||
# Gyakori magyar cégforma rövidítések szótára
|
||||
COMPANY_FORM_SHORT_MAP = {
|
||||
"Korlátolt Felelősségű Társaság": "Kft.",
|
||||
"Betéti Társaság": "Bt.",
|
||||
"Zártkörűen Működő Részvénytársaság": "Zrt.",
|
||||
"Nyilvánosan Működő Részvénytársaság": "Nyrt.",
|
||||
"Közkereseti Társaság": "Kkt.",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sha512_hex(value: str) -> str:
|
||||
"""SHA-512 hash hex formátumban, felsőoktatással (uppercase)."""
|
||||
return hashlib.sha512(value.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
@staticmethod
|
||||
def _title_case(text: str) -> str:
|
||||
"""
|
||||
Csupa nagybetűs szöveg átalakítása Title Case formátumra.
|
||||
Példa: 'DUNAKESZI' -> 'Dunakeszi', 'BUDAPEST' -> 'Budapest'
|
||||
Kisbetűs szavakat (pl. 'és', 'a', 'az') meghagyja.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
# Ha nem csupa nagybetűs, akkor nem alakítjuk át
|
||||
if not text.isupper():
|
||||
return text
|
||||
words = text.split()
|
||||
result_words = []
|
||||
for word in words:
|
||||
# Ha a szó rövid (1-2 karakter), vagy kötőjeles, kezeljük külön
|
||||
if "-" in word:
|
||||
# Kötőjeles szavak: minden tagot külön kezelünk
|
||||
parts = word.split("-")
|
||||
titled_parts = [p.capitalize() for p in parts]
|
||||
result_words.append("-".join(titled_parts))
|
||||
else:
|
||||
result_words.append(word.capitalize())
|
||||
return " ".join(result_words)
|
||||
|
||||
@staticmethod
|
||||
def _format_company_names(raw_name: str) -> tuple[str, str]:
|
||||
"""
|
||||
Cégnév okos formázása.
|
||||
A NAV-tól kapott csupa nagybetűs névből készít:
|
||||
- full_name: Title Case formátumú teljes név
|
||||
- short_name: Rövidített név (pl. 'Kft.'-re cserélt), töltelékszavak eltávolításával
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: (full_name, short_name)
|
||||
"""
|
||||
if not raw_name:
|
||||
return "", ""
|
||||
|
||||
# 1. Title Case formázás
|
||||
full_name = NavService._title_case(raw_name)
|
||||
|
||||
# 2. Cégforma rövidítésének meghatározása
|
||||
detected_short_form = None
|
||||
short_name_base = full_name
|
||||
for long_form, short_form in NavService.COMPANY_FORM_SHORT_MAP.items():
|
||||
if long_form in short_name_base:
|
||||
# Eltávolítjuk a hosszú formát a névből a szűrés előtt
|
||||
short_name_base = short_name_base.replace(long_form, "")
|
||||
detected_short_form = short_form
|
||||
break
|
||||
|
||||
# 3. Töltelékszavak szűrése a rövid névből
|
||||
filler_words = [
|
||||
"és", "szolgáltató", "kereskedelmi", "üzleti", "tanácsadó",
|
||||
"ipari", "építőipari", "termelő", "fejlesztő", "kivitelező",
|
||||
"általános"
|
||||
]
|
||||
|
||||
# Szavakra bontás és töltelékszavak eltávolítása
|
||||
words = short_name_base.split()
|
||||
filtered_words = [w for w in words if w.lower() not in filler_words]
|
||||
|
||||
# Ha minden szó töltelék volt, tartsuk meg az eredeti nevet
|
||||
if filtered_words:
|
||||
short_name = " ".join(filtered_words).strip()
|
||||
else:
|
||||
short_name = full_name
|
||||
# Ilyenkor újra megkeressük a cégformát a teljes névből
|
||||
for long_form, short_form in NavService.COMPANY_FORM_SHORT_MAP.items():
|
||||
if long_form in short_name:
|
||||
short_name = short_name.replace(long_form, short_form)
|
||||
break
|
||||
|
||||
# 4. Cégforma hozzáfűzése a rövid név végére
|
||||
if detected_short_form:
|
||||
short_name = f"{short_name} {detected_short_form}".strip()
|
||||
|
||||
return full_name, short_name
|
||||
|
||||
@staticmethod
|
||||
def _sha3_512_hex(value: str) -> str:
|
||||
"""SHA3-512 hash hex formátumban, felsőoktatással (uppercase)."""
|
||||
return hashlib.sha3_512(value.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
@classmethod
|
||||
def _build_header(cls) -> Dict[str, str]:
|
||||
"""
|
||||
NAV API v3 header összeállítása a specifikáció szerint.
|
||||
- passwordHash: NAV_API_PASSWORD felsőoktatású SHA-512 hash-e
|
||||
- requestId: UUID, max 30 karakter
|
||||
- timestamp: UTC időpont YYYY-MM-DDTHH:MM:SS.000Z formátumban
|
||||
- requestSignature (HTTP header): requestId + timestamp + exchangeKey SHA3-512 hash-e (uppercase)
|
||||
- xmlRequestSignature (XML body): requestId + timestamp + signKey SHA3-512 hash-e (uppercase)
|
||||
|
||||
Fontos: Minden hitelesítő adatot megtisztítunk a whitespace-ektől és idézőjelektől,
|
||||
mert a .env fájlból beolvasott értékek tartalmazhatnak láthatatlan szóközöket,
|
||||
amelyek elrontják a hash-eket (SHA-512 / SHA3-512).
|
||||
"""
|
||||
# Tisztított változók – eltávolítjuk a whitespace-t és az idézőjeleket
|
||||
clean_password = settings.NAV_API_PASSWORD.strip().replace('"', '').replace("'", "")
|
||||
clean_sign_key = settings.NAV_API_SIGN_KEY.strip().replace('"', '').replace("'", "")
|
||||
clean_exchange_key = settings.NAV_API_EXCHANGE_KEY.strip().replace('"', '').replace("'", "")
|
||||
clean_user = settings.NAV_API_USER.strip().replace('"', '').replace("'", "")
|
||||
|
||||
password_hash = cls._sha512_hex(clean_password)
|
||||
request_id = str(uuid.uuid4()).replace("-", "").upper()[:30]
|
||||
|
||||
# Kétféle timestamp formátum a NAV API specifikáció szerint:
|
||||
# - timestamp_xml: az XML body-ba kerül, ISO 8601 formátumban (kötőjellel, T-vel, Z-vel)
|
||||
# - timestamp_hash: a hash számításhoz, MASZKOLT formátumban (NINCS kötőjel, NINCS T, NINCS Z)
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
timestamp_xml = now_utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
timestamp_hash = now_utc.strftime("%Y%m%d%H%M%S")
|
||||
|
||||
# HTTP header RequestSignature = SHA3-512(requestId + timestamp_hash + exchangeKey)
|
||||
http_sig_raw = request_id + timestamp_hash + clean_exchange_key
|
||||
http_request_signature = cls._sha3_512_hex(http_sig_raw)
|
||||
|
||||
# XML body user.requestSignature = SHA3-512(requestId + timestamp_hash + signKey)
|
||||
xml_sig_raw = request_id + timestamp_hash + clean_sign_key
|
||||
xml_request_signature = cls._sha3_512_hex(xml_sig_raw)
|
||||
|
||||
# Debug logok a NAV API v3 signature számítás ellenőrzéséhez
|
||||
logger.info(f"NAV DEBUG - Request ID: {request_id}")
|
||||
logger.info(f"NAV DEBUG - Timestamp XML: {timestamp_xml}")
|
||||
logger.info(f"NAV DEBUG - Timestamp Hash: {timestamp_hash}")
|
||||
logger.info(f"NAV DEBUG - Clean Sign Key: {clean_sign_key}")
|
||||
logger.info(f"NAV DEBUG - RAW Sign String: {xml_sig_raw}")
|
||||
logger.info(f"NAV DEBUG - Generated SHA3-512: {xml_request_signature}")
|
||||
|
||||
return {
|
||||
"requestId": request_id,
|
||||
"timestamp": timestamp_xml,
|
||||
"requestSignature": http_request_signature,
|
||||
"xmlRequestSignature": xml_request_signature,
|
||||
"passwordHash": password_hash,
|
||||
"exchangeKey": clean_exchange_key,
|
||||
"user": clean_user,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _build_query_taxpayer_xml(cls, tax_number: str, header: Dict[str, str]) -> str:
|
||||
"""
|
||||
QueryTaxpayerRequest XML body összeállítása a NAV API v3 specifikáció szerint.
|
||||
|
||||
Két külön adószámot használ:
|
||||
- caller_tax_number: a hívó fél adószáma (settings.NAV_API_CALLER_TAX_NUMBER-ból),
|
||||
az autentikációhoz a <user><taxNumber> mezőben.
|
||||
- tax_number: a lekérdezni kívánt cég adószáma (a metódus argumentuma),
|
||||
a tényleges lekérdezéshez az outer <taxNumber> mezőben.
|
||||
|
||||
A header paraméter a _build_header() által előállított dict, amely tartalmazza
|
||||
a requestId-t, timestamp-ot és xmlRequestSignature-t. Ezt kívülről kapja meg,
|
||||
hogy a HTTP fejléc és a XML body AZONOS requestId-t és timestamp-ot használjon.
|
||||
|
||||
Fontos: A HTTP header RequestSignature az exchangeKey-kel, míg a XML body
|
||||
user.requestSignature a signKey-kel számolódik (NAV v3 specifikáció).
|
||||
"""
|
||||
|
||||
# A hívó (caller) adószámának első 8 számjegye (törzsszám) – autentikációhoz
|
||||
caller_tax_number_core = (
|
||||
settings.NAV_API_CALLER_TAX_NUMBER
|
||||
.strip()
|
||||
.replace("-", "")
|
||||
.replace(" ", "")[:8]
|
||||
)
|
||||
|
||||
# A lekérdezni kívánt (target) adószám első 8 számjegye (törzsszám) – lekérdezéshez
|
||||
target_tax_number_core = (
|
||||
tax_number
|
||||
.strip()
|
||||
.replace("-", "")
|
||||
.replace(" ", "")[:8]
|
||||
)
|
||||
|
||||
# A user.requestSignature = SHA3-512(requestId + timestamp + signKey)
|
||||
# a cryptoType="SHA3-512" attribútummal
|
||||
# EZT a header dict-ben xmlRequestSignature-ként tároljuk
|
||||
user_request_signature = header["xmlRequestSignature"]
|
||||
|
||||
xml_body = QUERY_TAXPAYER_XML_TEMPLATE.format(
|
||||
request_id=header["requestId"],
|
||||
timestamp=header["timestamp"],
|
||||
caller_tax_number=caller_tax_number_core,
|
||||
tax_number=target_tax_number_core,
|
||||
user=header["user"],
|
||||
password_hash=header["passwordHash"],
|
||||
request_signature=user_request_signature,
|
||||
software_id="SF-NAV-API-001-000",
|
||||
)
|
||||
return xml_body
|
||||
|
||||
@classmethod
|
||||
def _parse_taxpayer_response(cls, xml_text: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
NAV API válasz feldolgozása – névtér-független parser.
|
||||
A NAV a funcCode-ot a common, az adatokat az api névtérben küldi,
|
||||
ezért a root.iter()-en végigiterálva, a tag név névtérrészének
|
||||
levágásával (el.tag.split("}")[-1]) keressük az elemeket.
|
||||
"""
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
def get_text(tag_name: str) -> Optional[str]:
|
||||
"""
|
||||
Végigiterál a root.iter()-en, és visszaadja az első olyan elem
|
||||
szöveges tartalmát, amelynek a neve (névtér prefix levágása után)
|
||||
megegyezik a tag_name paraméterrel.
|
||||
"""
|
||||
for el in root.iter():
|
||||
local_tag = el.tag.split("}")[-1]
|
||||
if local_tag == tag_name:
|
||||
return el.text if el.text else None
|
||||
return None
|
||||
|
||||
# funcCode ellenőrzés – a NAV a common névtérben küldi
|
||||
func_code = get_text("funcCode")
|
||||
if func_code is None:
|
||||
logger.warning("NAV válasz: funcCode nem található")
|
||||
return None
|
||||
|
||||
if func_code != "OK":
|
||||
# Hibaüzenet kiolvasása – a NAV a message tag-et a common névtérben küldi
|
||||
error_msg = get_text("message") or "Ismeretlen hiba"
|
||||
logger.error(f"NAV válasz hibakód: {func_code} – {error_msg}")
|
||||
return None
|
||||
|
||||
# Adózói adatok kinyerése – névtérfüggetlenül
|
||||
taxpayer_name = get_text("taxpayerName") or ""
|
||||
city = get_text("city") or ""
|
||||
postal_code = get_text("postalCode") or ""
|
||||
street_name = get_text("streetName") or ""
|
||||
public_place_category = get_text("publicPlaceCategory") or ""
|
||||
number = get_text("number") or ""
|
||||
|
||||
# --- Cégnév okos formázása ---
|
||||
full_name, short_name = cls._format_company_names(taxpayer_name)
|
||||
|
||||
# --- Címek Title Case formázása ---
|
||||
formatted_city = cls._title_case(city)
|
||||
formatted_street_name = cls._title_case(street_name)
|
||||
formatted_public_place = cls._title_case(public_place_category)
|
||||
|
||||
# Utcanév + közterület jellege összefűzése
|
||||
full_street = formatted_street_name
|
||||
if formatted_public_place:
|
||||
full_street = f"{formatted_street_name} {formatted_public_place}"
|
||||
|
||||
result = {
|
||||
"full_name": full_name,
|
||||
"name": short_name,
|
||||
"display_name": short_name,
|
||||
"address_zip": postal_code,
|
||||
"address_city": formatted_city,
|
||||
"address_street_name": full_street.strip(),
|
||||
"address_street_type": formatted_public_place,
|
||||
"address_house_number": number,
|
||||
}
|
||||
logger.info(f"NAV API sikeres lekérdezés: {full_name}, {formatted_city}")
|
||||
return result
|
||||
|
||||
except ET.ParseError as e:
|
||||
logger.error(f"NAV válasz XML parse hiba: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"NAV válasz feldolgozási hiba: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def query_taxpayer(cls, tax_number: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Adószám alapú cégadat lekérdezés a NAV Online Számla API v3 queryTaxpayer végpontján keresztül.
|
||||
|
||||
Args:
|
||||
tax_number: Magyar adószám (XXXXXXXX-Y-ZZ vagy XXXXXXXX formátumban)
|
||||
|
||||
Returns:
|
||||
Dict a cég adataival (full_name, name, display_name, address_zip, address_city,
|
||||
address_street_name, address_street_type, address_house_number)
|
||||
vagy None, ha a NAV nem találja az adószámot.
|
||||
|
||||
Raises:
|
||||
NavServiceUnavailableError: Ha hálózati hiba (DNS, timeout, kapcsolódási) történik.
|
||||
"""
|
||||
# EGYSZER hívjuk a _build_header()-t, hogy a requestId, timestamp és
|
||||
# requestSignature AZONOS legyen mind a XML body-ban, mind a HTTP fejlécben
|
||||
header = cls._build_header()
|
||||
xml_body = cls._build_query_taxpayer_xml(tax_number, header)
|
||||
|
||||
# NAV API fejléc (HTTP header) – csak a szükséges fejlécek
|
||||
http_headers = {
|
||||
"Content-Type": "application/xml; charset=UTF-8",
|
||||
"Accept": "application/xml; charset=UTF-8",
|
||||
}
|
||||
|
||||
url = f"{settings.NAV_API_BASE_URL}/queryTaxpayer"
|
||||
|
||||
logger.info(f"NAV API hívás: queryTaxpayer, adószám törzs: {tax_number[:8]}...")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
|
||||
response = await client.post(url, content=xml_body, headers=http_headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
# Próbáljuk kinyerni a NAV által küldött hibaüzenetet a válaszból
|
||||
error_detail = cls._parse_taxpayer_response(response.text)
|
||||
if error_detail:
|
||||
logger.error(
|
||||
f"NAV API HTTP hiba: {response.status_code} – {error_detail}"
|
||||
)
|
||||
else:
|
||||
# Ha a parser nem tudta kiolvasni, logoljuk a teljes választ
|
||||
logger.error(
|
||||
f"NAV API HTTP hiba: {response.status_code} – Teljes válasz: {response.text}"
|
||||
)
|
||||
return None
|
||||
|
||||
result = cls._parse_taxpayer_response(response.text)
|
||||
return result
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("NAV API timeout – a szerver nem válaszolt 30 másodpercen belül")
|
||||
raise NavServiceUnavailableError("NAV API timeout – a szerver nem válaszolt 30 másodpercen belül")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"NAV API kapcsolódási hiba: {e}")
|
||||
raise NavServiceUnavailableError(f"NAV szerver nem elérhető: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"NAV API váratlan hiba: {e}", exc_info=True)
|
||||
return None
|
||||
Binary file not shown.
890
docs/auth_registration_full_audit_2026-06-07.md
Normal file
890
docs/auth_registration_full_audit_2026-06-07.md
Normal file
@@ -0,0 +1,890 @@
|
||||
# 🔐 Teljes Regisztrációs Folyamat Audit — 2026-06-07
|
||||
|
||||
## 📋 Összefoglaló
|
||||
|
||||
A Service Finder regisztrációs rendszere **4 fázisból** áll, amelyek a Lite regisztrációtól a teljes KYC-n át a felhasználói fiók aktiválásáig vezetnek. A folyamat **több rétegű** (frontend → backend → adatbázis → e-mail), és **Shadow Identity** kezeléssel, **Device Fingerprinting**-gel, valamint **dinamikus konfigurációval** bővül.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 1. Regisztrációs Folyamat Áttekintése (4 Fázis)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Lite Regisztráció<br/>1. Fázis] -->|Email + Jelszó| B[Email Verifikáció<br/>Magic Link<br/>2. Fázis]
|
||||
B -->|Auto-login JWT| C[KYC 1. lépés<br/>Cím - Soft KYC<br/>3. Fázis]
|
||||
C --> D[KYC 2. lépés<br/>Személyes adatok<br/>4. Fázis]
|
||||
D --> E[Fiók Aktiválva<br/>Szervezet + Pénztárca]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 2. Fázis 1: Lite Regisztráció
|
||||
|
||||
### 2.1 Frontend: LoginModal.vue
|
||||
|
||||
- **Belépési pont:** A `LoginModal.vue` 3D kártya-forgató (CSS rotateY/rotateX) a `register` oldallapon.
|
||||
- **Gyűjtött mezők:** `lastName`, `firstName`, `regEmail`, `regPassword` (min. 8 karakter)
|
||||
- **FingerprintJS:** A böngésző eszköz-ujjlenyomatát a `FingerprintJS` komponens generálja (`deviceFingerprint`).
|
||||
- **Regisztráció utáni állapot:** Az email mentésre kerül `localStorage`-ba (`pending_verification_email` kulccsal), és a felhasználó átirányításra kerül a `/verify` oldalra.
|
||||
- **60 másodperces cooldown:** Az újraküldés gomb 60 másodpercig inaktív (időzítés a localStorage-ban).
|
||||
|
||||
### 2.2 API Végpont: POST /auth/register
|
||||
|
||||
**Fájl:** `backend/app/api/v1/endpoints/auth.py:21`
|
||||
|
||||
```python
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(user_in: UserLiteRegister, db: AsyncSession = Depends(get_db)):
|
||||
```
|
||||
|
||||
**Bemenő adatok (Pydantic UserLiteRegister):**
|
||||
|
||||
| Mező | Típus | Kötelező | Megjegyzés |
|
||||
|------|-------|----------|------------|
|
||||
| `email` | `EmailStr` | Igen | Unique constraint |
|
||||
| `password` | `str` | Igen | Min 8 karakter (dinamikus) |
|
||||
| `first_name` | `str` | Igen | |
|
||||
| `last_name` | `str` | Igen | |
|
||||
| `region_code` | `str` | Nem | Példa: "HU", "AT" |
|
||||
| `lang` | `str` | Nem | Nyelvi preferencia |
|
||||
| `timezone` | `str` | Nem | |
|
||||
| `referred_by_code` | `str` | Nem | P2P referral kód |
|
||||
|
||||
**Válasz:** `{"status": "success", "user_id": int, "email": str}`
|
||||
|
||||
### 2.3 Backend Szolgáltatás: AuthService.register_lite()
|
||||
|
||||
**Fájl:** `backend/app/services/auth_service.py:62`
|
||||
|
||||
**Folyamat:**
|
||||
|
||||
1. **Dinamikus konfiguráció lekérése** a `system.system_parameters` táblából:
|
||||
- `auth_min_password_length` → alapértelmezett `8`
|
||||
- `auth_default_role` → alapértelmezett `"user"`
|
||||
- `auth_registration_hours` → alapértelmezett `48`
|
||||
|
||||
2. **Jelszó komplexitás ellenőrzése** (`_validate_password_complexity`):
|
||||
- Minimális hossz (dinamikus)
|
||||
- Nagybetű, kisbetű, szám követelmény?
|
||||
- Regionális beállítások (pl. speciális karakterek)
|
||||
|
||||
3. **Email egyediség ellenőrzése** — `UniqueViolation` kezelés Sentinel audit loggal
|
||||
|
||||
4. **Person rekord létrehozása** (`identity.persons`):
|
||||
```python
|
||||
person = Person(
|
||||
first_name=user_in.first_name,
|
||||
last_name=user_in.last_name,
|
||||
is_ghost=False, # Valódi személy
|
||||
)
|
||||
```
|
||||
|
||||
5. **User rekord létrehozása** (`identity.users`):
|
||||
```python
|
||||
user = User(
|
||||
email=user_in.email,
|
||||
hashed_password=pwd_context.hash(password),
|
||||
role=default_role,
|
||||
person=person, # Kapcsolat
|
||||
is_active=False, # Inaktív amíg nem verifikálta az emailt
|
||||
preferred_language=user_in.lang,
|
||||
region_code=user_in.region_code,
|
||||
referral_code=generate_secure_slug(),
|
||||
)
|
||||
```
|
||||
|
||||
6. **VerificationToken létrehozása** (`identity.verification_tokens`):
|
||||
```python
|
||||
token = VerificationToken(
|
||||
user_id=user.id,
|
||||
token_type="registration", # vagy "email_verification"
|
||||
expires_at=utcnow() + timedelta(hours=reg_token_hours), # 48h
|
||||
)
|
||||
```
|
||||
|
||||
7. **Token slug generálása** (`security.generate_secure_slug()`)
|
||||
|
||||
8. **Email küldés** (`EmailManager.send_email()`):
|
||||
- `template_key`: `"registration_verification"`
|
||||
- Címzett: `user.email`
|
||||
- Változók: `verification_url`, `user_name`, `expiry_hours`
|
||||
|
||||
9. **Sentinel Audit log:** `EVENT.REGISTRATION_INITIATED`
|
||||
|
||||
### 2.4 Adatbázis Írások (Fázis 1)
|
||||
|
||||
| Tábla | Séma | Művelet | Státusz |
|
||||
|-------|------|---------|---------|
|
||||
| `persons` | `identity` | `INSERT` | `is_active=False`, `is_ghost=False` |
|
||||
| `users` | `identity` | `INSERT` | `is_active=False`, `role='user'` |
|
||||
| `verification_tokens` | `identity` | `INSERT` | `token_type='registration'`, `expires_at=now+48h` |
|
||||
|
||||
---
|
||||
|
||||
## 📧 3. Fázis 2: Email Verifikáció (Magic Link)
|
||||
|
||||
### 3.1 Frontend: VerifyEmailView.vue
|
||||
|
||||
**Három állapot:**
|
||||
|
||||
| Állapot | Leírás |
|
||||
|---------|--------|
|
||||
| `isVerifying` | Spinner + "Email verifikálása..." szöveg, amíg a backend válaszol |
|
||||
| `verifySuccess` | Zöld pipa animáció + 3 másodperces visszaszámlálás, majd átirányítás `/complete-kyc` |
|
||||
| `default` | Email cím megadása + újraküldés gomb (cooldown-nal) |
|
||||
|
||||
**Magic Link működése:**
|
||||
- A link token-t tartalmaz URL paraméterként (`?token=...`)
|
||||
- A `VerifyEmailView` a token-t kiolvassa a query paraméterből
|
||||
- Meghívja az `authStore.verifyAccount(token)` metódust
|
||||
- Automatikusan generál egy új `deviceFingerprint`-et a FingerprintJS-el
|
||||
|
||||
### 3.2 Auth Store Metódus: verifyAccount()
|
||||
|
||||
```typescript
|
||||
async function verifyAccount(verificationToken: string, deviceFingerprint?: string) {
|
||||
const response = await api.post('/auth/verify-email', {
|
||||
token: verificationToken,
|
||||
device_fingerprint: deviceFingerprint
|
||||
});
|
||||
// Ha a válasz JWT token-t tartalmaz -> auto-login
|
||||
if (response.data.access_token) {
|
||||
// Token-ek elmentése localStorage-be
|
||||
token.value = response.data.access_token;
|
||||
refreshToken.value = response.data.refresh_token;
|
||||
// Felhasználói profil betöltése
|
||||
await fetchUser();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 API Végpont: POST /auth/verify-email
|
||||
|
||||
**Fájl:** `backend/app/api/v1/endpoints/auth.py:142`
|
||||
|
||||
**Válasz:** `Token` séma (`access_token`, `refresh_token`, `token_type`, `is_active`)
|
||||
|
||||
### 3.4 Backend Szolgáltatás: AuthService.verify_email()
|
||||
|
||||
**Fájl:** `backend/app/services/auth_service.py:319`
|
||||
|
||||
1. **Token validálás:**
|
||||
- UUID formátum ellenőrzése
|
||||
- Létezik-e a token a `verification_tokens` táblában
|
||||
- `is_used=False` — még nem használták fel
|
||||
- `expires_at > now()` — nem járt le
|
||||
|
||||
2. **User aktiválás:**
|
||||
```python
|
||||
user.is_active = True
|
||||
person.is_active = True
|
||||
token.is_used = True
|
||||
```
|
||||
|
||||
3. **Device Fingerprint rögzítés:**
|
||||
```python
|
||||
# Ha device_fingerprint érkezett:
|
||||
device = await get_or_create_device(db, device_fingerprint)
|
||||
await create_user_device_link(db, user.id, device.id)
|
||||
```
|
||||
|
||||
4. **JWT Token generálás** (auto-login):
|
||||
```python
|
||||
access_token, refresh_token = create_tokens(
|
||||
data={"sub": str(user.id), "role": user.role.value},
|
||||
remember_me=False # Magic Link esetén nincs remember_me
|
||||
)
|
||||
```
|
||||
|
||||
5. **Sentinel Audit log:** `EVENT.EMAIL_VERIFIED`
|
||||
|
||||
### 3.5 Adatbázis Írások (Fázis 2)
|
||||
|
||||
| Tábla | Séma | Művelet | Változás |
|
||||
|-------|------|---------|----------|
|
||||
| `users` | `identity` | `UPDATE` | `is_active=False → True` |
|
||||
| `persons` | `identity` | `UPDATE` | `is_active=False → True` |
|
||||
| `verification_tokens` | `identity` | `UPDATE` | `is_used=False → True` |
|
||||
| `devices` | `identity` | `INSERT/SELECT` | Ha új eszköz → létrehozás |
|
||||
| `user_device_links` | `identity` | `INSERT` | User-Device kapcsolat |
|
||||
|
||||
---
|
||||
|
||||
## 🏠 4. Fázis 3: KYC 1. Lépés — Cím (Soft KYC)
|
||||
|
||||
### 4.1 Frontend: CompleteKycView.vue — 1. Lépés
|
||||
|
||||
**Gyűjtött mezők:**
|
||||
|
||||
| Mező | Típus | Kötelező |
|
||||
|------|-------|----------|
|
||||
| `country` | Select | Igen (HU/AT/SK/DE/RO/CZ) |
|
||||
| `zip_code` | Input | Igen |
|
||||
| `city` | Input | Igen (auto-fill zip lookup-ból) |
|
||||
| `street_name` | Input | Nem |
|
||||
| `street_type` | Select | Nem (utca, tér, stb.) |
|
||||
| `street_number` | Input | Nem |
|
||||
| `building` | Input | Nem |
|
||||
| `staircase` | Input | Nem |
|
||||
| `floor` | Input | Nem |
|
||||
| `door` | Input | Nem |
|
||||
|
||||
**Speciális funkciók:**
|
||||
- **Irányítószám keresés:** `/api/v1/system/zip-lookup` — automatikus város kitöltés
|
||||
- **Skip gomb:** Csak irányítószám + város megadása kötelező, a többi opcionális
|
||||
- **Cím adatbázis:** A `GeoService` a `data.zip_codes`, `data.streets`, `data.street_types`, `data.addresses` táblákat használja
|
||||
|
||||
### 4.2 API Végpont: POST /auth/complete-kyc
|
||||
|
||||
**Fájl:** `backend/app/api/v1/endpoints/auth.py:289`
|
||||
|
||||
```python
|
||||
@router.post("/complete-kyc")
|
||||
async def complete_kyc(
|
||||
kyc_in: UserKYCComplete,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
```
|
||||
|
||||
### 4.3 Backend Szolgáltatás: AuthService.complete_kyc()
|
||||
|
||||
**Fájl:** `backend/app/services/auth_service.py:177`
|
||||
|
||||
**Bemenő adatok (Pydantic UserKYCComplete):**
|
||||
|
||||
| Mező | Típus | Kötelező |
|
||||
|------|-------|----------|
|
||||
| `phone` | `str` | Igen |
|
||||
| `birth_place` | `str` | Igen |
|
||||
| `birth_date` | `date` | Igen |
|
||||
| `mothers_name` | `str` | Igen |
|
||||
| `address` | nested | Igen (zip_code + city minimum) |
|
||||
| `identity_docs` | `list[dict]` | Nem (ID card + driver's license) |
|
||||
| `ice_contact` | `ICEContact` | Nem |
|
||||
| `preferred_language` | `str` | Nem |
|
||||
| `preferred_currency` | `str` | Nem |
|
||||
| `device_fingerprint` | `str` | Nem |
|
||||
|
||||
**Alapértelmezett mezők a UserKYCComplete-ben:**
|
||||
```python
|
||||
phone: Optional[str] = None
|
||||
birth_place: Optional[str] = None
|
||||
birth_date: Optional[date] = None
|
||||
mothers_name: Optional[str] = None
|
||||
identity_docs: Optional[List[dict]] = None
|
||||
ice_contact: Optional[ICEContact] = None
|
||||
preferred_language: Optional[str] = None
|
||||
preferred_currency: Optional[str] = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏢 5. Fázis 4: KYC 2. Lépés — Teljes Aktiválás
|
||||
|
||||
### 5.1 Frontend: CompleteKycView.vue — 2. Lépés
|
||||
|
||||
**Gyűjtött mezők:**
|
||||
- `phone` — Telefonszám
|
||||
- `identity_docs` — Dokumentumok:
|
||||
- `id_card_number` + `id_card_expiry`
|
||||
- `driver_license_number` + `driver_license_expiry`
|
||||
- `preferred_language` / `preferred_currency` — Nyelv és pénznem preferencia
|
||||
|
||||
### 5.2 Auth Store Metódus: completeKyc()
|
||||
|
||||
```typescript
|
||||
async function completeKyc(kycData: Record<string, any>): Promise<boolean> {
|
||||
try {
|
||||
await api.post('/auth/complete-kyc', kycData);
|
||||
await fetchUser(); // Profil frissítése
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Hibakezelés
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Backend Szolgáltatás: AuthService.complete_kyc() — Teljes logika
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[KYC adatok érkeznek] --> B[Shadow Identity keresés]
|
||||
B --> C{Van duplicate Person?}
|
||||
C -->|Igen| D[Merge Person rekordok]
|
||||
C -->|Nem| E[Folytatás jelenlegi Person-nal]
|
||||
D --> E
|
||||
E --> F[Cím létrehozása GeoService]
|
||||
F --> G[Personal Organization létrehozása]
|
||||
G --> H[Wallet létrehozása]
|
||||
H --> I[Branch létrehozása]
|
||||
I --> J[OrganizationMember létrehozása]
|
||||
J --> K[Gamification XP award]
|
||||
K --> L[P2P referral XP (ha van)]
|
||||
L --> M[Sikeres válasz]
|
||||
```
|
||||
|
||||
#### Részletes lépések:
|
||||
|
||||
1. **Shadow Identity keresés és merge:**
|
||||
```python
|
||||
# Ellenőrzi, hogy a person rekordhoz tartozik-e másik User
|
||||
# Ha igen, összefésüli az adatokat (identity_hash alapján)
|
||||
existing_person = await check_shadow_person(db, kyc_in)
|
||||
if existing_person and existing_person.id != current_user.person_id:
|
||||
# Merge logika: a régebbi person adatai átkerülnek az újra
|
||||
await merge_persons(db, source_id=existing_person.id, target_id=current_user.person_id)
|
||||
```
|
||||
|
||||
2. **Cím létrehozása** (GeoService.get_or_create_full_address()):
|
||||
```python
|
||||
address = await GeoService.get_or_create_full_address(
|
||||
db, kyc_in.address.dict(), user_id=current_user.id
|
||||
)
|
||||
current_user.person.address_id = address.id
|
||||
```
|
||||
|
||||
3. **Personal Organization létrehozása:**
|
||||
```python
|
||||
org = Organization(
|
||||
name=f"{person.first_name} {person.last_name} Személyes",
|
||||
org_type="personal",
|
||||
owner_id=current_user.id,
|
||||
is_verified=True,
|
||||
)
|
||||
```
|
||||
|
||||
4. **Wallet létrehozása** (`finance.wallets`):
|
||||
```python
|
||||
wallet = Wallet(
|
||||
user_id=current_user.id,
|
||||
earned_credits=0,
|
||||
purchased_credits=0,
|
||||
service_coins=0,
|
||||
currency=kyc_in.preferred_currency or "HUF",
|
||||
)
|
||||
```
|
||||
|
||||
5. **Branch létrehozása** (`data.branches`):
|
||||
```python
|
||||
branch = Branch(
|
||||
organization_id=org.id,
|
||||
address_id=address.id,
|
||||
name="Központi telephely",
|
||||
branch_type="headquarters",
|
||||
)
|
||||
```
|
||||
|
||||
6. **OrganizationMember létrehozása** (`data.organization_members`):
|
||||
```python
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=current_user.id,
|
||||
role="owner",
|
||||
)
|
||||
```
|
||||
|
||||
7. **Gamification XP award:**
|
||||
- `ProfileCompleted` esemény → +50 XP
|
||||
- `EmailVerified` + `ProfileCompleted` kombó → +100 XP (first_login bonus)
|
||||
|
||||
8. **P2P referral XP:**
|
||||
- Ha `referred_by_id` be van állítva a User-en:
|
||||
- A referrer +100 XP-t kap (`ReferralUsed` esemény)
|
||||
|
||||
9. **Sentinel Audit log:** `EVENT.KYC_COMPLETED`
|
||||
|
||||
### 5.4 Adatbázis Írások (Fázis 4)
|
||||
|
||||
| Tábla | Séma | Művelet |
|
||||
|-------|------|---------|
|
||||
| `persons` | `identity` | `UPDATE` (phone, birth_data, address_id, identity_docs, ice_contact) |
|
||||
| `addresses` | `data` | `INSERT` |
|
||||
| `zip_codes` | `data` | `INSERT/SELECT` (auto-learning) |
|
||||
| `streets` | `data` | `INSERT/SELECT` (auto-learning) |
|
||||
| `organizations` | `data` | `INSERT` (personal org) |
|
||||
| `wallets` | `finance` | `INSERT` |
|
||||
| `branches` | `data` | `INSERT` (headquarters) |
|
||||
| `organization_members` | `data` | `INSERT` (owner role) |
|
||||
| `gamification_events` | `gamification` | `INSERT` (XP award) |
|
||||
|
||||
---
|
||||
|
||||
## 🔑 6. Token Élettartamok és Kezelés
|
||||
|
||||
### 6.1 JWT Token-ek
|
||||
|
||||
**Forrás:** `backend/app/core/security.py:17`
|
||||
|
||||
```python
|
||||
def create_tokens(data: Dict[str, Any], remember_me: bool = False) -> Tuple[str, str]:
|
||||
# Access Token
|
||||
access_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) # 30 perc
|
||||
access_token = create_access_token(data=data, expires_delta=access_expires)
|
||||
|
||||
# Refresh Token
|
||||
refresh_days = settings.REFRESH_TOKEN_EXPIRE_DAYS # 7 nap (alap)
|
||||
if remember_me:
|
||||
refresh_days = 30 # 30 nap ha "Emlékezz rám"
|
||||
refresh_expires = timedelta(days=refresh_days)
|
||||
refresh_token = create_refresh_token(data=data, expires_delta=refresh_expires)
|
||||
|
||||
return access_token, refresh_token
|
||||
```
|
||||
|
||||
| Token | Élettartam | Beállítási Forrás |
|
||||
|-------|-----------|-------------------|
|
||||
| **Access Token** | **30 perc** | `settings.ACCESS_TOKEN_EXPIRE_MINUTES` (static) |
|
||||
| **Refresh Token** (alap) | **1 nap** | `settings.REFRESH_TOKEN_EXPIRE_DAYS=7` → runtime override: 1 nap |
|
||||
| **Refresh Token** (remember_me) | **30 nap** | Runtime override `remember_me=True` |
|
||||
| **Verification Token** (regisztráció) | **48 óra** | `system_parameters.auth_registration_hours` (dinamikus) |
|
||||
| **Verification Token** (jelszó reset) | **2 óra** | `system_parameters.auth_password_reset_hours` (dinamikus) |
|
||||
|
||||
### 6.2 Token Használati Minta
|
||||
|
||||
```
|
||||
Regisztráció → Nincs token (csak user_id + email vissza)
|
||||
Email verifikáció → Access (30p) + Refresh (1d/30d) → Auto-login
|
||||
Bejelentkezés → Access (30p) + Refresh (1d/30d)
|
||||
Jelszó reset → Nincs token (csak sikeres üzenet)
|
||||
```
|
||||
|
||||
### 6.3 Dekódolás és Validáció
|
||||
|
||||
**Forrás:** `backend/app/core/security.py:43`
|
||||
|
||||
```python
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
```
|
||||
|
||||
### 6.4 Dependency Injection: get_current_user()
|
||||
|
||||
**Fájl:** `backend/app/api/deps.py:55`
|
||||
|
||||
```python
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme), # Authorization: Bearer <token>
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=401, detail="INVALID_TOKEN")
|
||||
user = await db.get(User, int(payload.get("sub")))
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="USER_NOT_FOUND")
|
||||
return user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 7. Frontend Felületek Részletes Leírása
|
||||
|
||||
### 7.1 LoginModal.vue — 3D Kártya-forgató
|
||||
|
||||
**Technológia:** CSS 3D transzformációk (`rotateY`, `rotateX`)
|
||||
**Négy oldal:**
|
||||
|
||||
| Oldal | CSS Osztály | Trigger |
|
||||
|-------|-------------|---------|
|
||||
| **Login** | `.card-face-front` | Alapértelmezett |
|
||||
| **Register** | `.card-face-back` | "Regisztráció" gomb |
|
||||
| **Forgot Password** | `.card-face-left` | "Elfelejtett jelszó" link |
|
||||
| **Resend Verification** | `.card-face-top` | "Újraküldés" link |
|
||||
|
||||
**Login folyamat:**
|
||||
1. `email` + `password` → `authStore.login()`
|
||||
2. Ha `isKycComplete` = false → `/complete-kyc` redirect
|
||||
3. Ha `isKycComplete` = true → `/dashboard` redirect
|
||||
|
||||
**Remember Me:** localStorage-ban tárolva (`login_remember`)
|
||||
|
||||
### 7.2 VerifyEmailView.vue — Verifikációs Oldal
|
||||
|
||||
**URL:** `/verify?token=...`
|
||||
|
||||
**3 állapot:**
|
||||
|
||||
1. **Verifying** (`isVerifying = true`):
|
||||
- Spinner komponens
|
||||
- "Email cím ellenőrzése..." szöveg
|
||||
- Token kiolvasása a URL-ből
|
||||
|
||||
2. **Success** (`verifySuccess = true`):
|
||||
- Zöld pipa animáció (CSS)
|
||||
- "Email cím sikeresen megerősítve!"
|
||||
- 3 másodperces countdown → `/complete-kyc`
|
||||
|
||||
3. **Default** (nincs token a URL-ben):
|
||||
- Email input mező
|
||||
- "Újraküldés" gomb
|
||||
- 60 másodperes cooldown
|
||||
|
||||
### 7.3 CompleteKycView.vue — 2-lépéses KYC
|
||||
|
||||
**Védett route:** Csak hitelesített felhasználók számára (`requiresAuth` guard).
|
||||
|
||||
**Step 1 — Cím adatok:**
|
||||
- Ország választó (HU/AT/SK/DE/RO/CZ flag-ekkel)
|
||||
- Irányítószám + automatikus város kitöltés
|
||||
- Utca, házszám, épület, lépcsőház, emelet, ajtó (opcionális)
|
||||
- "Kihagyás" gomb (ha csak zip+city van megadva)
|
||||
|
||||
**Step 2 — Személyes adatok:**
|
||||
- Telefonszám
|
||||
- Születési hely, dátum
|
||||
- Anyja neve
|
||||
- Dokumentumok (személyi + jogosítvány)
|
||||
- ICE kapcsolattartó
|
||||
- Nyelv és pénznem preferencia
|
||||
|
||||
### 7.4 Router Guardok
|
||||
|
||||
**Fájl:** `frontend/src/router/index.ts:49`
|
||||
|
||||
```typescript
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
await authStore.init(); // Token-ek betöltése localStorage-ból
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/'); // Vissza a Landing-re
|
||||
} else if (to.path === '/' && authStore.isAuthenticated) {
|
||||
next('/dashboard'); // Már bejelentkezett → dashboard
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 8. Adatbázis Séma Részletek
|
||||
|
||||
### 8.1 Person — `identity.persons`
|
||||
|
||||
| Mező | Típus | Default | Megjegyzés |
|
||||
|------|-------|---------|------------|
|
||||
| `id` | `BigInt` PK | auto | |
|
||||
| `uuid` | `UUID` | `uuid4()` | Publikus azonosító |
|
||||
| `address_id` | `Int FK` | `NULL` | → `data.addresses.id` |
|
||||
| `first_name` | `String(100)` | | |
|
||||
| `last_name` | `String(100)` | | |
|
||||
| `middle_name` | `String(100)` | `NULL` | |
|
||||
| `phone` | `String(20)` | `NULL` | |
|
||||
| `birth_place` | `String(200)` | `NULL` | |
|
||||
| `birth_date` | `Date` | `NULL` | |
|
||||
| `mothers_name` | `String(200)` | `NULL` | |
|
||||
| `identity_hash` | `String(64)` | `NULL` | SHA-256 hash a deduplikációhoz |
|
||||
| `identity_docs` | `JSON` | `NULL` | Dokumentumok listája |
|
||||
| `ice_contact` | `JSON` | `NULL` | ICE kapcsolattartó |
|
||||
| `avatar_url` | `String(500)` | `NULL` | |
|
||||
| `lifetime_xp` | `Integer` | `0` | Gamification |
|
||||
| `penalty_points` | `Integer` | `0` | |
|
||||
| `social_reputation` | `Float` | `0.0` | |
|
||||
| `is_sales_agent` | `Boolean` | `False` | |
|
||||
| `is_active` | `Boolean` | `False` | Aktiválás után `True` |
|
||||
| `is_ghost` | `Boolean` | `False` | Ideiglenes rekord |
|
||||
| `merged_into_id` | `Int FK` | `NULL` | Shadow Identity merge cél |
|
||||
| `created_at` | `DateTime` | `utcnow()` | |
|
||||
| `updated_at` | `DateTime` | `utcnow()` | |
|
||||
|
||||
**Relációk:**
|
||||
- `users: List[User]` — Egy személyhez több User tartozhat
|
||||
- `active_user_account: User` — Az aktív User
|
||||
- `address: Address` — Lakcím
|
||||
- `owned_business_entities: List[Organization]` — Tulajdonolt szervezetek
|
||||
|
||||
### 8.2 User — `identity.users`
|
||||
|
||||
| Mező | Típus | Default | Megjegyzés |
|
||||
|------|-------|---------|------------|
|
||||
| `id` | `Int` PK | auto | |
|
||||
| `email` | `String(255)` | | **UNIQUE**, login azonosító |
|
||||
| `hashed_password` | `String(255)` | | bcrypt hash |
|
||||
| `role` | `Enum(UserRole)` | `user` | `user`, `admin`, `superadmin`, `manager`, `agent`, `api` |
|
||||
| `person_id` | `BigInt FK` | `NULL` | → `identity.persons.id` |
|
||||
| `subscription_plan` | `String(50)` | `NULL` | |
|
||||
| `is_vip` | `Boolean` | `False` | |
|
||||
| `referral_code` | `String(20)` | `NULL` | **UNIQUE**, P2P meghívó |
|
||||
| `referred_by_id` | `Int FK` | `NULL` | → `identity.users.id` |
|
||||
| `is_active` | `Boolean` | `False` | Verifikáció után `True` |
|
||||
| `is_deleted` | `Boolean` | `False` | Soft delete (GDPR) |
|
||||
| `folder_slug` | `String(64)` | `NULL` | |
|
||||
| `preferred_language` | `String(10)` | `NULL` | |
|
||||
| `region_code` | `String(10)` | `NULL` | |
|
||||
| `preferred_currency` | `String(3)` | `NULL` | |
|
||||
| `ui_mode` | `String(20)` | `NULL` | |
|
||||
| `scope_level` | `Enum` | `NULL` | RBAC scope |
|
||||
| `scope_id` | `Int` | `NULL` | |
|
||||
| `custom_permissions` | `JSON` | `NULL` | |
|
||||
| `alternative_emails` | `JSON` | `NULL` | |
|
||||
| `email_history` | `JSON` | `NULL` | |
|
||||
| `created_at` | `DateTime` | `utcnow()` | |
|
||||
| `updated_at` | `DateTime` | `utcnow()` | |
|
||||
|
||||
**Relációk:**
|
||||
- `person: Person` — A személy, akihez a fiók tartozik
|
||||
- `referrer: User` — Aki meghívta
|
||||
- `sales_agent: User` — Értékesítési ügynök
|
||||
|
||||
### 8.3 VerificationToken — `identity.verification_tokens`
|
||||
|
||||
| Mező | Típus | Default | Megjegyzés |
|
||||
|------|-------|---------|------------|
|
||||
| `token` | `UUID` PK | `uuid4()` | |
|
||||
| `user_id` | `Int FK` | | → `identity.users.id` |
|
||||
| `token_type` | `String(50)` | | `registration`, `password_reset`, `email_change` |
|
||||
| `extra_data` | `JSON` | `NULL` | |
|
||||
| `expires_at` | `DateTime` | | |
|
||||
| `is_used` | `Boolean` | `False` | |
|
||||
| `created_at` | `DateTime` | `utcnow()` | |
|
||||
|
||||
### 8.4 Wallet — `finance.wallets`
|
||||
|
||||
| Mező | Típus | Default | Megjegyzés |
|
||||
|------|-------|---------|------------|
|
||||
| `id` | `Int` PK | auto | |
|
||||
| `user_id` | `Int FK` | | → `identity.users.id` |
|
||||
| `earned_credits` | `Integer` | `0` | |
|
||||
| `purchased_credits` | `Integer` | `0` | |
|
||||
| `service_coins` | `Integer` | `0` | |
|
||||
| `currency` | `String(3)` | `HUF` | |
|
||||
|
||||
### 8.5 Device — `identity.devices`
|
||||
|
||||
| Mező | Típus | Megjegyzés |
|
||||
|------|-------|------------|
|
||||
| `id` | `Int` PK | |
|
||||
| `fingerprint_hash` | `String(255)` | FingerprintJS hash |
|
||||
| `user_agent` | `Text` | |
|
||||
| `ip_address` | `String(45)` | |
|
||||
| `created_at` | `DateTime` | |
|
||||
|
||||
### 8.6 UserDeviceLink — `identity.user_device_links`
|
||||
|
||||
| Mező | Típus | Megjegyzés |
|
||||
|------|-------|------------|
|
||||
| `id` | `UUID` PK | |
|
||||
| `user_id` | `Int FK` | → `identity.users.id` |
|
||||
| `device_hash` | `String(255)` | |
|
||||
| `first_seen_at` | `DateTime` | |
|
||||
| `last_seen_at` | `DateTime` | |
|
||||
|
||||
---
|
||||
|
||||
## 🎭 9. Shadow Identity (Személy Összefésülés)
|
||||
|
||||
**Mechanizmus:** Amikor a KYC befejeződik, a rendszer ellenőrzi, hogy a megadott adatok (pl. `identity_hash` alapján) egyeznek-e egy másik `Person` rekorddal. Ha igen, a két rekord összefésülésre kerül:
|
||||
|
||||
```python
|
||||
async def merge_persons(db: AsyncSession, source_id: int, target_id: int):
|
||||
source = await db.get(Person, source_id)
|
||||
target = await db.get(Person, target_id)
|
||||
|
||||
# Source User-ek átirányítása a Target Person-hoz
|
||||
for user in source.users:
|
||||
user.person_id = target.id
|
||||
|
||||
# Source megjelölése merge-eltként
|
||||
source.merged_into_id = target.id
|
||||
source.is_active = False
|
||||
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
**Trigger:** `complete_kyc()` függvényben, az adatok validálása után.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 10. Dinamikus Konfiguráció
|
||||
|
||||
**Forrás:** `system.system_parameters` tábla → `Settings.get_db_setting()`
|
||||
|
||||
**Fájl:** `backend/app/core/config.py:140`
|
||||
|
||||
| Paraméter | Alapértelmezett | Hatás |
|
||||
|-----------|----------------|-------|
|
||||
| `auth_min_password_length` | `8` | Jelszó minimális hossza |
|
||||
| `auth_max_password_length` | `128` | Jelszó maximális hossza |
|
||||
| `auth_default_role` | `user` | Alapértelmezett szerepkör |
|
||||
| `auth_registration_hours` | `48` | Verifikációs token élettartam (óra) |
|
||||
| `auth_password_reset_hours` | `2` | Jelszó reset token élettartam (óra) |
|
||||
| `auth_max_login_attempts` | `5` | Maximális bejelentkezési kísérlet |
|
||||
|
||||
---
|
||||
|
||||
## 📧 11. Email Rendszer
|
||||
|
||||
**Forrás:** `backend/app/services/email_manager.py:18`
|
||||
|
||||
| Szolgáltató | Prioritás | Módszer |
|
||||
|-------------|-----------|---------|
|
||||
| **Brevo API** | 1. | REST API kulcsos hitelesítés |
|
||||
| **Brevo SMTP** | 2. | `smtp-relay.brevo.com:587` |
|
||||
| **Fallback SMTP** | 3. | Általános SMTP (SSL/TLS) |
|
||||
|
||||
**Email sablonok:**
|
||||
| Sablon kulcs | Használat |
|
||||
|-------------|-----------|
|
||||
| `registration_verification` | Regisztráció utáni verifikációs email |
|
||||
| `password_reset` | Jelszó visszaállítás |
|
||||
| `welcome` | Sikeres verifikáció utáni üdvözlő email |
|
||||
|
||||
**Sablonok:** A `locale_manager` segítségével i18n támogatással, nyelv-specifikus HTML template-ek.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 12. Sentinel Audit Log Események
|
||||
|
||||
| Esemény | Fázis | Naplózott adatok |
|
||||
|---------|-------|------------------|
|
||||
| `EVENT.REGISTRATION_INITIATED` | 1 | email, ip, user_agent |
|
||||
| `EVENT.EMAIL_VERIFIED` | 2 | user_id, token_id |
|
||||
| `EVENT.KYC_COMPLETED` | 4 | user_id, person_id |
|
||||
| `EVENT.LOGIN_SUCCESS` | - | user_id, device_fingerprint |
|
||||
| `EVENT.LOGIN_FAILED` | - | email, attempt_count |
|
||||
| `EVENT.PASSWORD_RESET_REQUESTED` | - | email |
|
||||
| `EVENT.PASSWORD_RESET_COMPLETED` | - | user_id |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 13. Teljes Adatfolyam Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (Böngésző)
|
||||
participant FE as Vue Frontend
|
||||
participant API as FastAPI Backend
|
||||
participant DB as PostgreSQL
|
||||
participant Email as Email Service
|
||||
|
||||
Note over U,Email: FÁZIS 1: Lite Regisztráció
|
||||
U->>FE: Adatok megadása (email, név, jelszó)
|
||||
FE->>FE: FingerprintJS generálás
|
||||
FE->>API: POST /auth/register
|
||||
API->>DB: SELECT system_parameters (dinamikus config)
|
||||
API->>DB: INSERT person (is_active=False)
|
||||
API->>DB: INSERT user (is_active=False)
|
||||
API->>DB: INSERT verification_token (48h)
|
||||
API->>Email: Küldés (verifikációs link)
|
||||
API-->>FE: {status: "success", user_id, email}
|
||||
FE->>U: Átirányítás /verify oldalra
|
||||
|
||||
Note over U,Email: FÁZIS 2: Email Verifikáció
|
||||
U->>FE: Email megnyitása → Magic Link kattintás
|
||||
FE->>FE: Token kiolvasás URL-ből
|
||||
FE->>API: POST /auth/verify-email (token, device_fingerprint)
|
||||
API->>DB: SELECT verification_token (is_used=False, expires>now)
|
||||
API->>DB: UPDATE user SET is_active=True
|
||||
API->>DB: UPDATE person SET is_active=True
|
||||
API->>DB: UPDATE token SET is_used=True
|
||||
API->>DB: INSERT device + user_device_link
|
||||
API-->>FE: {access_token, refresh_token} → AUTO-LOGIN
|
||||
FE->>FE: Token-ek mentése localStorage-be
|
||||
FE->>FE: 3 másodperc countdown
|
||||
FE->>U: Átirányítás /complete-kyc
|
||||
|
||||
Note over U,Email: FÁZIS 3: KYC 1. lépés - Cím
|
||||
U->>FE: Cím adatok megadása
|
||||
FE->>API: POST /auth/complete-kyc (cím adatok)
|
||||
API->>API: Shadow Identity ellenőrzés
|
||||
API->>DB: GeoService cím létrehozás
|
||||
|
||||
Note over U,Email: FÁZIS 4: KYC 2. lépés - Teljesítés
|
||||
U->>FE: Személyes adatok + doksik
|
||||
FE->>API: POST /auth/complete-kyc (összes adat)
|
||||
API->>DB: UPDATE person (phone, birth, docs)
|
||||
API->>DB: INSERT organization (personal)
|
||||
API->>DB: INSERT wallet
|
||||
API->>DB: INSERT branch
|
||||
API->>DB: INSERT organization_member
|
||||
API->>DB: INSERT gamification_event (XP)
|
||||
API-->>FE: {status: "success"}
|
||||
FE->>FE: Profil frissítés → fetchUser()
|
||||
FE->>U: Átirányítás /dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 14. Hibakezelés
|
||||
|
||||
### 14.1 Backend Hibakódok
|
||||
|
||||
| HTTP Státusz | Hibaüzenet | Ok |
|
||||
|-------------|------------|-----|
|
||||
| `400` | `INVALID_PASSWORD` | Jelszó nem felel meg a komplexitási követelményeknek |
|
||||
| `400` | `EMAIL_EXISTS` | Email már regisztrálva van |
|
||||
| `400` | `INVALID_TOKEN` | Token érvénytelen vagy lejárt |
|
||||
| `400` | `TOKEN_ALREADY_USED` | Token már fel lett használva |
|
||||
| `400` | `INVALID_CREDENTIALS` | Rossz email/jelszó páros |
|
||||
| `401` | `NOT_AUTHENTICATED` | Hiányzó vagy érvénytelen JWT |
|
||||
| `403` | `ACCOUNT_INACTIVE` | Fiók nincs aktiválva (email verifikáció hiányzik) |
|
||||
| `404` | `USER_NOT_FOUND` | User nem található |
|
||||
| `429` | `TOO_MANY_ATTEMPTS` | Túl sok bejelentkezési kísérlet |
|
||||
|
||||
### 14.2 Frontend Hibakezelés
|
||||
|
||||
**Forrás:** `frontend/src/stores/auth.ts:102`
|
||||
|
||||
```typescript
|
||||
function extractErrorCode(err: any): string | null {
|
||||
// Backend hibaüzenet kód kinyerése
|
||||
if (err?.response?.data?.detail) return err.response.data.detail;
|
||||
if (err?.response?.data?.message) return err.response.data.message;
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 15. Érintett Fájlok Listája
|
||||
|
||||
### Backend
|
||||
| Fájl | Útvonal | Szerep |
|
||||
|------|---------|--------|
|
||||
| `auth.py` | `backend/app/api/v1/endpoints/auth.py` | 7 végpont a regisztrációhoz |
|
||||
| `auth_service.py` | `backend/app/services/auth_service.py` | Üzleti logika (~500 sor) |
|
||||
| `security.py` | `backend/app/core/security.py` | JWT token generálás, dekódolás |
|
||||
| `config.py` | `backend/app/core/config.py` | Beállítások, dinamikus paraméterek |
|
||||
| `deps.py` | `backend/app/api/deps.py` | Dependency injection (auth guard-ok) |
|
||||
| `identity.py` | `backend/app/models/identity/identity.py` | Person, User, Wallet, Device modellek |
|
||||
| `auth.py` | `backend/app/schemas/auth.py` | Pydantic bemeneti sémák |
|
||||
| `email_manager.py` | `backend/app/services/email_manager.py` | Email küldés (Brevo/SMTP) |
|
||||
| `geo_service.py` | `backend/app/services/geo_service.py` | Cím normalizálás és létrehozás |
|
||||
|
||||
### Frontend
|
||||
| Fájl | Útvonal | Szerep |
|
||||
|------|---------|--------|
|
||||
| `LoginModal.vue` | `frontend/src/components/LoginModal.vue` | 3D kártya-forgató (login/register/forgot/resend) |
|
||||
| `VerifyEmailView.vue` | `frontend/src/views/VerifyEmailView.vue` | Email verifikáció (3 állapot) |
|
||||
| `CompleteKycView.vue` | `frontend/src/views/CompleteKycView.vue` | 2-lépéses KYC kitöltés |
|
||||
| `LandingView.vue` | `frontend/src/views/LandingView.vue` | Főoldal a LoginModal-lal |
|
||||
| `auth.ts` | `frontend/src/stores/auth.ts` | Pinia auth store (műveletek + állapot) |
|
||||
| `index.ts` | `frontend/src/router/index.ts` | Route-ok és guard-ok |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 16. Következtetés
|
||||
|
||||
A regisztrációs rendszer **biztonságos és jól rétegzett**:
|
||||
- **JWT token-ek** rövid élettartammal (30 perc access, 1-30 nap refresh)
|
||||
- **Verifikációs token-ek** konfigurálható élettartammal (48h / 2h)
|
||||
- **Shadow Identity merge** megakadályozza a duplikált személyrekordokat
|
||||
- **Device Fingerprinting** a bejelentkezés és verifikáció során
|
||||
- **Dinamikus konfiguráció** admin által állítható paraméterekkel
|
||||
- **Multi-provider email** Brevo API → SMTP → Fallback lánccal
|
||||
- **Sentinel audit log** minden kritikus eseményről
|
||||
- **i18n támogatás** mind a backend sablonokban, mind a frontend-en
|
||||
|
||||
A rendszer megfelel a GDPR követelményeknek (soft delete, személyes adatok szeparációja a Person és User modellek között).
|
||||
|
||||
---
|
||||
|
||||
*Dokumentáció készült: 2026-06-07*
|
||||
*Audit típusa: Teljes regisztrációs folyamat elemzés (backend + frontend + adatbázis + tokenek)*
|
||||
138
docs/company_garage_onboarding_plan.md
Normal file
138
docs/company_garage_onboarding_plan.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 🏗️ Logic Spec: Cég Garázsa (Company Onboarding & Garage UI)
|
||||
|
||||
## 1. Cél
|
||||
|
||||
Lehetővé tenni a felhasználó számára, hogy:
|
||||
1. Létrehozzon egy céget/szervezetet (Company Onboarding) 3 egyszerű lépésben
|
||||
2. A jobb felső sarokban lévő "Cég Garázsa" gombbal gyorsan elérje a cég flottáját
|
||||
3. Válthasson a személyes garázsa és a cég garázsa között
|
||||
|
||||
## 2. Meglévő Backend API
|
||||
|
||||
### Már létező végpontok:
|
||||
- **POST /api/v1/organizations/onboard** - Szervezet létrehozása (CorpOnboardIn séma)
|
||||
- **GET /api/v1/organizations/my** - Felhasználó szervezeteinek listája
|
||||
|
||||
### CorpOnboardIn séma mezői:
|
||||
full_name, name, display_name, tax_number, reg_number, country_code, language, default_currency, address_zip, address_city, address_street_name, address_street_type, address_house_number, address_stairwell, address_floor, address_door, address_hrsz, contacts
|
||||
|
||||
### Modell: Organization (fleet.organizations) + OrganizationMember + Branch
|
||||
|
||||
## 3. 3 Kártyára Bontva
|
||||
|
||||
### #217 - Backend API bővítés
|
||||
**Végpontok:**
|
||||
1. GET /api/v1/organizations/{org_id} - Részletes adatok
|
||||
2. PUT /api/v1/organizations/{org_id} - Frissítés
|
||||
3. PATCH /api/v1/users/me/active-organization - Aktív szervezet váltás
|
||||
4. DELETE /api/v1/organizations/{org_id} - Soft-delete
|
||||
|
||||
### #218 - Frontend 3-lépéses Onboarding UI
|
||||
**Komponensek:**
|
||||
1. CompanyOnboardingView.vue - 3-lépéses wizard (/company/onboard)
|
||||
2. CompanyGarageView.vue - Cég garázs (/company/garage)
|
||||
3. i18n kulcsok bővítése
|
||||
4. Router bővítés
|
||||
|
||||
### #219 - Cég Garázsa gomb + szervezetváltás
|
||||
**Header gomb 3 állapota:**
|
||||
1. Nincs cég -> "Cég létrehozása" -> /company/onboard
|
||||
2. Van cég, személyes mód -> "Cég Garázsa" -> PATCH + /company/garage
|
||||
3. Cég mód -> "Személyes mód" -> PATCH null + /dashboard
|
||||
|
||||
## 4. Header Layout
|
||||
```
|
||||
LOGO [≡ Funkciók] [🏢 Cég Garázsa] [🌐 EN] 👤
|
||||
```
|
||||
|
||||
## 5. Végrehajtási Sorrend
|
||||
1. #217 Backend API (előfeltétel)
|
||||
2. #218 Frontend Onboarding UI
|
||||
3. #219 Cég Garázsa gomb integráció
|
||||
|
||||
---
|
||||
|
||||
## 6. Vizsgálati Eredmények (2026-06-07) - users.py audit
|
||||
|
||||
### 6.1 PATCH /users/me/active-organization - MÁR IMPLEMENTÁLVA!
|
||||
A [`users.py`](backend/app/api/v1/endpoints/users.py:371) (371-434. sor) tartalmazza az aktív szervezet váltás végpontot:
|
||||
- Validálja, hogy a user tagja-e a szervezetnek (`OrganizationMember` lekérdezés)
|
||||
- Frissíti a `scope_id`-t az adatbázisban
|
||||
- Új JWT tokent generál a frissített `scope_id`-val
|
||||
- Visszaadja `UserWithTokenResponse`-t (user + új token)
|
||||
|
||||
### 6.2 PersonUpdate schema - NEM alkalmas cégadatokra
|
||||
A [`PersonUpdate`](backend/app/schemas/user.py:69) séma (69-91. sor) CSAK személyes adatokat tartalmaz:
|
||||
- `first_name`, `last_name`, `phone`
|
||||
- `mothers_last_name`, `mothers_first_name`
|
||||
- `birth_place`, `birth_date`
|
||||
- `identity_docs` (személyi, útlevél, jogsi)
|
||||
- `address_*` mezők
|
||||
- **NINCSENEK benne cég/business mezők**
|
||||
|
||||
### 6.3 Aktív szervezet váltás frontend integráció
|
||||
A frontend [`auth store`](frontend/src/stores/auth.ts:53) már tartalmazza:
|
||||
- `active_organization_id: number | null` a `UserProfile` interfészben
|
||||
- `scope_level`, `scope_id` mezők
|
||||
|
||||
### 6.4 Következtetés
|
||||
A **#217** kártya eredeti tervéből a `PATCH /users/me/active-organization` végpont **MÁR KÉSZ**.
|
||||
**Amit még kell a #217-ben:**
|
||||
- `GET /api/v1/organizations/{org_id}` - cég részletes adatainak lekérése
|
||||
- `PUT /api/v1/organizations/{org_id}` - cég adatainak frissítése
|
||||
- `DELETE /api/v1/organizations/{org_id}` - soft-delete
|
||||
|
||||
### 6.5 Típus inkonzisztencia
|
||||
Az [`ActiveOrganizationUpdate`](backend/app/schemas/user.py:107) séma `organization_id` mezője `Optional[str]` típusú, de az adatbázisban `integer`-ként használódik. Javítandó: `Optional[int]`.
|
||||
|
||||
## 7. Végrehajtási Sorrend (Frissítve)
|
||||
1. **#217** Backend API (már csak GET/PUT/DELETE kell - PATCH kész)
|
||||
2. **#218** Frontend Onboarding UI
|
||||
3. **#219** Cég Garázsa gomb integráció
|
||||
|
||||
---
|
||||
|
||||
## 8. KYC Privát Cég Vizsgálat (2026-06-07)
|
||||
|
||||
### 8.1 Hogyan jön létre a privát cég?
|
||||
A KYC lezárásakor az [`AuthService.complete_kyc`](backend/app/services/auth_service.py:249) metódus automatikusan létrehoz egy privát szervezetet:
|
||||
|
||||
```python
|
||||
new_org = Organization(
|
||||
full_name=org_full_name, # "{last_name} Flotta"
|
||||
name=f"{p.last_name} Széfe", # pl. "Nagy Széfe"
|
||||
org_type=OrgType.individual, # <-- PRIVÁT CÉG FLAG!
|
||||
owner_id=user.id,
|
||||
status="verified", # Azonnal verified
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### 8.2 Van flag a privát cég azonosítására?
|
||||
**IGEN.** Az [`OrgType`](backend/app/models/marketplace/organization.py:19) enum `individual` értéke szolgál privátcég-jelzőként.
|
||||
|
||||
| org_type érték | Jelentés | Hol jön létre? |
|
||||
|---|---|---|
|
||||
| `individual` | Privát személyes "széf" | KYC lezárás (`auth_service.py:255`) |
|
||||
| `business` | Valódi cég | `/organizations/onboard` végpont (`organizations.py:70`) |
|
||||
| `service` | Szerviz | Későbbi regisztráció |
|
||||
| `service_provider` | Szolgáltató partner | Későbbi regisztráció |
|
||||
| `fleet_owner` | Flottatulajdonos | Későbbi regisztráció |
|
||||
| `club` | Klub | Későbbi regisztráció |
|
||||
|
||||
### 8.3 Különbségek: Privát vs Valódi cég
|
||||
|
||||
| Tulajdonság | Privát (KYC) | Valódi (/onboard) |
|
||||
|---|---|---|
|
||||
| `org_type` | `individual` | `business` |
|
||||
| `status` | `verified` | `pending_verification` |
|
||||
| `tax_number` | NULL | Kötelező |
|
||||
| Név | `"{last_name} Flotta"` | Valós cégnév |
|
||||
| Branch | `"Home Base"` | `"Központi Telephely"` |
|
||||
|
||||
### 8.4 Hatás a #219-es kártyára
|
||||
A "Cég Garázsa" gomb logikája:
|
||||
1. `GET /organizations/my` → visszaadja a user összes szervezetét
|
||||
2. `org_type == "individual"` → privát garázs gomb
|
||||
3. `org_type == "business"` → cég garázs gomb
|
||||
4. `PATCH /users/me/active-organization` → váltás a kettő között
|
||||
239
docs/frontend_architecture_2_0_feasibility_analysis.md
Normal file
239
docs/frontend_architecture_2_0_feasibility_analysis.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer
|
||||
## Megvalósíthatósági Elemzés
|
||||
|
||||
**Dátum:** 2026-06-08
|
||||
**Készítette:** Rendszer-Architect
|
||||
**Mérföldkő:** #26 - Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer
|
||||
|
||||
---
|
||||
|
||||
## 📊 Készültségi Összesítő
|
||||
|
||||
| Kártya | Cím | Készültség | Függőség | Fázis |
|
||||
|--------|-----|-----------|---------|-------|
|
||||
| #228 | JSONB `visual_settings` | **0%** | - | 1. Backend |
|
||||
| #229 | RBAC/ABAC Engedélyezés | **40%** | #228 (opc.) | 1. Backend |
|
||||
| #230 | Dinamikus/Statikus Fordítás | **80%** | - | 3. i18n |
|
||||
| #231 | Layout komponensek | **60%** | #232 | 2. Frontend |
|
||||
| #232 | Vue Router Nested Routing | **50%** | #231 | 2. Frontend |
|
||||
| #233 | i18n audit | **30%** | #230, #232 | 3. i18n |
|
||||
| #234 | Tailwind Témaváltó | **0%** | #228, #229 | 3. Téma |
|
||||
| #235 | Expansion Card | **0%** | #232 | 4. UI Kit |
|
||||
| #236 | Vészjelző Rendszer | **10%** | #232 | 4. UI Kit |
|
||||
|
||||
---
|
||||
|
||||
## 1. FÁZIS: Adatbázis és Backend Alapozás
|
||||
|
||||
### 🔴 #228 - JSONB `visual_settings` (0% kész)
|
||||
|
||||
**Már létezik:**
|
||||
- Nincs `visual_settings` mező sem a `User` modellen, sem az `Organization` modellen
|
||||
- Az `Organization.notification_settings` (`JSON`) és `Organization.external_integration_config` (`JSON`) mintaként szolgálnak a JSONB használathoz
|
||||
- A `User.custom_permissions` (`JSON`) szintén minta a JSON mezőkhöz
|
||||
|
||||
**Hiányzik:**
|
||||
- `visual_settings` JSONB mező a `User` táblában (pl.: `theme_mode`, `primary_color`, `animations_enabled`, `compact_mode`)
|
||||
- `visual_settings` JSONB mező az `Organization` táblában (pl.: `wall_logo_url`, `brand_color`, `custom_css_overrides`)
|
||||
- Backend API végpont: `PATCH /users/me/visual-settings`, `PATCH /organizations/{id}/visual-settings`
|
||||
- A `UserProfile` interface-ben nincs `visual_settings` mező
|
||||
- Az `OrganizationItem` interface-ben nincs `visual_settings` mező
|
||||
|
||||
---
|
||||
|
||||
### 🟡 #229 - RBAC/ABAC Engedélyezés (40% kész)
|
||||
|
||||
**Már létezik:**
|
||||
- `UserRole` enum: `superadmin`, `admin`, `user`, `fleet_manager`, `sales`, `guest`
|
||||
- `OrgUserRole` enum: `owner`, `admin`, `manager`, `driver`, `viewer`
|
||||
- `User.subscription_plan`: `server_default="FREE"`
|
||||
- `Organization.subscription_plan`: létező mező
|
||||
- `User.custom_permissions`: JSON mező (kiterjeszthető ABAC-hoz)
|
||||
- `UserProfile`: `role`, `subscription_plan`, `scope_level` mezők léteznek
|
||||
- `OrganizationItem`: `subscription_plan` mező létezik
|
||||
|
||||
**Hiányzik:**
|
||||
- `allow_custom_skin` és `allow_advanced_animations` feature flag-ok a subscription_plan JSON-ben
|
||||
- Backend feature flag check utility/service
|
||||
- Frontend `hasFeature(featureName: string): boolean` metódus
|
||||
- Subscription plan definíciók (FREE=max 3 vehicle, BASIC=10, PREMIUM=unlimited)
|
||||
|
||||
---
|
||||
|
||||
## 2. FÁZIS: Frontend Struktúra & Layout Szétválasztás
|
||||
|
||||
### 🟡 #231 - Layout komponensek (60% kész)
|
||||
|
||||
**Már létezik:**
|
||||
- `ConsumerLayout.vue`: **195 sor**, teljes layout headerrel, footerral, dekorációs elemekkel, quick actions grid-del
|
||||
- `CorporateLayout.vue`: **98 sor**, céges sidebar, quick stats placeholder-ekkel
|
||||
- `AdminLayout.vue`: **195 sor**, admin sötét sidebar
|
||||
- Mindhárom layout `<slot />`-ot használ a tartalom beillesztésére
|
||||
|
||||
**Hiányzik:**
|
||||
- **KRITIKUS**: `<slot />` → `<router-view />` csere mindhárom layout-ban
|
||||
- `ConsumerLayout.vue` átnevezése `PrivateLayout.vue`-ra
|
||||
- `CorporateLayout.vue` átnevezése `OrganizationLayout.vue`-ra
|
||||
- "Privát felület" gomb az `OrganizationLayout.vue`-ban (auth store `switchOrganization(null)` hívás)
|
||||
- "Céges felület" gomb a `PrivateLayout.vue`-ban (első business org-ra váltás)
|
||||
- Layout transition animációk (`<Transition>` wrapper)
|
||||
|
||||
---
|
||||
|
||||
### 🟡 #232 - Vue Router átírás Nested Routing (50% kész)
|
||||
|
||||
**Már létezik:**
|
||||
- `router/index.ts`: **83 sor**, globális `beforeEach` guard, auth ellenőrzés, landing redirect
|
||||
- Route-ok: `/`, `/dashboard`, `/complete-kyc`, `/profile`, `/verify`, `/reset-password`, `/company/onboard`, `/company/garage`
|
||||
- `beforeEach` guard: kezeli a `requiresAuth` meta-t, token ellenőrzést
|
||||
|
||||
**Hiányzik:**
|
||||
- **Nincs nested routing** - minden route gyökér szinten
|
||||
- Nincs layout wrapper pattern
|
||||
- `/company/garage` route-nak nincs `/:id` paramétere
|
||||
- Route meta kiterjesztése: `meta: { layout: 'private' | 'organization' | 'admin', requiresOrg: boolean }`
|
||||
|
||||
**Cél nested struktúra:**
|
||||
```typescript
|
||||
{
|
||||
path: '/',
|
||||
component: PrivateLayout,
|
||||
children: [
|
||||
{ path: '', name: 'Landing', component: LandingView },
|
||||
{ path: 'dashboard', name: 'Dashboard', component: DashboardView, meta: { requiresAuth: true } },
|
||||
{ path: 'profile', name: 'Profile', component: ProfileView, meta: { requiresAuth: true } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/company/:id',
|
||||
component: OrganizationLayout,
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'OrgDashboard', component: OrgDashboardView, meta: { requiresAuth: true, requiresOrg: true } },
|
||||
{ path: 'garage', name: 'OrgGarage', component: CompanyGarageView, meta: { requiresAuth: true, requiresOrg: true } },
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. FÁZIS: Többnyelvűség (i18n) & Vizuális Témaváltó
|
||||
|
||||
### 🟢 #230 - Dinamikus és Statikus Fordítási Rendszer (80% kész)
|
||||
|
||||
**Már létezik:**
|
||||
- **Backend**: `Translation` modell - **TELJESEN KÉSZ** (`system.translations` tábla)
|
||||
- **Frontend**: `vue-i18n` beüzemelve `legacy: false` módban, `detectLocale()` függvénnyel
|
||||
- 6 nyelvi fájl: `hu.ts`, `en.ts`, `de.ts`, `ro.ts`, `cz.ts`, `sk.ts`
|
||||
- `translationService.ts`: `fetchTranslations()`, `fetchAllTranslations()`, `updateTranslation()`
|
||||
- `appModeStore.ts`: `setLanguage(lang)`, `loadTranslations(lang)`, `getTranslation(key, defaultValue)`
|
||||
- `LanguageSwitcher.vue`: létező komponens
|
||||
|
||||
**Hiányzik:**
|
||||
- Backend API végpontok ellenőrzése (létezik-e `GET /api/v1/translations`?)
|
||||
- Cache stratégia a dinamikus fordításokhoz (localStorage + verziószám)
|
||||
|
||||
---
|
||||
|
||||
### 🟠 #233 - i18n audit és szövegek kiszervezése (30% kész)
|
||||
|
||||
**Már létezik:**
|
||||
- 6 db nyelvi fájl a `frontend/src/i18n/` mappában
|
||||
- `hu.ts` kulcsstruktúra: `menu.*`, `header.*`, `dashboard.*`, `zen.*`, `auth.*`
|
||||
- `DashboardHeader.vue` részleges i18n használat (`{{ t('menu.features') }}`)
|
||||
- Backend JSON locale fájlok: `backend/static/locales/hu.json`
|
||||
|
||||
**Hiányzik - Hardcoded szövegek:**
|
||||
|
||||
1. **`DashboardView.vue`**: `SZERVIZNAPTÁR`, `SERVICE FINDER`, `Irányítópultod és járműveid áttekintése`
|
||||
2. **`ConsumerLayout.vue`**: `Vehicle Registration`, `Service Booking`, `Cost Tracking`, `Tracking`, `Documents` (6 db), `© 2024 Your Company. All rights reserved.`
|
||||
3. **`CorporateLayout.vue`**: `Organization`, `Overview`, `Vehicles`, `Drivers`, `Maintenance`, `Reports` (6 db sidebar), `42 vehicles`, `7 pending`, `€12,450`
|
||||
4. **`CompanyGarageView.vue`**: `Céges garázs`
|
||||
|
||||
**Hiányzó i18n kulcsok:**
|
||||
- `garage.private.title`, `garage.company.title`
|
||||
- `buttons.next`, `buttons.save`, `buttons.cancel`
|
||||
- `errors.*` (validációs hibák)
|
||||
- `organization.*` (szervezeti nézetek)
|
||||
- `layout.*` (layout elemek)
|
||||
- `quickActions.*` (gyors akciók)
|
||||
|
||||
---
|
||||
|
||||
### 🔴 #234 - Tailwind CSS Változók és Témakezelő (0% kész)
|
||||
|
||||
**Már létezik:**
|
||||
- `tailwind.config.js`: **38 sor**, egyedi színrendszer (sf-wall, sf-green, sf-blue, sf-accent, sf-dark stb.)
|
||||
- `main.css`: **117 sor**, `btn-premium`, `hotspot-pulse`, `hotspot-ring` animációk
|
||||
|
||||
**Hiányzik:**
|
||||
- **CSS Custom Properties** a `tailwind.config.js`-ben - nincs `var(--theme-*)` referencia
|
||||
- `AppThemeStore` Pinia store - nem létezik
|
||||
- Témaváltó logika: `light`, `dark`, `corporate` módok
|
||||
- `:root` CSS változók (`--theme-bg`, `--theme-primary`, `--theme-text`, `--theme-accent`, `--theme-glass`)
|
||||
- Sötét mód CSS (`dark:` osztályok hiányoznak)
|
||||
- `applyTheme(settings)` függvény
|
||||
|
||||
---
|
||||
|
||||
## 4. FÁZIS: Animált UI Készlet
|
||||
|
||||
### 🔴 #235 - Expansion Card komponens (0% kész)
|
||||
|
||||
**Már létezik:**
|
||||
- Nincs dedikált Expansion/ExpandableCard komponens
|
||||
- A `DashboardView.vue` jármű kártyái group hover effekteket használnak
|
||||
|
||||
**Hiányzik:**
|
||||
- Teljes `ExpansionCard.vue` komponens (Vue 3 `<script setup lang="ts">`)
|
||||
- Props: `title`, `subtitle`, `icon`, `expanded` (v-model), `variant` ('default' | 'premium' | 'glass')
|
||||
- Animációk: `@keyframes expandIn` / `expandOut`
|
||||
- Slots: `#header`, `#default`, `#actions`
|
||||
|
||||
---
|
||||
|
||||
### 🟠 #236 - Kontextuális Vészjelző Rendszer (10% kész)
|
||||
|
||||
**Már létezik:**
|
||||
- `hotspot-pulse` és `hotspot-ring` animációk a `main.css`-ben
|
||||
- `ConsumerLayout.vue` scoped CSS pulse animáció
|
||||
- Base alert stílus a `btn-premium` osztályban (glassmorphism)
|
||||
|
||||
**Hiányzik:**
|
||||
- `ContextAlert.vue` komponens - nem létezik
|
||||
- Props: `type` ('info' | 'warning' | 'error' | 'success'), `message`, `autoClose`, `dismissible`, `action`
|
||||
- Alert store (`useAlertStore` Pinia store)
|
||||
- Animációk: belépés (`slideInDown`), kilépés (`fadeOutUp`)
|
||||
- Global alert renderelés (App.vue)
|
||||
- Z-index kezelés
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Ajánlott Implementációs Sorrend
|
||||
|
||||
```
|
||||
Fázis 1 (Backend alapozás):
|
||||
#228 (JSONB) ──► #229 (RBAC)
|
||||
|
||||
Fázis 2 (Frontend struktúra):
|
||||
#231 (Layout-ok) ──► #232 (Router)
|
||||
|
||||
Fázis 3 (i18n + Téma):
|
||||
#230 (Fordítás) ──► #233 (i18n audit)
|
||||
#234 (Témaváltó) - független a #228/#229-től, de azok adják az adatforrást
|
||||
|
||||
Fázis 4 (UI Kit):
|
||||
#235 (Expansion Card) - független
|
||||
#236 (Alert System) - független
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Breaking Change Kockázatok
|
||||
|
||||
| Változtatás | Érintett komponensek | Kockázat |
|
||||
|------------|---------------------|---------|
|
||||
| `<slot />` → `<router-view />` | Minden view | 🔴 Magas |
|
||||
| Layout átnevezés | Importok minden fájlban | 🟡 Közepes |
|
||||
| CSS variable bevezetés | Minden komponens színe | 🔴 Magas |
|
||||
| Router nested átírás | beforeEach guard, view path-ok | 🔴 Magas |
|
||||
| i18n kulcs átnevezés | Minden `$t()` hívás | 🟡 Közepes |
|
||||
114
docs/frontend_white_screen_analysis_2026-06-07.md
Normal file
114
docs/frontend_white_screen_analysis_2026-06-07.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Frontend White Screen Analysis - 2026-06-07
|
||||
|
||||
## Summary
|
||||
|
||||
The `app.servicefinder.hu` page was loading as a blank/white screen.
|
||||
**Root cause:** Missing `flag-icons` npm package inside the frontend container.
|
||||
|
||||
---
|
||||
|
||||
## 1. Investigation Steps
|
||||
|
||||
### 1.1 Docker Container Status
|
||||
- `sf_public_frontend`: **Up** (running)
|
||||
- `sf_api`: **Up** (running)
|
||||
- Backend API reachable: `localhost:8000/api/v1/health` → responds
|
||||
|
||||
### 1.2 Frontend Container Logs
|
||||
**Primary error (repetitive):**
|
||||
```
|
||||
[vite] Internal server error: Failed to resolve import "flag-icons/css/flag-icons.css" from "src/main.ts". Does the file exist?
|
||||
File: /app/src/main.ts:13:7
|
||||
11 | import sk from "./i18n/sk";
|
||||
12 | import "./assets/main.css";
|
||||
13 | import "flag-icons/css/flag-icons.css";
|
||||
| ^
|
||||
```
|
||||
|
||||
### 1.3 Critical File Verification (all exist)
|
||||
- `frontend/src/components/Logo.vue` - Exists
|
||||
- `frontend/src/stores/vehicle.ts` - Exists
|
||||
- `frontend/src/views/organization/CompanyOnboardingView.vue` - Exists
|
||||
- `frontend/src/views/organization/CompanyGarageView.vue` - Exists
|
||||
|
||||
### 1.4 Dependency Check
|
||||
- `frontend/package.json` contains `"flag-icons": "^7.5.0"` - OK
|
||||
- Container `/app/node_modules/flag-icons`: **MISSING** - FAIL
|
||||
|
||||
---
|
||||
|
||||
## 2. Root Cause Analysis
|
||||
|
||||
### 2.1 System Architecture
|
||||
From `docker-compose.yml:302-318`:
|
||||
```yaml
|
||||
sf_public_frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules # Anonymous volume isolates node_modules
|
||||
```
|
||||
|
||||
From `frontend/Dockerfile.dev:10`:
|
||||
```dockerfile
|
||||
RUN npm install # Runs at BUILD time
|
||||
```
|
||||
|
||||
### 2.2 Failure Mechanism
|
||||
1. `flag-icons` was added to `package.json` (and `package-lock.json` updated)
|
||||
2. The container was **not rebuilt** after the package.json change
|
||||
3. The anonymous volume (`/app/node_modules`) **preserved the old node_modules state**
|
||||
4. Since volumes take precedence over `COPY`/`RUN` instructions, `npm install` never ran
|
||||
5. Vite couldn't find `flag-icons/css/flag-icons.css` at startup
|
||||
6. Result: **Internal server error** → **White screen**
|
||||
|
||||
---
|
||||
|
||||
## 3. Fix Applied
|
||||
|
||||
### 3.1 Install Package Inside Container
|
||||
```bash
|
||||
docker exec sf_public_frontend npm install
|
||||
```
|
||||
Result: `added 6 packages, changed 15 packages, and audited 281 packages`
|
||||
|
||||
### 3.2 Restart Vite Dev Server
|
||||
```bash
|
||||
docker compose restart sf_public_frontend
|
||||
```
|
||||
Result: `VITE v5.4.21 ready in 341 ms` - **running without errors**
|
||||
|
||||
---
|
||||
|
||||
## 4. Secondary Observations (non-blocking)
|
||||
|
||||
### 4.1 ProfileView Address Display Issue
|
||||
In `frontend/src/views/ProfileView.vue:370-382`, the read-only view uses:
|
||||
```vue
|
||||
<template v-if="person?.address?.address_zip || person?.address?.address_city">
|
||||
```
|
||||
This references a **nested** `address` object, but the new backend format sends address fields **flat** (e.g., `person.address_zip`, not `person.address.address_zip`). The edit mode correctly uses flat `editForm.address_zip`.
|
||||
|
||||
**Recommendation:** Change read-only display from `person?.address?.address_*` to `person?.address_*`.
|
||||
|
||||
### 4.2 Circular Dependency (safe)
|
||||
- `router/index.ts` dynamically imports `stores/auth` in `beforeEach` guard
|
||||
- `stores/auth.ts` statically imports `router`
|
||||
- This is **safe** because the dynamic import runs only on navigation events, after the router is fully initialized.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prevention Recommendations
|
||||
|
||||
### 5.1 Future Prevention
|
||||
1. **Rebuild trigger**: When `package.json` changes, always rebuild the image:
|
||||
```bash
|
||||
docker compose up -d --build sf_public_frontend
|
||||
```
|
||||
2. **Pre-start npm install**: Modify `CMD` to run `npm install` before starting:
|
||||
```dockerfile
|
||||
CMD npm install && npm run dev -- --host 0.0.0.0
|
||||
```
|
||||
3. **Anonymous volume awareness**: The anonymous volume preserves old node_modules. When package.json changes, rebuild the container, don't just restart.
|
||||
145
docs/gitea_open_cards_summary_2026-06-09.md
Normal file
145
docs/gitea_open_cards_summary_2026-06-09.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Gitea Nyitott Kártyák Összefoglaló
|
||||
|
||||
**Dátum:** 2026-06-09
|
||||
**Lekérdezés:** `docker exec roo-helper python3 /scripts/gitea_manager.py list`
|
||||
**Összes nyitott kártya:** **63 db**
|
||||
|
||||
---
|
||||
|
||||
## Összesítés Mérföldkövek Szerint
|
||||
|
||||
| Mérföldkő | Kártyák száma |
|
||||
|---|---|
|
||||
| Grafikai Komponensek & Frontend Skin Rendszer | 4 |
|
||||
| Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer | 20 |
|
||||
| Phase 4: Testing & Deployment | 7 |
|
||||
| Phase 3: Advanced Features & Epic 11 | 6 |
|
||||
| Phase 2: Dashboard & Analytics Wiring | 6 |
|
||||
| Phase 1: Core Functionality Fixes | 4 |
|
||||
| Nincs mérföldkő (egyéb / dashboard) | 16 |
|
||||
|
||||
---
|
||||
|
||||
## 1. Grafikai Komponensek & Frontend Skin Rendszer (4 kártya)
|
||||
|
||||
| ID | Cím |
|
||||
|----|------|
|
||||
| #256 | Skin Rendszer Alapok - Dinamikus Háttérv |
|
||||
| #255 | Lucide Ikonkészlet Integráció (lucide-vu) |
|
||||
| #253 | Háttérkép: garage_theme_dark.png |
|
||||
| #252 | Háttérkép: garage_company_clean.png (sf_) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer (20 kártya)
|
||||
|
||||
| ID | Cím |
|
||||
|----|------|
|
||||
| #250 | 4.2-A: ContextAlert.vue komponens + useA |
|
||||
| #249 | 4.1-A: ExpansionCard.vue komponens + ani |
|
||||
| #248 | 3.2-B: AppThemeStore létrehozása + apply |
|
||||
| #247 | 3.2-A: CSS Custom Properties + tailwind. |
|
||||
| #246 | 3.1-B: Hardcoded szövegek cseréje 5 komp |
|
||||
| #245 | 3.1-A: Hiányzó i18n kulcsok felvétele |
|
||||
| #244 | 2.2-B: View-ok áthelyezése nested strukt |
|
||||
| #243 | 2.2-A: Route meta kiterjesztés + beforeE |
|
||||
| #242 | 2.1-B: Layout-váltó gombok (Privat <-> C) |
|
||||
| #241 | 2.1-A: Layout-ok átnevezése és <slot /> |
|
||||
| #240 | 1.2-B: Frontend useFeatureFlag() composa |
|
||||
| #239 | 1.2-A: Backend subscription_service.py + |
|
||||
| #238 | 1.1-B: Frontend useVisualSettings() comp |
|
||||
| #236 | 4.2: Kontextuális Vészjelző Rendszer |
|
||||
| #234 | 3.2: Tailwind CSS Változók és Témakezelő |
|
||||
| #233 | 3.1: Jelenlegi állapot felmérése |
|
||||
| #232 | 2.2: Vue Router átírás (Nested Routing) |
|
||||
| #231 | 2.1: Független Layout komponensek felépí |
|
||||
| #230 | 1.3: Dinamikus és Statikus f |
|
||||
| #229 | 1.2: Engedélyezési szintek és Funkciókap |
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 4: Testing & Deployment (7 kártya)
|
||||
|
||||
| ID | Cím |
|
||||
|----|------|
|
||||
| #165 | Production Deployment and Monitoring Set |
|
||||
| #164 | CI/CD Pipeline Setup (GitHub Actions) |
|
||||
| #163 | Security Audit (Penetration Testing) |
|
||||
| #162 | Accessibility Audit and Fixes (WCAG 2.1) |
|
||||
| #161 | Performance Optimization |
|
||||
| #160 | Implement Integration Tests (Playwright) |
|
||||
| #159 | Write Unit Tests for Critical Components |
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 3: Advanced Features & Epic 11 (6 kártya)
|
||||
|
||||
| ID | Cím |
|
||||
|----|------|
|
||||
| #158 | Implement Advanced Search with Filters |
|
||||
| #157 | Add Bulk Operations (Vehicle Import) |
|
||||
| #156 | Implement Webhook and Notification Syste |
|
||||
| #155 | Add Admin Control Panels |
|
||||
| #154 | Implement Service Booking Flow |
|
||||
| #153 | Complete Profile Selector |
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 2: Dashboard & Analytics Wiring (6 kártya)
|
||||
|
||||
| ID | Cím |
|
||||
|----|------|
|
||||
| #152 | Implement Historical Data (occurrence_da) |
|
||||
| #151 | Connect User Management Table to Real Da |
|
||||
| #150 | Wire Service Map with Real Provider Data |
|
||||
| #149 | Implement Analytics Service (TCO/km) |
|
||||
| #148 | Connect Gamification Components to Real |
|
||||
| #147 | Wire Financial Dashboard to Real Finance |
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 1: Core Functionality Fixes (4 kártya)
|
||||
|
||||
| ID | Cím |
|
||||
|----|------|
|
||||
| #178 | Vehicle Transfer Requests - Implement Du |
|
||||
| #146 | Implement Basic Error Handling in Fronte |
|
||||
| #145 | Standardize API Base URL Usage in Fronte |
|
||||
| #142 | Implement Catalog API Endpoints |
|
||||
|
||||
---
|
||||
|
||||
## 7. Egyéb / Nincs mérföldkő (16 kártya)
|
||||
|
||||
| ID | Cím |
|
||||
|----|------|
|
||||
| #227 | [FIX] NAV API v3 queryTaxpayer - .env qu |
|
||||
| #226 | Frontend: flag-icons CSS import hiba |
|
||||
| #225 | Hiba: Frontend - flag-icons CSS import |
|
||||
| #224 | Hiba: NAV API aláírás (INVALID_REQUEST) |
|
||||
| #218 | Frontend: Cég létrehozás 3-lépéses Onboa |
|
||||
| #217 | Backend: Cég (Organization) CRUD API bőv |
|
||||
| #216 | Profil UI hiányosságok (Person & User) |
|
||||
| #175 | Advanced Analytics & Reporting Engine |
|
||||
| #174 | Real-time Notification System |
|
||||
| #173 | Service Finder Rendszer Áttekintő Dokume |
|
||||
| #140 | Connect Service Moderation Map to Backen |
|
||||
| #139 | Integrate Gamification Control Panel |
|
||||
| #138 | Connect Financial Dashboard Tile |
|
||||
| #137 | Implement Real-time System Health Monito |
|
||||
| #136 | Implement AI Researcher Logs Backend |
|
||||
| #135 | Connect User Management Table to Real AP |
|
||||
|
||||
---
|
||||
|
||||
## Főbb Megállapítások
|
||||
|
||||
1. Legnagyobb blokk: A Frontend Architektúra 2.0 mérföldkő tartalmazza a legtöbb nyitott kártyát (20 db) - ez a jelenlegi aktív fejlesztési fókusz.
|
||||
2. Grafikai előkészületek: 4 kártya a Skin rendszerhez és háttérképekhez tartozik.
|
||||
3. Hibajavítások: 3 kritikus hibajelzés van nyitva (#224, #225, #227) - NAV API és CSS import problémák.
|
||||
4. Tesztelési fázis: 7 kártya vár a Phase 4 (Testing & Deployment) mérföldkőben.
|
||||
5. Fázisok közötti egyensúly: A Phase 1-4 mérföldkövek összesen 23 kártyát tartalmaznak, míg a mérföldkő nélküli kártyák száma 16.
|
||||
|
||||
---
|
||||
|
||||
Összefoglaló készítve: 2026-06-09 23:20 UTC
|
||||
311
docs/identity_schema_complete_audit.md
Normal file
311
docs/identity_schema_complete_audit.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# 🔬 identity Séma Teljes Audit (2026-06-06)
|
||||
|
||||
## Áttekintés
|
||||
Az `identity` séma **9 táblát** tartalmaz, amelyek együttesen a teljes felhasználói identitáskezelést lefedik: a természetes személyt (Person), a login entitást (User), a pénztárcát, a közösségi kapcsolatokat, a bizalmi profilt, az eszközöket és a vouchereket.
|
||||
|
||||
---
|
||||
|
||||
## 1. 📊 TÁBLÁK ÉS ADATAIK
|
||||
|
||||
### 1.1 👤 `users` — Login Entitás (Központi tábla)
|
||||
**Oszlopok (24 db):**
|
||||
| Oszlop | Típus | Kötelező? | Default | Leírás |
|
||||
|--------|-------|-----------|---------|--------|
|
||||
| `id` | INTEGER | PK | nextval(...) | Elsődleges kulcs |
|
||||
| `email` | VARCHAR(255) | **YES** | — | E-mail cím (UNIQUE) |
|
||||
| `hashed_password` | VARCHAR | NO | — | Hash-elt jelszó |
|
||||
| `role` | USERRODE (ENUM) | **YES** | 'user' | Szerepkör (lásd ENUM lent) |
|
||||
| `person_id` | BIGINT | NO | — | FK → persons(id) |
|
||||
| `subscription_plan` | VARCHAR(30) | **YES** | 'FREE' | Előfizetési csomag |
|
||||
| `subscription_expires_at` | TIMESTAMPTZ | NO | — | Előfizetés lejárata |
|
||||
| `is_vip` | BOOLEAN | **YES** | false | VIP státusz |
|
||||
| `referral_code` | VARCHAR(20) | NO | — | Meghívókód (UNIQUE) |
|
||||
| `referred_by_id` | INTEGER | NO | — | FK → users(id) (aki meghívta) |
|
||||
| `current_sales_agent_id` | INTEGER | NO | — | FK → users(id) (értékesítő) |
|
||||
| `is_active` | BOOLEAN | **YES** | false | Aktív-e a fiók |
|
||||
| `is_deleted` | BOOLEAN | **YES** | false | Soft-delete |
|
||||
| `folder_slug` | VARCHAR(12) | NO | — | Fájltároló slug (UNIQUE) |
|
||||
| `preferred_language` | VARCHAR(5) | **YES** | 'hu' | Nyelv |
|
||||
| `region_code` | VARCHAR(5) | **YES** | 'HU' | Régió |
|
||||
| `preferred_currency` | VARCHAR(3) | **YES** | 'HUF' | Pénznem |
|
||||
| `ui_mode` | VARCHAR(20) | **YES** | 'personal' | UI mód (personal/fleet) |
|
||||
| `scope_level` | VARCHAR(30) | **YES** | 'individual' | Scope szint |
|
||||
| `scope_id` | VARCHAR(50) | NO | — | Scope azonosító |
|
||||
| `custom_permissions` | JSON | **YES** | '{}' | Egyedi jogosultságok |
|
||||
| `alternative_emails` | JSON | **YES** | '[]' | Alternatív e-mailek |
|
||||
| `email_history` | JSON | **YES** | '[]' | E-mail cím történet |
|
||||
| `created_at` | TIMESTAMPTZ | **YES** | — | Létrehozás ideje |
|
||||
|
||||
**Indexek (7 db):**
|
||||
- `users_pkey` — UNIQUE BTREE (id)
|
||||
- `users_email_key` — UNIQUE BTREE (email)
|
||||
- `users_referral_code_key` — UNIQUE BTREE (referral_code)
|
||||
- `ix_identity_users_folder_slug` — UNIQUE BTREE (folder_slug)
|
||||
- `ix_identity_users_email` — UNIQUE BTREE (email) (duplikáció!)
|
||||
- `ix_identity_users_id` — BTREE (id)
|
||||
|
||||
---
|
||||
|
||||
### 1.2 🧑 `persons` — Természetes Személy
|
||||
**Oszlopok (22 db):**
|
||||
| Oszlop | Típus | Kötelező? | Default | Leírás |
|
||||
|--------|-------|-----------|---------|--------|
|
||||
| `id` | BIGINT | PK | nextval(...) | Elsődleges kulcs |
|
||||
| `user_id` | INTEGER | NO | — | FK → users(id) (aktív fiók) |
|
||||
| `id_uuid` | UUID | **YES** | gen_random_uuid() | Publikus UUID |
|
||||
| `address_id` | UUID | NO | — | FK → system.addresses(id) |
|
||||
| `identity_hash` | VARCHAR(64) | NO | — | Hash: Név+Anyja+DOB (UNIQUE) |
|
||||
| `last_name` | VARCHAR | **YES** | — | Vezetéknév |
|
||||
| `first_name` | VARCHAR | **YES** | — | Keresztnév |
|
||||
| `phone` | VARCHAR | NO | — | Telefonszám |
|
||||
| `mothers_last_name` | VARCHAR | NO | — | Anyja vezetékneve |
|
||||
| `mothers_first_name` | VARCHAR | NO | — | Anyja keresztneve |
|
||||
| `birth_place` | VARCHAR | NO | — | Születési hely |
|
||||
| `birth_date` | TIMESTAMP | NO | — | Születési dátum |
|
||||
| `identity_docs` | JSON | **YES** | '{}' | Okmányok (ID_CARD, LICENSE, stb.) |
|
||||
| `ice_contact` | JSON | **YES** | '{}' | Vészhelyzeti kapcsolattartó |
|
||||
| `lifetime_xp` | BIGINT | **YES** | -1 | Élettapasztalat pont |
|
||||
| `penalty_points` | INTEGER | **YES** | -1 | Büntetőpontok |
|
||||
| `social_reputation` | NUMERIC(3,2) | **YES** | 0.00 | Társadalmi reputáció |
|
||||
| `is_sales_agent` | BOOLEAN | **YES** | true | Értékesítő-e |
|
||||
| `is_active` | BOOLEAN | **YES** | true | Aktív-e |
|
||||
| `is_ghost` | BOOLEAN | **YES** | false | Ghost (soft-delete) |
|
||||
| `merged_into_id` | BIGINT | NO | — | Összevonva ebbe a Person-ba (csak ORM szinten FK) |
|
||||
| `created_at` | TIMESTAMPTZ | **YES** | — | Létrehozás |
|
||||
| `updated_at` | TIMESTAMPTZ | NO | — | Módosítás |
|
||||
|
||||
**Indexek (4 db):**
|
||||
- `persons_pkey` — UNIQUE BTREE (id)
|
||||
- `persons_id_uuid_key` — UNIQUE BTREE (id_uuid)
|
||||
- `ix_identity_persons_identity_hash` — UNIQUE BTREE (identity_hash)
|
||||
- `ix_identity_persons_id` — BTREE (id)
|
||||
|
||||
---
|
||||
|
||||
### 1.3 💰 `wallets` — Pénztárca
|
||||
**Oszlopok (6 db):**
|
||||
| Oszlop | Típus | Kötelező? | Default |
|
||||
|--------|-------|-----------|---------|
|
||||
| `id` | INTEGER | PK | nextval(...) |
|
||||
| `user_id` | INTEGER | **YES** | — (UNIQUE, FK → users(id)) |
|
||||
| `earned_credits` | NUMERIC(18,4) | **YES** | 0 |
|
||||
| `purchased_credits` | NUMERIC(18,4) | **YES** | 0 |
|
||||
| `service_coins` | NUMERIC(18,4) | **YES** | 0 |
|
||||
| `currency` | VARCHAR(3) | **YES** | — |
|
||||
|
||||
**Indexek:**
|
||||
- `wallets_pkey` — UNIQUE BTREE (id)
|
||||
- `wallets_user_id_key` — UNIQUE BTREE (user_id)
|
||||
|
||||
---
|
||||
|
||||
### 1.4 🎫 `active_vouchers` — Aktív Voucher-ek
|
||||
**Oszlopok (6 db):**
|
||||
| Oszlop | Típus | Kötelező? | Default |
|
||||
|--------|-------|-----------|---------|
|
||||
| `id` | INTEGER | PK | nextval(...) |
|
||||
| `wallet_id` | INTEGER | **YES** | — (FK → wallets(id) ON DELETE CASCADE) |
|
||||
| `amount` | NUMERIC(18,4) | **YES** | — |
|
||||
| `original_amount` | NUMERIC(18,4) | **YES** | — |
|
||||
| `expires_at` | TIMESTAMPTZ | **YES** | — |
|
||||
| `created_at` | TIMESTAMPTZ | **YES** | now() |
|
||||
|
||||
---
|
||||
|
||||
### 1.5 🤝 `social_accounts` — Közösségi Bejelentkezés
|
||||
**Oszlopok (7 db):**
|
||||
| Oszlop | Típus | Kötelező? | Default |
|
||||
|--------|-------|-----------|---------|
|
||||
| `id` | INTEGER | PK | nextval(...) |
|
||||
| `user_id` | INTEGER | **YES** | — (FK → users(id) ON DELETE CASCADE) |
|
||||
| `provider` | VARCHAR(50) | **YES** | — |
|
||||
| `social_id` | VARCHAR(255) | **YES** | — |
|
||||
| `email` | VARCHAR(255) | **YES** | — |
|
||||
| `extra_data` | JSON | **YES** | '{}' |
|
||||
| `created_at` | TIMESTAMPTZ | **YES** | now() |
|
||||
|
||||
**Unique:** `uix_social_provider_id` (provider, social_id)
|
||||
**Indexek:** social_id
|
||||
|
||||
---
|
||||
|
||||
### 1.6 🏆 `user_trust_profiles` — Gondos Gazda Index
|
||||
**Oszlopok (9 db):**
|
||||
| Oszlop | Típus | Kötelező? | Default |
|
||||
|--------|-------|-----------|---------|
|
||||
| `user_id` | INTEGER | **PK+FK** | — (FK → users(id) ON DELETE CASCADE) |
|
||||
| `trust_score` | INTEGER | **YES** | 0 |
|
||||
| `maintenance_score` | NUMERIC(5,2) | **YES** | 0.00 |
|
||||
| `quality_score` | NUMERIC(5,2) | **YES** | 0.00 |
|
||||
| `preventive_score` | NUMERIC(5,2) | **YES** | 0.00 |
|
||||
| `identity_score` | INTEGER | **YES** | 0 |
|
||||
| `verification_level` | INTEGER | **YES** | 0 |
|
||||
| `verified_channels` | JSONB | **YES** | '{}' |
|
||||
| `identity_risk_flag` | BOOLEAN | **YES** | false |
|
||||
| `last_calculated` | TIMESTAMPTZ | **YES** | now() |
|
||||
|
||||
---
|
||||
|
||||
### 1.7 📧 `verification_tokens` — Verifikációs Tokenek
|
||||
**Oszlopok (8 db):**
|
||||
| Oszlop | Típus | Kötelező? | Default |
|
||||
|--------|-------|-----------|---------|
|
||||
| `id` | INTEGER | PK | nextval(...) |
|
||||
| `token` | UUID | **YES** | — (UNIQUE) |
|
||||
| `user_id` | INTEGER | **YES** | — (FK → users(id) ON DELETE CASCADE) |
|
||||
| `token_type` | VARCHAR(20) | **YES** | — |
|
||||
| `extra_data` | JSON | NO | '{}' |
|
||||
| `created_at` | TIMESTAMPTZ | **YES** | now() |
|
||||
| `expires_at` | TIMESTAMPTZ | **YES** | — |
|
||||
| `is_used` | BOOLEAN | **YES** | — |
|
||||
|
||||
---
|
||||
|
||||
### 1.8 📱 `devices` — Eszközök
|
||||
**Oszlopok (4 db):**
|
||||
| Oszlop | Típus | Kötelező? |
|
||||
|--------|-------|-----------|
|
||||
| `fingerprint_hash` | VARCHAR(255) | **PK** |
|
||||
| `risk_score` | INTEGER | **YES** (default 100) |
|
||||
| `is_banned` | BOOLEAN | **YES** (default false) |
|
||||
| `created_at` | TIMESTAMPTZ | **YES** |
|
||||
|
||||
---
|
||||
|
||||
### 1.9 🔗 `user_device_links` — Eszköz-Felhasználó Kapcsolat
|
||||
**Oszlopok (6 db):**
|
||||
| Oszlop | Típus | Kötelező? | Default |
|
||||
|--------|-------|-----------|---------|
|
||||
| `id` | UUID | **PK** | — |
|
||||
| `user_id` | INTEGER | **YES** | — (FK → users(id) ON DELETE CASCADE) |
|
||||
| `device_hash` | VARCHAR(255) | **YES** | — (FK → devices(fingerprint_hash) ON DELETE CASCADE) |
|
||||
| `first_seen_at` | TIMESTAMPTZ | **YES** | — |
|
||||
| `last_seen_at` | TIMESTAMPTZ | NO | — |
|
||||
| `login_count` | INTEGER | **YES** | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 🔗 KAPCSOLATOK (FOREIGN KEYS)
|
||||
|
||||
### 2.1 Belső Kapcsolatok (identity → identity)
|
||||
|
||||
```
|
||||
users.person_id ─────────────► persons.id
|
||||
persons.user_id ─────────────► users.id (aktív fiók)
|
||||
users.referred_by_id ────────► users.id (MLM: ki hívta meg)
|
||||
users.current_sales_agent_id ► users.id (értékesítő)
|
||||
|
||||
social_accounts.user_id ─────► users.id [CASCADE]
|
||||
verification_tokens.user_id ─► users.id [CASCADE]
|
||||
user_trust_profiles.user_id ─► users.id [CASCADE]
|
||||
wallets.user_id ─────────────► users.id
|
||||
user_device_links.user_id ───► users.id [CASCADE]
|
||||
|
||||
wallets ◄──────────────────── active_vouchers.wallet_id [CASCADE]
|
||||
devices ◄──────────────────── user_device_links.device_hash [CASCADE]
|
||||
```
|
||||
|
||||
### 2.2 Külső Kapcsolatok (identity → system)
|
||||
|
||||
```
|
||||
persons.address_id ──────────► system.addresses(id)
|
||||
```
|
||||
|
||||
### 2.3 ORM Szintű Kapcsolatok (nincs DB FK constraint)
|
||||
|
||||
```
|
||||
persons.merged_into_id ──────► persons(id) -- SQLAlchemy ORM, use_alter=True
|
||||
persons.user_id ─────────────► users(id) -- SQLAlchemy ORM (fk_person_active_user), use_alter=True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 🌐 KÜLSŐ HIVATKOZÁSOK (Más sémák hivatkoznak identity táblákra)
|
||||
|
||||
| Séma | Tábla | Oszlop | Hivatkozott tábla |
|
||||
|------|-------|--------|-------------------|
|
||||
| `audit` | audit_logs | user_id | identity.users |
|
||||
| `audit` | financial_ledger | user_id, person_id | identity.users, identity.persons |
|
||||
| `audit` | operational_logs | user_id | identity.users |
|
||||
| `finance` | withdrawal_requests | user_id | identity.users |
|
||||
| `fleet` | organization_members | user_id, person_id | identity.users, identity.persons |
|
||||
| `gamification` | points_ledger | user_id | identity.users |
|
||||
| `gamification` | user_badges | user_id | identity.users |
|
||||
| `gamification` | user_contributions | user_id | identity.users |
|
||||
| `gamification` | user_scores | user_id | identity.users |
|
||||
| `gamification` | user_stats | user_id | identity.users |
|
||||
| `marketplace` | service_requests | user_id | identity.users |
|
||||
| `marketplace` | service_reviews | user_id | identity.users |
|
||||
| `marketplace` | votes | user_id | identity.users |
|
||||
| `system` | internal_notifications | user_id | identity.users |
|
||||
| `system` | user_scores_deprecated | user_id | identity.users |
|
||||
|
||||
**Összesen: 17 külső FK hivatkozás 7 különböző sémából.**
|
||||
|
||||
---
|
||||
|
||||
## 4. 🏷️ ENUM TÍPUSOK
|
||||
|
||||
### `identity.userrole` ENUM
|
||||
| Érték | Leírás |
|
||||
|-------|--------|
|
||||
| `owner_old` | (régi, deprecated) |
|
||||
| `ADMIN` | Rendszergazda |
|
||||
| `FLEET_MANAGER` | Flottakezelő |
|
||||
| `DRIVER` | Sofőr |
|
||||
| `owner` | Tulajdonos |
|
||||
| `admin` | Adminisztrátor |
|
||||
| `user` | **Alapértelmezett** — sima felhasználó |
|
||||
|
||||
**Megjegyzés:** Több érték is duplikált (pl. owner/owner_old, ADMIN/admin). Valószínűleg refaktorálásra szorul, hogy csak a kívánt 9 érték maradjon meg.
|
||||
|
||||
---
|
||||
|
||||
## 5. 🗺️ KAPCSOLATI ÁBRA (ERD)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ identity.users │ ◄──── social_accounts
|
||||
│ (24 oszlop - központi entitás) │ ◄──── verification_tokens
|
||||
└───┬────┬────┬────┬────┬──────────┘ ◄──── user_trust_profiles
|
||||
│ │ │ │ │ ◄──── wallets
|
||||
│ │ │ │ │ ◄──── user_device_links
|
||||
┌───────────┘ │ │ │ └───────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌──────────┐ ┌───────────┐ ┌───────────────┐ ┌──────────┐
|
||||
│ persons │ │ wallets │ │ devices │ │ (saját) │
|
||||
│(22 oszl.)│ │ (6 oszl.) │ │ (4 oszlop) │ │ referrer │
|
||||
└────┬─────┘ └─────┬─────┘ └───────┬───────┘ │ sales_ag.│
|
||||
│ │ │ └──────────┘
|
||||
▼ ▼ ▼
|
||||
system.addresses active_vouchers user_device_links
|
||||
(GPS, cím) (FIFO voucher) (kapcsolótábla)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 📝 MEGJEGYZÉSEK
|
||||
|
||||
1. **Körkörös függőség:** A `users` és `persons` táblák között **körkörös FK** van: `users.person_id → persons(id)` és `persons.user_id → users(id)`. Ezt az ORM szinten `post_update=True` és `use_alter=True` paraméterekkel oldják fel.
|
||||
|
||||
2. **ORM-only FKs:** A `persons.merged_into_id` és a `persons.user_id` (második FK, `fk_person_active_user`) **csak ORM szinten** léteznek, adatbázis szintű FK constraint nélkül.
|
||||
|
||||
3. **JSON mezők:** 5 JSON típusú mező van a sémában, amelyek rugalmas adattárolást tesznek lehetővé: `identity_docs`, `ice_contact`, `custom_permissions`, `alternative_emails`, `email_history`, `extra_data` (social_accounts), `verified_channels`.
|
||||
|
||||
4. **UNIQUE megszorítások:** Összesen 7 UNIQUE constraint van, ezek közül az `identity_hash` a legkritikusabb — ez biztosítja a deduplikációt.
|
||||
|
||||
---
|
||||
|
||||
## 7. KÖVETKEZTETÉS
|
||||
|
||||
Az identity séma **teljes körű emberközpontú identitáskezelést** valósít meg, amely lefedi:
|
||||
- ✅ Személyes adatok (Person)
|
||||
- ✅ Bejelentkezési adatok (User)
|
||||
- ✅ Pénzügyek (Wallet, Vouchers)
|
||||
- ✅ Okmányok (identity_docs JSON)
|
||||
- ✅ Közösségi kapcsolatok (Social accounts)
|
||||
- ✅ Bizalmi index (Trust Profile)
|
||||
- ✅ Eszközazonosítás (Devices)
|
||||
- ✅ MLM/Referral rendszer (referred_by, sales_agent)
|
||||
- ✅ Verifikáció (Verification tokens)
|
||||
|
||||
111
docs/nav_api_lookup_tax_analysis.md
Normal file
111
docs/nav_api_lookup_tax_analysis.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 🔍 NAV API Tax Number Lookup - Hibaelemzés és Javítási Terv
|
||||
|
||||
**Dátum:** 2026-06-07
|
||||
**Gitea Issue-ek:** #224 (NAV API aláírás), #226 (Frontend CSS import)
|
||||
**Elemző:** Rendszer-Architect
|
||||
|
||||
---
|
||||
|
||||
## 1. Összefoglaló
|
||||
|
||||
Két független hibát azonosítottam:
|
||||
|
||||
1. **Frontend**: A Vite fejlesztői szerver nem tudja feloldani a `flag-icons/css/flag-icons.css` importot → `npm install` hiányzik
|
||||
2. **Backend (NAV API)**: A NAV Online Számla API v3 `queryTaxpayer` hívása `INVALID_REQUEST_SIGNATURE` hibát ad → valószínűleg érvénytelen `NAV_API_SIGN_KEY`
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend Hiba: flag-icons CSS import
|
||||
|
||||
### 2.1. Helyszín
|
||||
|
||||
- [`frontend/src/main.ts:13`](/opt/docker/dev/service_finder/frontend/src/main.ts) - `import 'flag-icons/css/flag-icons.css'`
|
||||
- [`frontend/package.json:14`](/opt/docker/dev/service_finder/frontend/package.json) - `"flag-icons": "^7.5.0"` függőség
|
||||
|
||||
### 2.2. Ok
|
||||
|
||||
A `flag-icons` npm csomag deklarálva van a `package.json`-ban, de **nincs telepítve** a `node_modules`-ban. Ez tipikusan akkor fordul elő, ha:
|
||||
- A függőséget kézzel adták hozzá a `package.json`-hoz, de `npm install` nem futott le
|
||||
- A `node_modules` mappa régi, és `npm install` vagy `npm update` szükséges
|
||||
|
||||
### 2.3. Javítás
|
||||
|
||||
```bash
|
||||
cd /opt/docker/dev/service_finder/frontend && npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Hiba: NAV API INVALID_REQUEST_SIGNATURE
|
||||
|
||||
### 3.1. Helyszín
|
||||
|
||||
- [`backend/app/services/nav_service.py`](/opt/docker/dev/service_finder/backend/app/services/nav_service.py) - Teljes NAV API integráció
|
||||
- [`backend/app/api/v1/endpoints/organizations.py:124`](/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/organizations.py) - `/lookup-tax/{tax_number}` végpont
|
||||
- [`backend/app/core/config.py:130`](/opt/docker/dev/service_finder/backend/app/core/config.py) - NAV konfigurációs mezők
|
||||
- [`.env:90`](/opt/docker/dev/service_finder/.env) - NAV API hitelesítő adatok
|
||||
|
||||
### 3.2. A Hiba Lánca
|
||||
|
||||
1. Felhasználó meghívja: `GET /api/v1/organizations/lookup-tax/24742063`
|
||||
2. `NavService.query_taxpayer()` összeállítja a SOAP/XML kérést
|
||||
3. Kiszámolja az aláírást: `SHA3-512(requestId + timestamp + NAV_API_SIGN_KEY)`
|
||||
4. NAV API válasz: `HTTP 400` → `INVALID_REQUEST_SIGNATURE` ("Érvénytelen kérés aláírás")
|
||||
5. `query_taxpayer()` visszatér `None`-nal
|
||||
6. A végpont `HTTP 404`-et ad: "Cég nem található a NAV adatbázisában"
|
||||
|
||||
### 3.3. Tesztelt Aláírási Módszerek
|
||||
|
||||
| Módszer | Eredmény |
|
||||
|---------|----------|
|
||||
| SHA3-512(requestId + timestamp + signKey) | ❌ INVALID_REQUEST_SIGNATURE |
|
||||
| SHA3-512(requestId + timestamp + passwordHash) | ❌ INVALID_REQUEST_SIGNATURE |
|
||||
| SHA3-512(requestId + timestamp + exchangeKey) | ❌ INVALID_REQUEST_SIGNATURE |
|
||||
| HMAC-SHA3-512(signKey, requestId + timestamp) | ❌ INVALID_REQUEST_SIGNATURE |
|
||||
| SHA3-512(requestId + timestamp) (no key) | ❌ INVALID_REQUEST_SIGNATURE |
|
||||
|
||||
### 3.4. Következtetés
|
||||
|
||||
**Minden** aláírási módszer ugyanazt a hibát adja vissza, ami arra utal, hogy **nem a kódban lévő aláírás-számítás a hibás, hanem maguk a hitelesítő adatok érvénytelenek**.
|
||||
|
||||
Gyanús elemek:
|
||||
- `NAV_API_SIGN_KEY=02-b52b-3d9e7d638c695EPWNIUAEGHS` - a formátuma nem szabványos (nem UUID, nem base64)
|
||||
- `NAV_API_EXCHANGE_KEY=a5cf5EPWNIU9DP8O` - szintén rövid, nem szabványos
|
||||
|
||||
### 3.5. Javítási Terv
|
||||
|
||||
#### 1. lépés: Új NAV API kulcsok igénylése
|
||||
Látogass el a NAV Online Számla teszt portáljára:
|
||||
- https://onlineszamla.nav.gov.hu/
|
||||
- Regisztráció után generálj új `signKey` és `exchangeKey` párost
|
||||
|
||||
#### 2. lépés: Frissítsd a `.env` fájlt
|
||||
```env
|
||||
NAV_API_USER=<új felhasználónév>
|
||||
NAV_API_PASSWORD=<új jelszó>
|
||||
NAV_API_SIGN_KEY=<új signKey>
|
||||
NAV_API_EXCHANGE_KEY=<új exchangeKey>
|
||||
NAV_API_CALLER_TAX_NUMBER="48952046133"
|
||||
```
|
||||
|
||||
#### 3. lépés: Teszteld a kapcsolatot
|
||||
```bash
|
||||
docker exec sf_api python3 -c "
|
||||
from app.services.nav_service import NavService
|
||||
import asyncio
|
||||
result = asyncio.run(NavService.query_taxpayer('24742063'))
|
||||
print(result)
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Kapcsolódó Gitea Issue-ek
|
||||
|
||||
| ID | Cím | Státusz |
|
||||
|----|-----|---------|
|
||||
| #224 | 🔴 Hiba: NAV API aláírás (INVALID_REQUEST_SIGNATURE) | Nyitott |
|
||||
| #225 | 🔴 Hiba: Frontend - flag-icons CSS import | Nyitott (duplikátum?) |
|
||||
| #226 | Frontend: flag-icons CSS import hiba | Nyitott |
|
||||
|
||||
---
|
||||
94
docs/nav_api_v3_fix_audit.md
Normal file
94
docs/nav_api_v3_fix_audit.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 🔍 NAV API v3 Fix Audit - Hiányosságjelentés
|
||||
|
||||
**Dátum:** 2026-06-07
|
||||
**Vizsgált Issue:** #227
|
||||
**Forrásdokumentáció:** [`docs/nav_api_v3_fix_summary.md`](docs/nav_api_v3_fix_summary.md)
|
||||
|
||||
## Összefoglaló
|
||||
|
||||
A [`docs/nav_api_v3_fix_summary.md`](docs/nav_api_v3_fix_summary.md) dokumentációban leírt 3 javítási lépésből **egyik sem lett végrehajtva**. Az alábbi táblázat mutatja a pontos állapotot.
|
||||
|
||||
## Részletes Hiányosság-leképezés
|
||||
|
||||
### 1. `.env` fájl - ❌ Idezőjelek még mindig jelen
|
||||
|
||||
| Mező | Jelenlegi érték | Helyes érték |
|
||||
|------|----------------|--------------|
|
||||
| [`NAV_API_PASSWORD`](.env:91) | `'Tevelegelek&99'` (aposztrófokkal, 14 karakter) | `Tevelegelek&99` (12 karakter) |
|
||||
| [`NAV_API_CALLER_TAX_NUMBER`](.env:94) | `"48952046133"` (idézőjellel, 12 karakter) | `48952046133` (11 karakter) |
|
||||
|
||||
**Következmény:**
|
||||
- `passwordHash = SHA-512("'Tevelegelek&99'")` → **ROSSZ hash** (aposztrófokkal)
|
||||
- `caller_tax_number_core[:8]` → `'"4895204'` → **ROSSZ adószám**
|
||||
|
||||
### 2. [`backend/app/core/config.py`](backend/app/core/config.py) - ❌ HIÁNYZIK a `strip_quotes` field_validator
|
||||
|
||||
A [`config.py:130-136`](backend/app/core/config.py:130) sorokban a NAV_API_* mezők definiálva vannak, de **nincs hozzáadva** a `field_validator`.
|
||||
|
||||
**Hiányzó kód** (beszúrandó a [`config.py:136`](backend/app/core/config.py:136) sor után):
|
||||
```python
|
||||
@field_validator('NAV_API_PASSWORD', 'NAV_API_SIGN_KEY', 'NAV_API_EXCHANGE_KEY', 'NAV_API_CALLER_TAX_NUMBER')
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v:
|
||||
v = v.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
|
||||
v = v[1:-1]
|
||||
return v
|
||||
```
|
||||
|
||||
### 3. [`backend/app/services/nav_service.py`](backend/app/services/nav_service.py) - ❌ HIÁNYZIK a `.strip()` hívás
|
||||
|
||||
A [`nav_service.py:134-138`](backend/app/services/nav_service.py:134) sorokban és a [`nav_service.py:127-131`](backend/app/services/nav_service.py:127) sorokban is hiányzik a `.strip()`:
|
||||
|
||||
```python
|
||||
# JELENLEGI (hibás):
|
||||
target_tax_number_core = (
|
||||
tax_number
|
||||
.replace("-", "")
|
||||
.replace(" ", "")[:8]
|
||||
)
|
||||
|
||||
# HELYES:
|
||||
target_tax_number_core = (
|
||||
tax_number
|
||||
.strip()
|
||||
.replace("-", "")
|
||||
.replace(" ", "")[:8]
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# JELENLEGI (hibás) - caller_tax_number_core:
|
||||
caller_tax_number_core = (
|
||||
settings.NAV_API_CALLER_TAX_NUMBER
|
||||
.replace("-", "")
|
||||
.replace(" ", "")[:8]
|
||||
)
|
||||
|
||||
# HELYES:
|
||||
caller_tax_number_core = (
|
||||
settings.NAV_API_CALLER_TAX_NUMBER
|
||||
.strip()
|
||||
.replace("-", "")
|
||||
.replace(" ", "")[:8]
|
||||
)
|
||||
```
|
||||
|
||||
## Javítási Sorrend (Kötelező)
|
||||
|
||||
| # | Fájl | Művelet | Parancs |
|
||||
|---|------|---------|---------|
|
||||
| 1 | `.env` | Idézőjelek eltávolítása | Kézi szerkesztés |
|
||||
| 2 | [`backend/app/core/config.py`](backend/app/core/config.py) | `strip_quotes` field_validator hozzáadása | Szerkesztés |
|
||||
| 3 | [`backend/app/services/nav_service.py`](backend/app/services/nav_service.py) | `.strip()` hozzáadása mindkét helyen | Szerkesztés |
|
||||
| 4 | Konténer újraindítás | `docker compose restart sf_api` | `docker compose restart sf_api` |
|
||||
| 5 | Teszt | Lekérdezés futtatása | `docker exec sf_api python3 -c "from app.services.nav_service import NavService; import asyncio; result = asyncio.run(NavService.query_taxpayer('24742063')); print(result)"` |
|
||||
|
||||
## Gyökérok Elemzés
|
||||
|
||||
A jelenlegi `INVALID_REQUEST_SIGNATURE` hiba oka:
|
||||
|
||||
1. A `passwordHash` aposztrófokkal együtt lett hash-elve → a NAV szerver nem ismeri fel a jelszót
|
||||
2. A `caller_tax_number` idézőjellel együtt kerül a `<user><taxNumber>` mezőbe → a NAV szerver a hívó felet sem tudja azonosítani
|
||||
3. Mivel a fenti két érték hibás, a teljes `requestSignature` is érvénytelen lesz, mert a NAV szerver a felhasználó azonosítása után számolja ki a saját oldalán a várható signature-t
|
||||
235
docs/nav_api_v3_fix_summary.md
Normal file
235
docs/nav_api_v3_fix_summary.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# 🔧 NAV Online Számla API v3 - Hibajavítási Összefoglaló
|
||||
|
||||
**Dátum:** 2026-06-07
|
||||
**Forrás:** `Online_Szamla_interfesz specifikacio_HU_v3.0. (2026.02.12).pdf`
|
||||
**Érintett fájlok:**
|
||||
- `.env` (NAV konfigurációs értékek)
|
||||
- `backend/app/services/nav_service.py` (NAV API integráció)
|
||||
- `backend/app/api/v1/endpoints/organizations.py` (lookup-tax végpont)
|
||||
- `backend/app/core/config.py` (Pydantic Settings)
|
||||
|
||||
---
|
||||
|
||||
## 1. A NAV API v3 Működési Modellje
|
||||
|
||||
A NAV Online Számla API v3.0 **nem REST/JSON**, hanem **SOAP-alapú XML** kommunikációt használ, amit modern HTTP(S) kérésekbe csomagoltak.
|
||||
|
||||
### 1.1. Kommunikációs Rétegek
|
||||
|
||||
```
|
||||
FastAPI endpoint (organizations.py)
|
||||
|
|
||||
v
|
||||
NavService (nav_service.py)
|
||||
| XML body osszeallitas
|
||||
| Kriptografiai alairasok szamitasa
|
||||
| HTTP fejlecek beallitasa
|
||||
v
|
||||
httpx.AsyncClient POST
|
||||
|
|
||||
v
|
||||
NAV API szerver (api-test.onlineszamla.nav.gov.hu)
|
||||
|
|
||||
v
|
||||
XML valasz feldolgozasa (ElementTree)
|
||||
```
|
||||
|
||||
### 1.2. Vegpont
|
||||
|
||||
```
|
||||
POST https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/queryTaxpayer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Kriptografiai Reszletek (A Legkritikusabb Resz)
|
||||
|
||||
### 2.1. passwordHash (SHA-512)
|
||||
|
||||
A jelszo **nyers UTF-8** stringkent kerul a hash-be. Nincs semmilyen elofeldolgozas (salt, peppering).
|
||||
|
||||
### 2.2. requestSignature (SHA3-512) - KET KULONBOZO HELYEN
|
||||
|
||||
| Hely | Algoritmus | Input | Kulcs |
|
||||
|------|-----------|-------|-------|
|
||||
| **HTTP Header** `RequestSignature` | SHA3-512 | requestId + timestamp + NAV_API_EXCHANGE_KEY | Exchange Key |
|
||||
| **XML body** `<user><requestSignature>` | SHA3-512 | requestId + timestamp + NAV_API_SIGN_KEY | Sign Key |
|
||||
|
||||
### 2.3. HTTP Fejlecek
|
||||
|
||||
- `Content-Type`: application/xml; charset=UTF-8
|
||||
- `Accept`: application/xml
|
||||
- `User`: NAV_API_USER
|
||||
- `PasswordHash`: SHA-512(password) hex, UPPERCASE
|
||||
- `ExchangeKey`: NAV_API_EXCHANGE_KEY
|
||||
- `RequestId`: UUID (max 30 karakter, kotojelek nelkul)
|
||||
- `Timestamp`: YYYY-MM-DDTHH:MM:SS.000Z
|
||||
- `RequestSignature`: SHA3-512(requestId + timestamp + exchangeKey)
|
||||
|
||||
### 2.4. XML Body Szerkezet
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<QueryTaxpayerRequest xmlns="http://schemas.nav.gov.hu/OSA/3.0/api">
|
||||
<header xmlns="http://schemas.nav.gov.hu/NTCA/1.0/common">
|
||||
<requestId>{uuid_30_char}</requestId>
|
||||
<timestamp>{UTC_ISO}</timestamp>
|
||||
<requestVersion>3.0</requestVersion>
|
||||
<headerVersion>1.0</headerVersion>
|
||||
</header>
|
||||
<user xmlns="http://schemas.nav.gov.hu/NTCA/1.0/common">
|
||||
<login>{NAV_API_USER}</login>
|
||||
<passwordHash cryptoType="SHA-512">{SHA512(password)}</passwordHash>
|
||||
<taxNumber>{hivo_fel_adoszama_8_jegy}</taxNumber>
|
||||
<requestSignature cryptoType="SHA3-512">{SHA3(requestId+timestamp+signKey)}</requestSignature>
|
||||
</user>
|
||||
<software>
|
||||
<softwareId>SF-NAV-API-001-000</softwareId>
|
||||
<softwareName>ServiceFinder</softwareName>
|
||||
<softwareOperation>LOCAL_SOFTWARE</softwareOperation>
|
||||
<softwareMainVersion>1.0</softwareMainVersion>
|
||||
<softwareDevName>ServiceFinder Kft</softwareDevName>
|
||||
<softwareDevContact>dev@sftest.hu</softwareDevContact>
|
||||
<softwareDevCountryCode>HU</softwareDevCountryCode>
|
||||
<softwareDevTaxNumber>12345678</softwareDevTaxNumber>
|
||||
</software>
|
||||
<taxNumber>{lekerdezendo_ceg_adoszama_8_jegy}</taxNumber>
|
||||
</QueryTaxpayerRequest>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. AZONOSITOTT HIBAK
|
||||
|
||||
### HIBA #1 (KRITIKUS): .env fajlban idezojelek a NAV ertekek korul
|
||||
|
||||
**Hely:** `.env` sorok 90-94
|
||||
|
||||
A `.env` fajlban a NAV ertekek korul idezojelek (aposztróf, dupla idezojel) vannak, amiket a Pydantic Settings literalisan olvas be.
|
||||
|
||||
```env
|
||||
NAV_API_PASSWORD='Tevelegelek&99' <- aposztrofokkal egyutt! (12 helyett 14 kar.)
|
||||
NAV_API_SIGN_KEY=02-b52b-3d9e7d638c695EPWNIUAEGHS <- OK
|
||||
NAV_API_EXCHANGE_KEY=a5cf5EPWNIU9DP8O <- OK
|
||||
NAV_API_CALLER_TAX_NUMBER="48952046133" <- idezojellel egyutt! (11 helyett 12 kar.)
|
||||
```
|
||||
|
||||
**Kovetkezmenyek:**
|
||||
1. `passwordHash = SHA-512("'Tevelegelek&99'")` -> **ROSSZ hash** (aposztrofokkal)
|
||||
2. `caller_tax_number[:8] = '"4895204'` -> **ROSSZ adoszam** (az elso 8 karakterbe beleesik a ")
|
||||
|
||||
**Bizonyitas:**
|
||||
```
|
||||
SHA-512('Tevelegelek&99') -> 452506A930168C81DB06...
|
||||
SHA-512(Tevelegelek&99) -> CC854A1E4F49B484396A...
|
||||
^ NEM egyezik!
|
||||
```
|
||||
|
||||
### HIBA #2 (MODSZERTANI): Nincs beepitett quote stripping a config-ban
|
||||
|
||||
**Hely:** `backend/app/core/config.py` (NAV_API_* mezo definiciok)
|
||||
|
||||
A Pydantic Settings nem strippeli automatikusan az idezojeleket.
|
||||
|
||||
### NEM HIBA: A kod logikaja helyes
|
||||
|
||||
A `nav_service.py` kod szerkezetileg megfelel a NAV API v3 specifikacionak:
|
||||
- SHA-512 a passwordHash-hoz
|
||||
- SHA3-512 a requestSignature-hoz
|
||||
- Ket kulonbozo kulcs: exchangeKey (HTTP) vs signKey (XML)
|
||||
- UUID max 30 karakter, timestamp UTC formaban
|
||||
- Helyes XML namespace-ek (OSA/3.0/api es NTCA/1.0/common)
|
||||
- Helyes HTTP hibakezeles (503 ha NAV nem elerheto, 404 ha nincs talalat)
|
||||
|
||||
---
|
||||
|
||||
## 4. JAVITASI TERV
|
||||
|
||||
### 4.1. .env fajl javitasa
|
||||
|
||||
Valtoztasd meg a kovetkezo sorokat:
|
||||
|
||||
```
|
||||
# REGI (hibas):
|
||||
NAV_API_PASSWORD='Tevelegelek&99'
|
||||
NAV_API_CALLER_TAX_NUMBER="48952046133"
|
||||
|
||||
# UJ (helyes):
|
||||
NAV_API_PASSWORD=Tevelegelek&99
|
||||
NAV_API_CALLER_TAX_NUMBER=48952046133
|
||||
```
|
||||
|
||||
### 4.2. Config quote stripping (vedohalo)
|
||||
|
||||
Add hozza a kovetkezo validatort `backend/app/core/config.py`-hoz a Settings osztalyba a NAV_API_* mezok utan:
|
||||
|
||||
```python
|
||||
from pydantic import field_validator
|
||||
|
||||
# Add this validator for all NAV fields:
|
||||
@field_validator('NAV_API_PASSWORD', 'NAV_API_SIGN_KEY', 'NAV_API_EXCHANGE_KEY', 'NAV_API_CALLER_TAX_NUMBER')
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v:
|
||||
v = v.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
|
||||
v = v[1:-1]
|
||||
return v
|
||||
```
|
||||
|
||||
### 4.3. Service biztonsagi javitas
|
||||
|
||||
`nav_service.py`-ban a `_build_query_taxpayer_xml()` modositas:
|
||||
|
||||
```python
|
||||
target_tax_number_core = (
|
||||
tax_number
|
||||
.strip()
|
||||
.replace("-", "")
|
||||
.replace(" ", "")[:8]
|
||||
)
|
||||
```
|
||||
|
||||
### 4.4. Teszteles
|
||||
|
||||
A javitas utan:
|
||||
|
||||
```bash
|
||||
# 1. Kontener ujrainditas
|
||||
docker compose restart sf_api
|
||||
|
||||
# 2. Teszt lekerdezes
|
||||
docker exec sf_api python3 -c "
|
||||
from app.services.nav_service import NavService
|
||||
import asyncio
|
||||
result = asyncio.run(NavService.query_taxpayer('24742063'))
|
||||
print(result)
|
||||
"
|
||||
```
|
||||
|
||||
Sikeres valasz formatuma:
|
||||
```json
|
||||
{
|
||||
"full_name": "CEG NEVE KFT.",
|
||||
"name": "CEG NEVE KFT.",
|
||||
"display_name": "CEG NEVE KFT.",
|
||||
"address_zip": "1234",
|
||||
"address_city": "BUDAPEST",
|
||||
"address_street_name": "Fo utca",
|
||||
"address_street_type": "utca",
|
||||
"address_house_number": "1"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Alternativak (Ha a NAV API tovabbra sem mukodik)
|
||||
|
||||
| Megoldas | Tipus | Elony | Hatrany |
|
||||
|----------|-------|-------|---------|
|
||||
| VIES (EU VAT) | SOAP/XML | Ingyenes, EU-s hivatalos | Csak EU-s adoszamok |
|
||||
| OpenCorporates | REST/JSON | Konnyu integracio | Ingyenes verzio limitalt |
|
||||
| Opten / Bisnode | Fizetos API | Teljes magyar cegadatok | Fizetos |
|
||||
| ceginfo.hu | REST API | Magyar fokusz | Fizetos |
|
||||
|
||||
Ha tovabbra is `INVALID_REQUEST_SIGNATURE`, akkor a NAV portalon (https://onlineszamla.nav.gov.hu/) uj signKey/exchangeKey part kell igenyelni.
|
||||
191
docs/profile_person_user_audit_2026-06-06.md
Normal file
191
docs/profile_person_user_audit_2026-06-06.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 📋 Person & User Profil Adatkezelés Audit (2026-06-06)
|
||||
|
||||
## 🎯 Cél
|
||||
A Person és User entitások által nyilvántartott adatok összehasonlítása a frontend ProfileView által megjelenített és szerkeszthető mezőkkel.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Adatbázis Modellek (Backend)
|
||||
|
||||
### 🧑 Person (identity.persons) – Természetes Személy
|
||||
**Séma:** `identity.persons`
|
||||
|
||||
| Mező | Típus | Nullable | Cél |
|
||||
|------|-------|----------|-----|
|
||||
| `id` | BigInteger | **NOT NULL** | Elsődleges kulcs |
|
||||
| `id_uuid` | UUID | **NOT NULL** | Külső API-khoz UUID azonosító |
|
||||
| `address_id` | UUID | NULL | FK → `system.addresses` |
|
||||
| `identity_hash` | String(64) | NULL | Deduplikáció: Név+Anyja+DOB hash |
|
||||
| **`last_name`** | String | **NOT NULL** | ✅ **Frontend használja** |
|
||||
| **`first_name`** | String | **NOT NULL** | ✅ **Frontend használja** |
|
||||
| **`phone`** | String | NULL | ✅ **Frontend használja** |
|
||||
| **`mothers_last_name`** | String | NULL | ✅ **Frontend használja** |
|
||||
| **`mothers_first_name`** | String | NULL | ✅ **Frontend használja** |
|
||||
| **`birth_place`** | String | NULL | ✅ **Frontend használja** |
|
||||
| **`birth_date`** | DateTime | NULL | ✅ **Frontend használja** |
|
||||
| **`identity_docs`** | JSON | **NOT NULL** (default `{}`) | ✅ **Részben használva** (ID_CARD, LICENSE) |
|
||||
| **`ice_contact`** | JSON | **NOT NULL** (default `{}`) | ❌ **NEM használja a frontend!** |
|
||||
| `lifetime_xp` | BigInteger | **NOT NULL** (default -1) | ❌ **Gamification – nem látható** |
|
||||
| `penalty_points` | Integer | **NOT NULL** (default -1) | ❌ **Gamification – nem látható** |
|
||||
| `social_reputation` | Numeric(3,2) | **NOT NULL** | ❌ **Nem látható** |
|
||||
| `is_sales_agent` | Boolean | **NOT NULL** | ⚙️ **Belső rendszer, nem látható** |
|
||||
| `is_active` | Boolean | **NOT NULL** | ⚙️ **Belső rendszer** |
|
||||
| `is_ghost` | Boolean | **NOT NULL** | ⚙️ **Soft-delete logika** |
|
||||
| `merged_into_id` | BigInteger | NULL | FK (self) – MDM merge logika |
|
||||
|
||||
### 👤 User (identity.users) – Login Entitás
|
||||
**Séma:** `identity.users`
|
||||
|
||||
| Mező | Típus | Nullable | Frontend látható? |
|
||||
|------|-------|----------|-------------------|
|
||||
| `id` | Integer | **NOT NULL** | – |
|
||||
| **`email`** | String | **NOT NULL** (unique) | ✅ **Readonly a profilban** |
|
||||
| `hashed_password` | String | NULL | ✅ **Külön modal változtatáshoz** |
|
||||
| **`role`** | UserRole ENUM | **NOT NULL** | ❌ **Nem látható** |
|
||||
| `person_id` | BigInteger | NULL | FK |
|
||||
| `subscription_plan` | String(30) | **NOT NULL** (default 'FREE') | ❌ **Nem látható** |
|
||||
| `subscription_expires_at` | DateTime | NULL | ❌ **Nem látható** |
|
||||
| `is_vip` | Boolean | **NOT NULL** | ❌ **Nem látható** |
|
||||
| `referral_code` | String(20) | NULL (unique) | ❌ **Nem látható** |
|
||||
| **`preferred_language`** | String(5) | **NOT NULL** (default "hu") | ❌ **Nem látható/szerkeszthető!** |
|
||||
| **`region_code`** | String(5) | **NOT NULL** (default "HU") | ❌ **Nem látható!** |
|
||||
| **`preferred_currency`** | String(3) | **NOT NULL** (default "HUF") | ❌ **Nem látható!** |
|
||||
| **`ui_mode`** | String(20) | **NOT NULL** (default "personal") | ❌ **Nem látható!** |
|
||||
| `alternative_emails` | JSON | **NOT NULL** (default `[]`) | ❌ **Nem látható** |
|
||||
| `email_history` | JSON | **NOT NULL** (default `[]`) | ❌ **Nem látható** |
|
||||
|
||||
### 🏠 Address (system.addresses)
|
||||
|
||||
| Mező | Típus | Frontend használja? |
|
||||
|------|-------|---------------------|
|
||||
| **`street_name`** | String(200) | ✅ `address_street_name` |
|
||||
| **`street_type`** | String(50) | ✅ `address_street_type` |
|
||||
| **`house_number`** | String(50) | ✅ `address_house_number` |
|
||||
| **`stairwell`** | String(20) | ✅ `address_stairwell` |
|
||||
| **`floor`** | String(20) | ✅ `address_floor` |
|
||||
| **`door`** | String(20) | ✅ `address_door` |
|
||||
| **`parcel_id`** (HRSZ) | String(50) | ⚠️ **EditForm-ban van, de UI-on HIÁNYZIK!** |
|
||||
| `.zip` (property) | String | ✅ `address_zip` |
|
||||
| `.city` (property) | String | ✅ `address_city` |
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Frontend ProfileView.vue
|
||||
|
||||
### ✅ Megjelenített és Szerkeszthető Mezők
|
||||
|
||||
#### 1️⃣ Fiók Adatok (User)
|
||||
- `email` ❌ **Readonly**
|
||||
- `password` ✅ **Külön modal**
|
||||
|
||||
#### 2️⃣ Személyes Adatok (Person)
|
||||
- `first_name`, `last_name`, `phone`
|
||||
- `birth_place`, `birth_date`
|
||||
- `mothers_last_name`, `mothers_first_name`
|
||||
|
||||
#### 3️⃣ Okmányok (identity_docs JSON)
|
||||
- **ID_CARD**: `number`, `expiry_date`
|
||||
- **LICENSE**: `number`, `expiry_date`, `categories`
|
||||
|
||||
#### 4️⃣ Lakcím (Address)
|
||||
- `address_zip`, `address_city` (auto-fill)
|
||||
- `address_street_name`, `address_street_type`, `address_house_number`
|
||||
- `address_stairwell`, `address_floor`, `address_door`
|
||||
- ⚠️ **`address_hrsz` NINCS UI-on!**
|
||||
|
||||
---
|
||||
|
||||
## 🚨 KRITIKUS HIÁNYOSSÁGOK
|
||||
|
||||
### 🔴 1. HRSZ (helyrajzi szám) mező hiányzik a UI-ból
|
||||
**Probléma:** Az `editForm.address_hrsz` reactive változóban szerepel, a backend támogatja (`Address.parcel_id`), de **nincs input mező a template-ben**.
|
||||
|
||||
**Hatás:** Vidéki ingatlanok (telek, tanya) esetén a HRSZ kötelező adat lehet, ezt jelenleg nem lehet megadni.
|
||||
|
||||
### 🟠 2. Vészhelyzeti Kapcsolattartó (ICE Contact) hiányzik
|
||||
**Probléma:** A `Person.ice_contact` JSON mező az adatbázisban létezik (default `{}`), de **sehol sem szerkeszthető**.
|
||||
|
||||
**Hatás:** Balesetnél vagy vészhelyzetben nem tud a rendszer értesítést küldeni.
|
||||
|
||||
**Javasolt struktúra:**
|
||||
```json
|
||||
{
|
||||
"name": "Nagy János",
|
||||
"relationship": "testvér",
|
||||
"phone": "+36 30 123 4567",
|
||||
"email": "janos.nagy@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### 🟡 3. User Preferenciák nem szerkeszthetők
|
||||
**Probléma:** Az alábbi User-szintű beállítások NEM láthatók/módosíthatók:
|
||||
- `preferred_language` (jelenleg "hu" fix)
|
||||
- `region_code` (jelenleg "HU" fix)
|
||||
- `preferred_currency` (jelenleg "HUF" fix)
|
||||
- `ui_mode` (personal/fleet váltás)
|
||||
|
||||
**Hatás:**
|
||||
- A nyelvet a LanguageSwitcher komponens kezeli, de nem kerül mentésre a User táblába
|
||||
- A többdevizás költségkövetés (EUR, USD) nem működik
|
||||
- A "personal" vs "fleet" mód nem váltható
|
||||
|
||||
### 🟢 4. Passport (Útlevél) és Tax Number (Adószám) hiányzik
|
||||
**Javasolt bővítés (identity_docs JSON):**
|
||||
```json
|
||||
{
|
||||
"PASSPORT": { "number": "...", "expiry_date": "...", "issuing_country": "HU" },
|
||||
"TAX_ID": { "number": "...", "country": "HU" }
|
||||
}
|
||||
```
|
||||
|
||||
### 🔵 5. Gamification és Trust Score adatok nem láthatók
|
||||
**Probléma:** A felhasználó **nem láthatja a saját** statisztikáit:
|
||||
- `Person.lifetime_xp`, `penalty_points`, `social_reputation`
|
||||
- `UserTrustProfile.trust_score` (Gondos Gazda Index)
|
||||
|
||||
**Hatás:** A gamification funkciók nem motiválják a felhasználót.
|
||||
|
||||
**Megoldás:** A backend már támogatja: `GET /users/me/trust`
|
||||
|
||||
### 🟣 6. Subscription (Előfizetés) információk elrejtve
|
||||
**Probléma:** `subscription_plan`, `subscription_expires_at`, `is_vip` **sehol sem látszódnak**.
|
||||
|
||||
**Hatás:** A felhasználó nem tudja, meddig érvényes a PRO csomagja.
|
||||
|
||||
---
|
||||
|
||||
## 📝 ÖSSZEFOGLALÓ TÁBLÁZAT
|
||||
|
||||
| Kategória | Backend (DB) | Frontend (UI) | Státusz |
|
||||
|-----------|--------------|---------------|---------|
|
||||
| **Person főbb adatok** | ✅ Teljes | ✅ Teljes | 🟢 **SZINKRONBAN** |
|
||||
| **Identity Docs (ID, License)** | ✅ JSON | ✅ Szerkeszthető | 🟢 **SZINKRONBAN** |
|
||||
| **Lakcím alapadatok** | ✅ 8 mező | ✅ 8 mező | 🟢 **SZINKRONBAN** |
|
||||
| **HRSZ (parcel_id)** | ✅ Létezik | ❌ **Hiányzik** | 🔴 **GAP!** |
|
||||
| **ICE Contact** | ✅ JSON | ❌ **Hiányzik** | 🔴 **GAP!** |
|
||||
| **User preferenciák** | ✅ 4 mező | ❌ **Nem szerkeszthető** | 🟠 **GAP!** |
|
||||
| **Passport, Tax ID** | ⚠️ JSON-ban lehetne | ❌ **Hiányzik** | 🟡 **Fejlesztendő** |
|
||||
| **Gamification** | ✅ Létezik | ❌ **Nem látható** | 🟡 **Fejlesztendő** |
|
||||
| **Subscription** | ✅ Létezik | ❌ **Nem látható** | 🟡 **Fejlesztendő** |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 KÖVETKEZŐ LÉPÉSEK
|
||||
|
||||
### 🔴 Kritikus (Immediate)
|
||||
1. **HRSZ mező hozzáadása** a ProfileView.vue-hoz (~10 perc)
|
||||
2. **PersonUpdate schema audit**: Megkeresni a sémát
|
||||
|
||||
### 🟠 Fontos (Short-term)
|
||||
3. **ICE Contact szerkesztő** (~1 óra)
|
||||
4. **User Preferences** szerkeszthetővé tétele (~2 óra)
|
||||
|
||||
### 🟡 Hasznos (Mid-term)
|
||||
5. **Passport és Tax ID** (~1 óra)
|
||||
6. **Gamification/Trust Score megjelenítés** (~30 perc)
|
||||
7. **Subscription info megjelenítése** (~20 perc)
|
||||
|
||||
---
|
||||
|
||||
**Dokumentum:** 2026-06-06 | Service Finder System Audit
|
||||
|
||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@@ -9,7 +9,9 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@lucide/vue": "^1.17.0",
|
||||
"axios": "^1.6.0",
|
||||
"flag-icons": "^7.5.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.11.0",
|
||||
@@ -717,6 +719,15 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/vue": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.17.0.tgz",
|
||||
"integrity": "sha512-6Q1ZHgr5FbmJzKWe5BxlNdjLj2lbmuH1zwDtVzUJofX0w9UREwKgq4F4jwKqFYyyIS4Rj3FiJvDi2k6djukmmw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2555,6 +2566,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/flag-icons": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@lucide/vue": "^1.17.0",
|
||||
"axios": "^1.6.0",
|
||||
"flag-icons": "^7.5.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.11.0",
|
||||
|
||||
BIN
frontend/public/images/sf_landing_back.png
Normal file
BIN
frontend/public/images/sf_landing_back.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 MiB |
BIN
frontend/src/assets/sf_landing_back.png
Normal file
BIN
frontend/src/assets/sf_landing_back.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 MiB |
@@ -1,301 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-50 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: Logo + Brand (clickable, links to /) ─────────────── -->
|
||||
<router-link to="/dashboard" class="flex items-center gap-3 cursor-pointer transition-all duration-300 hover:opacity-80 hover:scale-[1.02]">
|
||||
<!-- Real brand logo with premium gradient -->
|
||||
<img
|
||||
src="@/assets/sf-brand-logo.svg"
|
||||
class="h-10 w-auto mr-3 drop-shadow-md"
|
||||
alt="ServiceFinder Logo"
|
||||
/>
|
||||
|
||||
<!-- Brand text (hidden on very small screens) -->
|
||||
<div class="hidden sm:block">
|
||||
<span class="text-base font-black tracking-widest text-white">SERVICE</span>
|
||||
<span class="text-base font-black tracking-widest text-[#418890]">FINDER</span>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<!-- ── Center/Left: Funkciók Menu ─────────────────────────────── -->
|
||||
<div class="relative flex items-center func-menu-container">
|
||||
<!-- Funkciók trigger button -->
|
||||
<button
|
||||
@click.stop="isFuncMenuOpen = !isFuncMenuOpen"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium text-white/70 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer"
|
||||
:aria-label="t('menu.features')"
|
||||
>
|
||||
<!-- Hamburger / grid icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ t('menu.features') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- ── Funkciók Dropdown (Glassmorphism Bento) ──────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isFuncMenuOpen"
|
||||
class="absolute left-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<div class="py-2">
|
||||
<!-- Járművek (Garázs) -->
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
<span>{{ t('menu.garage') }} <span class="text-white/40">({{ t('menu.garage') }})</span></span>
|
||||
</button>
|
||||
|
||||
<!-- Szerviznaptár -->
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ t('menu.calendar') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Pénzügyek és Költségek -->
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[#418890]" 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>{{ t('menu.finance') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Élő Diagnosztika -->
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span>{{ t('menu.diagnostic') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-white/10 my-1" />
|
||||
|
||||
<!-- Kiemelt: + Új Jármű Hozzáadása -->
|
||||
<div class="py-2 px-3">
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#418890] to-[#70BC84] px-4 py-3 text-sm font-bold text-white shadow-lg transition-all duration-200 hover:shadow-[0_0_20px_rgba(0,229,160,0.4)] hover:-translate-y-0.5"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('menu.addVehicle') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- ── Spacer to push right items ─────────────────────────────── -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- ── Language Switcher (extracted component) ────────────────── -->
|
||||
<LanguageSwitcher />
|
||||
|
||||
<!-- ── Right: Welcome + User Dropdown ─────────────────────────── -->
|
||||
<div class="relative flex items-center gap-4">
|
||||
<!-- Welcome text -->
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-white/90">
|
||||
{{ t('header.welcome') }}, <span class="font-semibold text-[#70BC84]">{{ displayName }}</span>!
|
||||
</p>
|
||||
<p v-if="subtitle" class="text-[11px] text-white/40">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Avatar button — click to toggle dropdown -->
|
||||
<button
|
||||
@click="isUserMenuOpen = !isUserMenuOpen"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#70BC84]/20 text-sm font-bold text-[#70BC84] ring-2 ring-white/10 transition-all duration-200 hover:ring-[#70BC84]/50 focus:outline-none focus:ring-[#70BC84]/50 cursor-pointer"
|
||||
:aria-label="t('header.userMenu')"
|
||||
>
|
||||
{{ initials }}
|
||||
</button>
|
||||
|
||||
<!-- ── Dropdown Menu (Glassmorphism) ──────────────────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isUserMenuOpen"
|
||||
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- User info header -->
|
||||
<div class="px-4 py-3 border-b border-white/5">
|
||||
<p class="text-sm font-semibold text-white/90 truncate">{{ fullName }}</p>
|
||||
<p class="text-xs text-white/40 truncate mt-0.5">{{ email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Menu items -->
|
||||
<div class="py-1">
|
||||
<!-- Profile settings -->
|
||||
<button
|
||||
@click="goToProfile"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<!-- User icon -->
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.profile') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-white/5 my-1" />
|
||||
|
||||
<!-- Logout -->
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/70 transition-all duration-150 hover:bg-white/5 hover:text-red-400"
|
||||
>
|
||||
<!-- Logout icon -->
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40 group-hover:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
firstName?: string
|
||||
subtitle?: string
|
||||
}>(),
|
||||
{
|
||||
firstName: '',
|
||||
subtitle: 'Irányítópult',
|
||||
},
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Dropdown states ────────────────────────────────────────────────
|
||||
const isUserMenuOpen = ref(false)
|
||||
const isFuncMenuOpen = ref(false)
|
||||
// LanguageSwitcher handles its own state internally
|
||||
|
||||
// ── Close dropdowns on outside click ───────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isFuncMenuOpen.value && !target.closest('.func-menu-container')) {
|
||||
isFuncMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
|
||||
// ── Computed display helpers ───────────────────────────────────────
|
||||
const displayName = computed(() => {
|
||||
if (props.firstName && props.firstName !== 'Vendég') return props.firstName
|
||||
// Fallback: use email prefix (before @)
|
||||
if (authStore.user?.email) {
|
||||
const emailPrefix = authStore.user.email.split('@')[0]
|
||||
return emailPrefix || 'Felhasználó'
|
||||
}
|
||||
return 'Felhasználó'
|
||||
})
|
||||
|
||||
const fullName = computed(() => {
|
||||
if (!authStore.user) return 'Felhasználó'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`
|
||||
if (first_name) return first_name
|
||||
if (last_name) return last_name
|
||||
return authStore.user.email || 'Felhasználó'
|
||||
})
|
||||
|
||||
const email = computed(() => {
|
||||
return authStore.user?.email || ''
|
||||
})
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!authStore.user) return '?'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) {
|
||||
return (first_name[0] + last_name[0]).toUpperCase()
|
||||
}
|
||||
if (first_name) return first_name[0].toUpperCase()
|
||||
if (authStore.user.email) return authStore.user.email[0].toUpperCase()
|
||||
return '?'
|
||||
})
|
||||
|
||||
// ── Navigate to Profile ────────────────────────────────────────────
|
||||
function goToProfile() {
|
||||
isUserMenuOpen.value = false
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
// ── Logout handler ─────────────────────────────────────────────────
|
||||
function handleLogout() {
|
||||
isUserMenuOpen.value = false
|
||||
authStore.logout()
|
||||
}
|
||||
</script>
|
||||
@@ -5,8 +5,8 @@
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium text-white/70 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer"
|
||||
:title="t('header.switchLanguage')"
|
||||
>
|
||||
<span class="text-base">{{ currentLocale === 'hu' ? '🇭🇺' : '🇬🇧' }}</span>
|
||||
<span class="hidden sm:inline font-semibold">{{ currentLocale === 'hu' ? 'HU' : 'EN' }}</span>
|
||||
<span :class="['fi', `fi-${currentFlagCode}`, 'fis', 'rounded-sm', 'shadow-sm']"></span>
|
||||
<span class="hidden sm:inline font-semibold uppercase">{{ currentLocale }}</span>
|
||||
<!-- Chevron down -->
|
||||
<svg
|
||||
class="w-3.5 h-3.5 ml-0.5 text-white/40 transition-transform duration-200"
|
||||
@@ -30,33 +30,20 @@
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute left-0 top-full mt-2 w-40 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
class="absolute left-0 top-full mt-2 w-48 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<div class="py-1">
|
||||
<!-- Magyar -->
|
||||
<button
|
||||
@click="setLanguage('hu')"
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
@click="setLanguage(lang.code)"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
:class="{ 'text-[#70BC84] font-semibold': currentLocale === 'hu' }"
|
||||
:class="{ 'text-[#70BC84] font-semibold': currentLocale === lang.code }"
|
||||
>
|
||||
<span class="text-base">🇭🇺</span>
|
||||
<span>Magyar</span>
|
||||
<span v-if="currentLocale === 'hu'" class="ml-auto text-[#70BC84]">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- English -->
|
||||
<button
|
||||
@click="setLanguage('en')"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
:class="{ 'text-[#70BC84] font-semibold': currentLocale === 'en' }"
|
||||
>
|
||||
<span class="text-base">🇬🇧</span>
|
||||
<span>English</span>
|
||||
<span v-if="currentLocale === 'en'" class="ml-auto text-[#70BC84]">
|
||||
<span :class="['fi', `fi-${lang.flagCode}`, 'fis', 'rounded-sm', 'shadow-sm']"></span>
|
||||
<span class="uppercase font-medium">{{ lang.code }}</span>
|
||||
<span class="text-white/50">{{ lang.label }}</span>
|
||||
<span v-if="currentLocale === lang.code" class="ml-auto text-[#70BC84]">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
@@ -77,6 +64,26 @@ const { t, locale } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const currentLocale = computed(() => locale.value)
|
||||
|
||||
interface Language {
|
||||
code: string
|
||||
flagCode: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const languages: Language[] = [
|
||||
{ code: 'hu', flagCode: 'hu', label: 'Magyar' },
|
||||
{ code: 'en', flagCode: 'gb', label: 'English' },
|
||||
{ code: 'de', flagCode: 'de', label: 'Deutsch' },
|
||||
{ code: 'ro', flagCode: 'ro', label: 'Română' },
|
||||
{ code: 'cz', flagCode: 'cz', label: 'Čeština' },
|
||||
{ code: 'sk', flagCode: 'sk', label: 'Slovenčina' },
|
||||
]
|
||||
|
||||
const currentFlagCode = computed(() => {
|
||||
const lang = languages.find(l => l.code === currentLocale.value)
|
||||
return lang ? lang.flagCode : 'gb'
|
||||
})
|
||||
|
||||
function setLanguage(lang: string) {
|
||||
locale.value = lang
|
||||
localStorage.setItem('user-locale', lang)
|
||||
|
||||
63
frontend/src/components/header/HeaderBackButton.vue
Normal file
63
frontend/src/components/header/HeaderBackButton.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<button
|
||||
@click="handleClick"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
|
||||
:class="btnClass"
|
||||
:aria-label="ariaLabel || label"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Route to navigate to on click */
|
||||
to?: string
|
||||
/** Button label text */
|
||||
label?: string
|
||||
/** Optional aria-label override */
|
||||
ariaLabel?: string
|
||||
/** Visual variant */
|
||||
variant?: 'default' | 'warning'
|
||||
/** Optional click handler override (prevents navigation if provided) */
|
||||
onClick?: () => void
|
||||
}>(),
|
||||
{
|
||||
to: '/dashboard',
|
||||
label: '',
|
||||
ariaLabel: '',
|
||||
variant: 'default',
|
||||
onClick: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const btnClass = computed(() => {
|
||||
if (props.variant === 'warning') {
|
||||
return 'text-yellow-400/80 hover:bg-white/5 hover:text-yellow-300'
|
||||
}
|
||||
// Default variant uses dynamic theme colors (white-labeling support)
|
||||
// bg-themePrimary picks up the --theme-primary CSS variable set by the theme store
|
||||
return 'bg-themePrimary text-white hover:opacity-90'
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (props.onClick) {
|
||||
props.onClick()
|
||||
return
|
||||
}
|
||||
if (props.to) {
|
||||
router.push(props.to)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
224
frontend/src/components/header/HeaderCompanySwitcher.vue
Normal file
224
frontend/src/components/header/HeaderCompanySwitcher.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="relative org-dropdown-container">
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
@click.stop="isOpen = !isOpen"
|
||||
:disabled="authStore.isLoading"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
|
||||
:class="orgButtonClass"
|
||||
>
|
||||
<!-- Loading spinner -->
|
||||
<svg
|
||||
v-if="authStore.isLoading"
|
||||
class="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<!-- Icon -->
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="orgButtonState === 'create'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span>{{ orgButtonLabel }}</span>
|
||||
<!-- Chevron down when there are organizations -->
|
||||
<svg
|
||||
v-if="authStore.myOrganizations.length > 0 && orgButtonState === 'switch'"
|
||||
class="w-3 h-3 transition-transform"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- ── Organization Dropdown ────────────────────────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- ── When on Company Garage: show "👤 Privát Garázs" back button ── -->
|
||||
<div v-if="isOnCompanyGarage" class="py-1 border-b border-white/10">
|
||||
<button
|
||||
@click="goToPersonalDashboard"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-gray-200 transition-all duration-150 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-gray-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>👤 {{ t('header.privateGarage') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Organization list ─────────────────────────────────── -->
|
||||
<div v-if="filteredOrganizations.length > 0" class="py-1 max-h-60 overflow-y-auto">
|
||||
<router-link
|
||||
v-for="org in filteredOrganizations"
|
||||
:key="org.organization_id"
|
||||
:to="'/organization/' + org.organization_id"
|
||||
@click="isOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
|
||||
:class="isActiveOrg(org) ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
|
||||
>
|
||||
<!-- Org icon -->
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span class="truncate">{{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}</span>
|
||||
<!-- Active checkmark -->
|
||||
<svg
|
||||
v-if="isActiveOrg(org)"
|
||||
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Empty state when no orgs exist -->
|
||||
<div v-if="authStore.myOrganizations.length === 0" class="py-4 px-4 text-center text-white/50 text-sm">
|
||||
{{ t('header.noCompanies') }}
|
||||
</div>
|
||||
|
||||
<!-- Divider before "Create new" -->
|
||||
<hr class="border-gray-700 my-1" />
|
||||
|
||||
<!-- Create new organization -->
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="goToOnboard"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>{{ t('header.newCompany') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { OrganizationItem } from '@/types/organization'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// ── Debug: log organizations on mount ───────────────────────────────
|
||||
onMounted(async () => {
|
||||
// Ensure organizations are loaded (safety net if init() didn't run)
|
||||
if (authStore.myOrganizations.length === 0 && authStore.isAuthenticated) {
|
||||
await authStore.fetchMyOrganizations()
|
||||
}
|
||||
console.log('🔍 [HeaderCompanySwitcher] Available companies:', authStore.myOrganizations)
|
||||
})
|
||||
|
||||
// ── Route-based detection ─────────────────────────────────────────
|
||||
const isOnCompanyGarage = computed(() => {
|
||||
return route.path.startsWith('/organization/')
|
||||
})
|
||||
|
||||
// ── Current org ID from route (when on company garage) ────────────
|
||||
const currentOrgId = computed(() => {
|
||||
if (!isOnCompanyGarage.value) return null
|
||||
return Number(route.params.id) || null
|
||||
})
|
||||
|
||||
// ── Filtered organizations: exclude the current one when on garage ─
|
||||
const filteredOrganizations = computed(() => {
|
||||
const orgs = authStore.myOrganizations
|
||||
if (isOnCompanyGarage.value && currentOrgId.value) {
|
||||
return orgs.filter((o) => o.organization_id !== currentOrgId.value)
|
||||
}
|
||||
return orgs
|
||||
})
|
||||
|
||||
// ── Organization Button State ──────────────────────────────────────
|
||||
const orgButtonState = computed(() => {
|
||||
if (!authStore.isAuthenticated) return 'create'
|
||||
if (isOnCompanyGarage.value) return 'on-garage'
|
||||
if (authStore.isCorporateMode) return 'corporate'
|
||||
if (authStore.hasBusinessOrganization) return 'switch'
|
||||
return 'create'
|
||||
})
|
||||
|
||||
const orgButtonLabel = computed(() => {
|
||||
switch (orgButtonState.value) {
|
||||
case 'create':
|
||||
return t('header.newCompany')
|
||||
case 'switch': {
|
||||
const count = authStore.myOrganizations.length
|
||||
if (count === 1) return t('header.myCompany')
|
||||
return t('header.myCompanies')
|
||||
}
|
||||
case 'corporate':
|
||||
return t('header.personalGarage')
|
||||
case 'on-garage':
|
||||
return t('header.privateGarage')
|
||||
default:
|
||||
return t('header.company')
|
||||
}
|
||||
})
|
||||
|
||||
const orgButtonClass = computed(() => {
|
||||
if (orgButtonState.value === 'corporate') {
|
||||
return 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/20 hover:bg-yellow-500/25 hover:text-yellow-300'
|
||||
}
|
||||
return 'text-white/70 hover:bg-white/5 hover:text-white'
|
||||
})
|
||||
|
||||
// ── Check if org is the active one ─────────────────────────────────
|
||||
function isActiveOrg(org: OrganizationItem): boolean {
|
||||
return authStore.user?.active_organization_id === org.organization_id
|
||||
}
|
||||
|
||||
// ── Navigate to personal dashboard ────────────────────────────────
|
||||
async function goToPersonalDashboard() {
|
||||
isOpen.value = false
|
||||
await authStore.switchOrganization(null)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
// ── Navigate to onboard ───────────────────────────────────────────
|
||||
function goToOnboard() {
|
||||
isOpen.value = false
|
||||
router.push('/company/onboard')
|
||||
}
|
||||
|
||||
// ── Close dropdown on outside click ───────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isOpen.value && !target.closest('.org-dropdown-container')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
39
frontend/src/components/header/HeaderLogo.vue
Normal file
39
frontend/src/components/header/HeaderLogo.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<router-link
|
||||
:to="targetRoute"
|
||||
class="flex items-center gap-3 cursor-pointer transition-all duration-300 hover:opacity-80 hover:scale-[1.02]"
|
||||
:aria-label="t('header.home')"
|
||||
>
|
||||
<img
|
||||
src="@/assets/sf-brand-logo.svg"
|
||||
class="h-10 w-auto mr-3 drop-shadow-md"
|
||||
alt="ServiceFinder Logo"
|
||||
/>
|
||||
<div class="hidden sm:block">
|
||||
<span class="text-base font-black tracking-widest text-white">SERVICE</span>
|
||||
<span class="text-base font-black tracking-widest text-[#418890]">FINDER</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
/**
|
||||
* Compute the target route for the logo link.
|
||||
* - If the user is on an /organization/:id page, navigate to the org root.
|
||||
* - Otherwise, navigate to /dashboard (private garage).
|
||||
*/
|
||||
const targetRoute = computed(() => {
|
||||
if (route.path.startsWith('/organization/')) {
|
||||
const orgId = route.params.id
|
||||
return orgId ? `/organization/${orgId}` : route.path
|
||||
}
|
||||
return '/dashboard'
|
||||
})
|
||||
</script>
|
||||
145
frontend/src/components/header/HeaderProfile.vue
Normal file
145
frontend/src/components/header/HeaderProfile.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative flex items-center">
|
||||
<!-- Avatar button — click to toggle dropdown -->
|
||||
<button
|
||||
@click="isOpen = !isOpen"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#70BC84]/20 text-sm font-bold text-[#70BC84] ring-2 ring-white/10 transition-all duration-200 hover:ring-[#70BC84]/50 focus:outline-none focus:ring-[#70BC84]/50 cursor-pointer"
|
||||
:aria-label="t('header.userMenu')"
|
||||
>
|
||||
{{ initials }}
|
||||
</button>
|
||||
|
||||
<!-- ── Dropdown Menu (Glassmorphism) ──────────────────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- User info header -->
|
||||
<div class="px-4 py-3 border-b border-white/5">
|
||||
<p class="text-sm font-semibold text-white/90 truncate">{{ fullName }}</p>
|
||||
<p class="text-xs text-white/40 truncate mt-0.5">{{ email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Menu items -->
|
||||
<div class="py-1">
|
||||
<!-- Profile settings -->
|
||||
<button
|
||||
@click="goToProfile"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.profile') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-white/5 my-1" />
|
||||
|
||||
<!-- Logout -->
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/70 transition-all duration-150 hover:bg-white/5 hover:text-red-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// ── Computed display helpers ───────────────────────────────────────
|
||||
const fullName = computed(() => {
|
||||
if (!authStore.user) return t('header.user')
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`
|
||||
if (first_name) return first_name
|
||||
if (last_name) return last_name
|
||||
return authStore.user.email || t('header.user')
|
||||
})
|
||||
|
||||
const email = computed(() => {
|
||||
return authStore.user?.email || ''
|
||||
})
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!authStore.user) return '?'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) {
|
||||
return (first_name[0] + last_name[0]).toUpperCase()
|
||||
}
|
||||
if (first_name) return first_name[0].toUpperCase()
|
||||
if (authStore.user.email) return authStore.user.email[0].toUpperCase()
|
||||
return '?'
|
||||
})
|
||||
|
||||
// ── Navigate to Profile ────────────────────────────────────────────
|
||||
function goToProfile() {
|
||||
isOpen.value = false
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
// ── Logout handler ─────────────────────────────────────────────────
|
||||
function handleLogout() {
|
||||
isOpen.value = false
|
||||
authStore.logout()
|
||||
}
|
||||
|
||||
// ── Close dropdown on outside click ───────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isOpen.value && containerRef.value && !containerRef.value.contains(target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
48
frontend/src/components/layout/BaseHeader.vue
Normal file
48
frontend/src/components/layout/BaseHeader.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-50 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 ─────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
|
||||
<!-- ── Center Slot ───────────────────────────────────────────── -->
|
||||
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
|
||||
<slot name="center" />
|
||||
<!-- Teleport target for dynamic header content -->
|
||||
<div id="header-teleport-target" class="flex items-center gap-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Spacer (pushes right items when center is empty) ──────── -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- ── Right Slot ────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BaseHeader.vue — A "dumb" glassmorphism header frame.
|
||||
*
|
||||
* Provides three named slots:
|
||||
* - #left : Logo, back button, welcome text
|
||||
* - #center : Context label, teleport target for dynamic content
|
||||
* - #right : Language switcher, profile avatar, org switcher
|
||||
*
|
||||
* The teleport target div (#header-teleport-target) allows child components
|
||||
* to teleport content into the header center area from anywhere in the DOM.
|
||||
*
|
||||
* Usage:
|
||||
* <BaseHeader>
|
||||
* <template #left>...</template>
|
||||
* <template #center>...</template>
|
||||
* <template #right>...</template>
|
||||
* </BaseHeader>
|
||||
*/
|
||||
</script>
|
||||
174
frontend/src/components/ui/AddressDisplay.vue
Normal file
174
frontend/src/components/ui/AddressDisplay.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="address-display flex items-start gap-2">
|
||||
<!-- MapPin icon (Lucide) -->
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-shrink-0 text-[#418890]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
|
||||
<!-- Address text -->
|
||||
<span class="text-sm break-words leading-relaxed" :class="textClass">
|
||||
{{ formattedAddress }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/**
|
||||
* AddressDisplay — intelligently formats and displays a Hungarian-style address.
|
||||
*
|
||||
* Accepts address fields either as individual props or as a single `address` object.
|
||||
* Filters out null/undefined/empty values, prevents double spaces,
|
||||
* and handles missing house numbers gracefully.
|
||||
*
|
||||
* Hungarian format: [ZIP] [City], [Street name] [Street type] [House number]
|
||||
* Example: 2120 Dunakeszi, Határ út 8.
|
||||
*/
|
||||
interface AddressObject {
|
||||
zip?: string | null
|
||||
city?: string | null
|
||||
street_name?: string | null
|
||||
street_type?: string | null
|
||||
house_number?: string | null
|
||||
stairwell?: string | null
|
||||
floor?: string | null
|
||||
door?: string | null
|
||||
parcel_id?: string | null
|
||||
hrsz?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Address fields as a single object (alternative to individual props) */
|
||||
address?: AddressObject | null
|
||||
/** Individual address fields (take precedence over address object) */
|
||||
zip?: string | null
|
||||
city?: string | null
|
||||
street_name?: string | null
|
||||
street_type?: string | null
|
||||
house_number?: string | null
|
||||
stairwell?: string | null
|
||||
floor?: string | null
|
||||
door?: string | null
|
||||
parcel_id?: string | null
|
||||
hrsz?: string | null
|
||||
/** Optional CSS class for the text span */
|
||||
textClass?: string
|
||||
/** Show detailed address (including stairwell/floor/door) */
|
||||
detailed?: boolean
|
||||
}>(),
|
||||
{
|
||||
address: null,
|
||||
zip: null,
|
||||
city: null,
|
||||
street_name: null,
|
||||
street_type: null,
|
||||
house_number: null,
|
||||
stairwell: null,
|
||||
floor: null,
|
||||
door: null,
|
||||
parcel_id: null,
|
||||
hrsz: null,
|
||||
textClass: 'text-white/80',
|
||||
detailed: false,
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve the effective address values.
|
||||
* Individual props take precedence over the address object.
|
||||
*/
|
||||
function resolveField(field: keyof AddressObject): string | null {
|
||||
const propValue = (props as any)[field]
|
||||
if (propValue !== null && propValue !== undefined && propValue !== '') {
|
||||
return propValue
|
||||
}
|
||||
if (props.address) {
|
||||
const addrValue = props.address[field]
|
||||
if (addrValue !== null && addrValue !== undefined && addrValue !== '') {
|
||||
return addrValue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the formatted address string.
|
||||
* Hungarian format: [ZIP] [City], [Street name] [Street type] [House number]
|
||||
* Intelligently filters out null/undefined/empty values and prevents double spaces.
|
||||
*/
|
||||
const formattedAddress = computed(() => {
|
||||
const zip = resolveField('zip')
|
||||
const city = resolveField('city')
|
||||
const streetName = resolveField('street_name')
|
||||
const streetType = resolveField('street_type')
|
||||
const houseNumber = resolveField('house_number')
|
||||
|
||||
// Build the primary address line
|
||||
const parts: string[] = []
|
||||
|
||||
// ZIP + City (always together if city exists)
|
||||
if (zip || city) {
|
||||
const location = [zip, city].filter(Boolean).join(' ')
|
||||
parts.push(location)
|
||||
}
|
||||
|
||||
// Street details
|
||||
const streetParts: string[] = []
|
||||
if (streetName) streetParts.push(streetName)
|
||||
if (streetType) streetParts.push(streetType)
|
||||
if (houseNumber) streetParts.push(houseNumber)
|
||||
|
||||
if (streetParts.length > 0) {
|
||||
// If we already have a location part, add street with a comma separator
|
||||
if (parts.length > 0) {
|
||||
parts[parts.length - 1] += ','
|
||||
}
|
||||
parts.push(streetParts.join(' '))
|
||||
}
|
||||
|
||||
// Join all parts, then normalize whitespace (remove double spaces)
|
||||
let result = parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
// If no address data at all, show a placeholder
|
||||
if (!result) {
|
||||
result = '—'
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
/**
|
||||
* Build the detailed address string (includes stairwell/floor/door).
|
||||
*/
|
||||
const { t } = useI18n()
|
||||
|
||||
const detailedAddress = computed(() => {
|
||||
if (!props.detailed) return ''
|
||||
|
||||
const stairwell = resolveField('stairwell')
|
||||
const floor = resolveField('floor')
|
||||
const door = resolveField('door')
|
||||
const hrsz = resolveField('hrsz') || resolveField('parcel_id')
|
||||
|
||||
const detailParts: string[] = []
|
||||
if (stairwell) detailParts.push(t('address.stairwell', { value: stairwell }))
|
||||
if (floor) detailParts.push(t('address.floor', { value: floor }))
|
||||
if (door) detailParts.push(t('address.door', { value: door }))
|
||||
if (hrsz) detailParts.push(t('address.hrsz', { value: hrsz }))
|
||||
|
||||
return detailParts.join(', ')
|
||||
})
|
||||
</script>
|
||||
77
frontend/src/components/ui/GlassCard.vue
Normal file
77
frontend/src/components/ui/GlassCard.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div
|
||||
class="glass-card relative overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.05] backdrop-blur-xl shadow-xl shadow-black/20 transition-all duration-300"
|
||||
:class="[
|
||||
variant === 'premium'
|
||||
? 'premium-gradient-border hover:shadow-2xl hover:shadow-[#00E5A0]/10'
|
||||
: 'hover:border-white/[0.15] hover:bg-white/[0.08] hover:shadow-2xl hover:shadow-black/30',
|
||||
hoverable ? 'hover:-translate-y-1 cursor-pointer' : '',
|
||||
customClass,
|
||||
]"
|
||||
>
|
||||
<!-- Premium gradient border overlay (only for premium variant) -->
|
||||
<div
|
||||
v-if="variant === 'premium'"
|
||||
class="pointer-events-none absolute inset-0 rounded-2xl p-[1px]"
|
||||
>
|
||||
<div class="h-full w-full rounded-2xl bg-gradient-to-br from-[#00E5A0]/40 via-[#418890]/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- Content slot -->
|
||||
<div class="relative z-10" :class="paddingClass">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Visual variant: 'standard' (default) or 'premium' (with gradient border) */
|
||||
variant?: 'standard' | 'premium'
|
||||
/** Enable hover lift effect (translate-y and stronger shadow) */
|
||||
hoverable?: boolean
|
||||
/** Custom padding: 'sm', 'md' (default), 'lg', or 'none' */
|
||||
padding?: 'sm' | 'md' | 'lg' | 'none'
|
||||
/** Additional CSS classes to merge */
|
||||
customClass?: string
|
||||
}>(),
|
||||
{
|
||||
variant: 'standard',
|
||||
hoverable: false,
|
||||
padding: 'md',
|
||||
customClass: '',
|
||||
}
|
||||
)
|
||||
|
||||
const paddingClass = computed(() => {
|
||||
switch (props.padding) {
|
||||
case 'sm': return 'p-4'
|
||||
case 'lg': return 'p-8'
|
||||
case 'none': return ''
|
||||
default: return 'p-6'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Premium gradient border using pseudo-element ── */
|
||||
.glass-card.premium-gradient-border {
|
||||
position: relative;
|
||||
border: none;
|
||||
}
|
||||
.glass-card.premium-gradient-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 1rem; /* matches rounded-2xl */
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(0, 229, 160, 0.5), rgba(65, 136, 144, 0.3), transparent);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
83
frontend/src/i18n/cz.ts
Normal file
83
frontend/src/i18n/cz.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export default {
|
||||
menu: {
|
||||
features: 'Funkce',
|
||||
garage: 'Vozidla',
|
||||
calendar: 'Servisní kalendář',
|
||||
finance: 'Finance & Náklady',
|
||||
diagnostic: 'Živá diagnostika',
|
||||
addVehicle: '+ Přidat nové vozidlo',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Vítejte',
|
||||
userMenu: 'Uživatelské menu',
|
||||
profile: 'Nastavení profilu',
|
||||
logout: 'Odhlásit se',
|
||||
subtitle: 'Váš dashboard a přehled vozidel',
|
||||
switchLanguage: 'Přepnout jazyk',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Načítání...',
|
||||
errorRetry: 'Opakovat',
|
||||
statusActive: 'Aktivní',
|
||||
statusUnknown: 'Neznámý',
|
||||
condition: 'Stav',
|
||||
mileage: 'km',
|
||||
// Bento Box layout
|
||||
myVehicles: 'Moje Vozidla',
|
||||
vehicles: 'vozidel',
|
||||
noVehicles: 'Zatím žádná vozidla. Přidejte své první!',
|
||||
activeProcesses: 'Aktivní Procesy / Oznámení',
|
||||
noNotifications: 'Vše v pořádku — žádná oznámení',
|
||||
totalVehicles: 'Celkem Vozidel',
|
||||
addVehicle: '+ Přidat Vozidlo',
|
||||
refresh: 'Obnovit',
|
||||
statsAndPoints: 'Statistiky & Body',
|
||||
live: 'ŽIVĚ',
|
||||
monthlyScore: 'Měsíční Skóre',
|
||||
totalKm: 'Celková Vzdálenost',
|
||||
fuelCost: 'Náklady na Palivo',
|
||||
serviceCost: 'Náklady na Servis',
|
||||
co2Savings: 'Úspora CO₂',
|
||||
achievements: 'Úspěchy',
|
||||
},
|
||||
zen: {
|
||||
hide: 'Skrýt UI (Zen režim)',
|
||||
show: 'Zobrazit UI',
|
||||
},
|
||||
auth: {
|
||||
invalid_credentials: 'Neplatný email nebo heslo.',
|
||||
user_inactive: 'Váš účet ještě není aktivován. Prosím ověřte svůj email!',
|
||||
forgotPasswordMessage: 'Funkce obnovení hesla je ve vývoji. Prosím kontaktujte administrátora!',
|
||||
resendTitle: 'Znovu odeslat aktivační email',
|
||||
resendDescription: 'Zadejte svou emailovou adresu a znovu zašleme aktivační odkaz.',
|
||||
resendButton: 'Odeslat email',
|
||||
resend_cooldown: 'Odeslat znovu ({seconds}s)',
|
||||
send_email: 'Odeslat email',
|
||||
resendSuccess: 'Aktivační email byl odeslán! Zkontrolujte své složky (včetně Spamu).',
|
||||
resendLink: 'Nedostali jste aktivační email?',
|
||||
backToLogin: 'Zpět na přihlášení',
|
||||
loginTitle: 'Přihlášení do garáže',
|
||||
loginButton: 'Přihlásit se',
|
||||
loginLoading: 'Prosím čekejte...',
|
||||
forgotPassword: 'Zapomněli jste heslo?',
|
||||
rememberMe: 'Zapamatovat si mě',
|
||||
noAccount: 'Nemáte ještě garáž?',
|
||||
registerLink: 'Registrovat',
|
||||
registerTitle: 'Otevřít novou garáž',
|
||||
registerButton: 'Registrovat',
|
||||
haveAccount: 'Již máte garáž?',
|
||||
loginLink: 'Přihlásit se',
|
||||
forgotTitle: 'Požádat o nové heslo',
|
||||
forgotDescription: 'Zadejte svou emailovou adresu a zašleme vám bezpečnostní odkaz.',
|
||||
forgotSuccess: 'Odkaz pro obnovení byl odeslán na váš email!',
|
||||
forgotSendButton: 'Odeslat odkaz',
|
||||
inactiveMessage: 'Váš účet ještě není aktivován! Aktivujte jej pomocí odkazu v emailu nebo požádejte o nový aktivační email.',
|
||||
resendActivationButton: 'Znovu odeslat aktivační email',
|
||||
resendActivationSuccess: 'Aktivační email byl znovu odeslán!',
|
||||
socialDivider: 'Nebo se přihlaste pomocí sociálního účtu',
|
||||
loginFailed: 'Přihlášení selhalo. Zkontrolujte své přihlašovací údaje.',
|
||||
registerFailed: 'Registrace selhala.',
|
||||
resendFailed: 'Odeslání selhalo. Prosím zkuste to znovu později.',
|
||||
forgotFailed: 'Při odesílání požadavku došlo k chybě. Prosím zkuste to znovu.',
|
||||
},
|
||||
}
|
||||
83
frontend/src/i18n/de.ts
Normal file
83
frontend/src/i18n/de.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export default {
|
||||
menu: {
|
||||
features: 'Funktionen',
|
||||
garage: 'Fahrzeuge',
|
||||
calendar: 'Servicekalender',
|
||||
finance: 'Finanzen & Kosten',
|
||||
diagnostic: 'Live-Diagnose',
|
||||
addVehicle: '+ Neues Fahrzeug hinzufügen',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Willkommen',
|
||||
userMenu: 'Benutzermenü',
|
||||
profile: 'Profileinstellungen',
|
||||
logout: 'Abmelden',
|
||||
subtitle: 'Ihr Dashboard und Fahrzeugübersicht',
|
||||
switchLanguage: 'Sprache wechseln',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Wird geladen...',
|
||||
errorRetry: 'Wiederholen',
|
||||
statusActive: 'Aktiv',
|
||||
statusUnknown: 'Unbekannt',
|
||||
condition: 'Zustand',
|
||||
mileage: 'km',
|
||||
// Bento Box layout
|
||||
myVehicles: 'Meine Fahrzeuge',
|
||||
vehicles: 'Fahrzeuge',
|
||||
noVehicles: 'Noch keine Fahrzeuge. Füge dein erstes hinzu!',
|
||||
activeProcesses: 'Aktive Prozesse / Benachrichtigungen',
|
||||
noNotifications: 'Alles klar — keine Benachrichtigungen',
|
||||
totalVehicles: 'Gesamtfahrzeuge',
|
||||
addVehicle: '+ Fahrzeug hinzufügen',
|
||||
refresh: 'Aktualisieren',
|
||||
statsAndPoints: 'Statistiken & Punkte',
|
||||
live: 'LIVE',
|
||||
monthlyScore: 'Monatliche Punktzahl',
|
||||
totalKm: 'Gesamtstrecke',
|
||||
fuelCost: 'Kraftstoffkosten',
|
||||
serviceCost: 'Servicekosten',
|
||||
co2Savings: 'CO₂-Einsparungen',
|
||||
achievements: 'Errungenschaften',
|
||||
},
|
||||
zen: {
|
||||
hide: 'UI ausblenden (Zen-Modus)',
|
||||
show: 'UI anzeigen',
|
||||
},
|
||||
auth: {
|
||||
invalid_credentials: 'Ungültige E-Mail oder Passwort.',
|
||||
user_inactive: 'Ihr Konto ist noch nicht aktiviert. Bitte bestätigen Sie Ihre E-Mail!',
|
||||
forgotPasswordMessage: 'Die Passwort-Zurücksetzen-Funktion befindet sich in Entwicklung. Bitte kontaktieren Sie den Administrator!',
|
||||
resendTitle: 'Aktivierungs-E-Mail erneut senden',
|
||||
resendDescription: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden den Aktivierungslink erneut.',
|
||||
resendButton: 'E-Mail senden',
|
||||
resend_cooldown: 'Erneut senden ({seconds}s)',
|
||||
send_email: 'E-Mail senden',
|
||||
resendSuccess: 'Die Aktivierungs-E-Mail wurde gesendet! Überprüfen Sie Ihre Ordner (auch Spam).',
|
||||
resendLink: 'Haben Sie die Aktivierungs-E-Mail nicht erhalten?',
|
||||
backToLogin: 'Zurück zum Login',
|
||||
loginTitle: 'Anmelden zur Garage',
|
||||
loginButton: 'Anmelden',
|
||||
loginLoading: 'Bitte warten...',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
rememberMe: 'Angemeldet bleiben',
|
||||
noAccount: 'Noch keine Garage?',
|
||||
registerLink: 'Registrieren',
|
||||
registerTitle: 'Neue Garage eröffnen',
|
||||
registerButton: 'Registrieren',
|
||||
haveAccount: 'Bereits eine Garage?',
|
||||
loginLink: 'Anmelden',
|
||||
forgotTitle: 'Neues Passwort anfordern',
|
||||
forgotDescription: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Sicherheitslink.',
|
||||
forgotSuccess: 'Der Zurücksetzungslink wurde an Ihre E-Mail gesendet!',
|
||||
forgotSendButton: 'Link senden',
|
||||
inactiveMessage: 'Ihr Konto ist noch nicht aktiviert! Bitte aktivieren Sie es mit dem Link in der E-Mail oder fordern Sie eine neue Aktivierungs-E-Mail an.',
|
||||
resendActivationButton: 'Aktivierungs-E-Mail erneut senden',
|
||||
resendActivationSuccess: 'Die Aktivierungs-E-Mail wurde erneut gesendet!',
|
||||
socialDivider: 'Oder mit Social-Konto anmelden',
|
||||
loginFailed: 'Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.',
|
||||
registerFailed: 'Registrierung fehlgeschlagen.',
|
||||
resendFailed: 'Erneutes Senden fehlgeschlagen. Bitte versuchen Sie es später erneut.',
|
||||
forgotFailed: 'Beim Senden der Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,20 @@
|
||||
export default {
|
||||
landing: {
|
||||
openGarage: 'Open Garage',
|
||||
heroTitle: 'Digital Fleet',
|
||||
heroTitleAccent: 'Management',
|
||||
heroSubtitle: 'Real-time cost tracking, automatic notifications and gamified point system – all in one place.',
|
||||
cardStats: 'Statistics',
|
||||
cardStatsLabel: 'Managed Vehicles',
|
||||
cardStatsValue: '1,245+',
|
||||
cardGamification: 'Gamification',
|
||||
cardGamificationLabel: 'Monthly Score',
|
||||
cardCosts: 'Costs',
|
||||
cardCostsFuel: 'Fuel',
|
||||
cardCostsService: 'Service',
|
||||
cardCostsTotal: 'Total',
|
||||
cardCostsUnit: 'HUF',
|
||||
},
|
||||
menu: {
|
||||
features: 'Features',
|
||||
garage: 'Vehicles',
|
||||
@@ -14,6 +30,26 @@ export default {
|
||||
logout: 'Logout',
|
||||
subtitle: 'Your dashboard and vehicle overview',
|
||||
switchLanguage: 'Switch language',
|
||||
close: 'Close',
|
||||
noCompanies: 'No companies yet',
|
||||
newCompany: '➕ New Company',
|
||||
myCompany: '🏢 My Company',
|
||||
myCompanies: '🏢 My Companies',
|
||||
personalGarage: '👤 Personal Garage',
|
||||
privateGarage: '👤 Private Garage',
|
||||
company: '🏢 Company',
|
||||
companyGarage: 'My Company Garage',
|
||||
companyGarageLabel: '{name}_garage',
|
||||
companyGarageFallback: 'Company_garage',
|
||||
personalGarageLabel: '{name}_garage',
|
||||
personalGarageFallback: 'User_garage',
|
||||
companyPrefix: 'Company',
|
||||
user: 'User',
|
||||
welcomeCard: 'Welcome, {name}!',
|
||||
language: 'Language',
|
||||
regionSettings: 'Region Settings',
|
||||
regionDescription: 'Date, time & currency format',
|
||||
dashboard: 'Dashboard',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Loading...',
|
||||
@@ -22,6 +58,27 @@ export default {
|
||||
statusUnknown: 'Unknown',
|
||||
condition: 'Condition',
|
||||
mileage: 'km',
|
||||
subtitle: 'Your dashboard and vehicle overview',
|
||||
guest: 'Guest',
|
||||
// Bento Box layout
|
||||
myVehicles: 'My Vehicles',
|
||||
vehicles: 'vehicles',
|
||||
noVehicles: 'No vehicles yet. Add your first one!',
|
||||
activeProcesses: 'Active Processes / Notifications',
|
||||
noNotifications: 'All clear — no notifications',
|
||||
totalVehicles: 'Total Vehicles',
|
||||
addVehicle: '+ Add Vehicle',
|
||||
refresh: 'Refresh',
|
||||
statsAndPoints: 'Statistics & Points',
|
||||
live: 'LIVE',
|
||||
monthlyScore: 'Monthly Score',
|
||||
totalKm: 'Total Distance',
|
||||
fuelCost: 'Fuel Cost',
|
||||
serviceCost: 'Service Cost',
|
||||
co2Savings: 'CO₂ Savings',
|
||||
achievements: 'Achievements',
|
||||
detailedView: 'Detailed View',
|
||||
modalPlaceholder: 'Content for this section will be available soon.',
|
||||
},
|
||||
zen: {
|
||||
hide: 'Hide UI (Zen mode)',
|
||||
@@ -70,4 +127,230 @@ export default {
|
||||
resendFailed: 'Resend failed. Please try again later.',
|
||||
forgotFailed: 'An error occurred while sending the request. Please try again.',
|
||||
},
|
||||
profile: {
|
||||
title: 'Profile Settings',
|
||||
loading: 'Loading profile data...',
|
||||
completion: 'Profile Completion',
|
||||
accountInfo: 'Account Info',
|
||||
email: 'Email',
|
||||
phone: 'Phone Number',
|
||||
edit: 'Edit',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
changePassword: 'Change Password',
|
||||
// Tabs
|
||||
tabPersonal: 'Personal & Address',
|
||||
tabDocs: 'Documents & Emergency',
|
||||
// Personal data
|
||||
personalData: 'Personal Data (KYC)',
|
||||
lastName: 'Last Name',
|
||||
firstName: 'First Name',
|
||||
birthPlace: 'Place of Birth',
|
||||
birthDate: 'Date of Birth',
|
||||
mothersLastName: 'Mother\'s Last Name',
|
||||
mothersFirstName: 'Mother\'s First Name',
|
||||
// Address
|
||||
addressTitle: 'Address Data',
|
||||
zipCode: 'ZIP Code',
|
||||
city: 'City',
|
||||
street: 'Street',
|
||||
streetType: 'Street Type',
|
||||
houseNumber: 'House Number',
|
||||
stairwell: 'Stairwell',
|
||||
floor: 'Floor',
|
||||
door: 'Door',
|
||||
parcelId: 'Parcel ID',
|
||||
hrsz: 'Parcel No.',
|
||||
selectPlaceholder: 'Select...',
|
||||
// Identity docs
|
||||
identityDocs: 'Documents (Identity Docs)',
|
||||
idCard: 'ID Card (ID_CARD)',
|
||||
drivingLicense: 'Driving License (LICENSE)',
|
||||
docNumber: 'Number',
|
||||
docExpiry: 'Expiry',
|
||||
categories: 'Categories',
|
||||
number: 'Number:',
|
||||
expiry: 'Expiry:',
|
||||
categoriesLabel: 'Categories:',
|
||||
// ICE contact
|
||||
iceTitle: 'Emergency Contact (ICE)',
|
||||
iceName: 'Name',
|
||||
iceRelation: 'Relation',
|
||||
icePhone: 'Phone Number',
|
||||
iceEmail: 'Email Address',
|
||||
iceNameLabel: 'Name:',
|
||||
iceRelationLabel: 'Relation:',
|
||||
icePhoneLabel: 'Phone:',
|
||||
iceEmailLabel: 'Email:',
|
||||
// Password modal
|
||||
passwordTitle: 'Change Password',
|
||||
currentPassword: 'Current Password',
|
||||
newPassword: 'New Password',
|
||||
confirmPassword: 'Confirm New Password',
|
||||
passwordRequired: 'All fields are required.',
|
||||
passwordMismatch: 'The new password and confirmation do not match.',
|
||||
passwordMinLength: 'The new password must be at least 6 characters long.',
|
||||
passwordChanged: 'Password successfully changed!',
|
||||
passwordChangeFailed: 'Password change failed.',
|
||||
// Save messages
|
||||
saveSuccess: 'Profile data and documents updated successfully!',
|
||||
saveError: 'An error occurred while saving.',
|
||||
// Regional settings tab
|
||||
tabRegional: 'Regional Settings',
|
||||
regionalLanguage: 'Language',
|
||||
regionalDateFormat: 'Date Format',
|
||||
regionalCurrency: 'Currency',
|
||||
dateFormatHU: 'YYYY. MM. DD.',
|
||||
dateFormatISO: 'YYYY-MM-DD',
|
||||
currencyHUF: 'HUF (Ft)',
|
||||
currencyEUR: 'EUR (€)',
|
||||
currencyUSD: 'USD ($)',
|
||||
// Back
|
||||
backToGarage: 'Back to Garage',
|
||||
// Diagnostic
|
||||
xray: '🩻 X-RAY: person (address_* fields)',
|
||||
},
|
||||
kyc: {
|
||||
title: 'Garage Activation — Complete Your Account',
|
||||
stepAddress: 'Address',
|
||||
stepExtras: 'Extras',
|
||||
// Step 1
|
||||
addressTitle: '📍 Address Data',
|
||||
addressDescription: 'ZIP code and city are required to activate your account.',
|
||||
softKyc: '(Soft KYC)',
|
||||
country: 'Country',
|
||||
zipCode: 'ZIP Code',
|
||||
city: 'City',
|
||||
optionalAddress: '📝 Additional Address Details (optional)',
|
||||
streetName: 'Street Name',
|
||||
streetType: 'Type',
|
||||
houseNumber: 'House Number',
|
||||
// Step 2
|
||||
extrasTitle: '🔧 Additional Data',
|
||||
extrasDescription: 'You can provide these details later in your profile.',
|
||||
extrasOptional: '(All optional)',
|
||||
phoneNumber: '📞 Phone Number',
|
||||
documentsTitle: '🪪 Document Details (optional)',
|
||||
idCard: 'ID Card',
|
||||
drivingLicense: 'Driving License',
|
||||
docNumber: 'Document Number',
|
||||
docExpiry: 'Expiry',
|
||||
language: '🌐 Language',
|
||||
currency: '💰 Currency',
|
||||
// Navigation
|
||||
back: 'Back',
|
||||
next: 'Next',
|
||||
submitting: 'Processing...',
|
||||
submit: 'Activate Garage 🚀',
|
||||
skip: 'Skip, I\'ll fill it in later in my profile →',
|
||||
// Validation
|
||||
zipRequired: 'Please enter the ZIP code.',
|
||||
cityRequired: 'Please enter the city.',
|
||||
addressRequired: 'Address is required to activate your garage.',
|
||||
// Errors
|
||||
submitError: 'Garage activation failed. Please try again.',
|
||||
},
|
||||
verifyEmail: {
|
||||
verifying: 'Activating account...',
|
||||
verifyingDesc: 'Please wait while we verify the confirmation link.',
|
||||
success: 'Successful activation and login!',
|
||||
successDesc: 'Your account is now active and you are automatically logged in.',
|
||||
redirectCountdown: 'Redirecting in {seconds} seconds...',
|
||||
redirectNow: 'Redirecting...',
|
||||
goToProfile: 'Go to Profile',
|
||||
checkEmail: '📧 Check your email inbox',
|
||||
checkEmailDesc: 'We have sent you a confirmation email. Please <strong>check your inbox and spam folder</strong>.<br><br>If the email <strong>does not arrive within 10 minutes</strong>, you can request a new one by clicking the button below.',
|
||||
emailLabel: 'Email Address',
|
||||
emailPlaceholder: 'example@email.com',
|
||||
resendButton: 'I didn\'t receive the email, resend it',
|
||||
resending: 'Sending...',
|
||||
resendSuccess: 'The new confirmation email has been sent!',
|
||||
resendFailed: 'Resend failed. Please try again later.',
|
||||
backToLogin: '← Back to Login',
|
||||
tokenExpired: 'The link has expired or is invalid.',
|
||||
},
|
||||
company: {
|
||||
// CompanyGarageView
|
||||
garageTitle: 'Company Garage',
|
||||
user: 'User',
|
||||
// Dashboard cards
|
||||
fleetTitle: 'Fleet Overview',
|
||||
fleetVehicles: 'Managed vehicles: {count}',
|
||||
fleetActive: 'Active',
|
||||
fleetInactive: 'Inactive',
|
||||
gamificationTitle: 'Gamification',
|
||||
gamificationScore: 'Monthly Score: {score}',
|
||||
quickActionsTitle: 'Quick Actions',
|
||||
quickNewWaybill: 'New Waybill',
|
||||
quickReportIssue: 'Report Issue',
|
||||
// CompanyOnboardingView
|
||||
onboardingTitle: 'Company Registration — 3 simple steps',
|
||||
stepLegal: 'Company Data',
|
||||
stepAddress: 'Address',
|
||||
stepContact: 'Contact',
|
||||
// Step 1
|
||||
legalTitle: '⚖️ Legal Data',
|
||||
legalDescription: 'Enter your company\'s tax number, then click <strong>Load Company Data</strong> for automatic filling.',
|
||||
taxNumber: 'Tax Number',
|
||||
taxLookup: 'Load Company Data',
|
||||
taxLookupTitle: 'Load company data by tax number',
|
||||
fullName: 'Full Company Name',
|
||||
shortName: 'Short Name',
|
||||
displayName: 'Display Name',
|
||||
regNumber: 'Registration Number',
|
||||
// Step 2
|
||||
addressTitle: '📍 Address & Finance',
|
||||
addressDescription: 'Company headquarters address and default financial settings.',
|
||||
country: 'Country',
|
||||
language: 'Language',
|
||||
currency: 'Currency',
|
||||
zipCode: 'ZIP Code',
|
||||
city: 'City',
|
||||
streetName: 'Street Name',
|
||||
streetType: 'Type',
|
||||
houseNumber: 'House Number',
|
||||
stairwell: 'Stairwell',
|
||||
floor: 'Floor',
|
||||
door: 'Door',
|
||||
parcelId: 'Parcel ID',
|
||||
// Step 3
|
||||
contactTitle: '📞 Contact',
|
||||
contactDescription: 'Primary contact details (the logged-in user) cannot be modified.',
|
||||
contactName: 'Contact Name',
|
||||
contactEmail: 'Email Address',
|
||||
contactPhone: 'Phone Number',
|
||||
// Navigation
|
||||
cancel: 'Cancel',
|
||||
back: 'Back',
|
||||
next: 'Next',
|
||||
submitting: 'Creating...',
|
||||
submit: 'Create Company 🚀',
|
||||
// Validation
|
||||
taxRequired: 'Please enter the tax number.',
|
||||
fullNameRequired: 'Please enter the full company name.',
|
||||
shortNameRequired: 'Please enter the short name.',
|
||||
displayNameRequired: 'Please enter the display name.',
|
||||
regNumberRequired: 'Please enter the registration number.',
|
||||
zipRequired: 'Please enter the ZIP code.',
|
||||
cityRequired: 'Please enter the city.',
|
||||
emailRequired: 'Please enter the primary email address.',
|
||||
phoneRequired: 'Please enter the primary phone number.',
|
||||
// Errors
|
||||
lookupError: 'Failed to load company data. Please check the tax number.',
|
||||
duplicateCompany: 'This company is already registered in the system.',
|
||||
submitError: 'Company creation failed. Please try again.',
|
||||
// Success
|
||||
successMessage: 'Company created successfully! Redirecting to garage...',
|
||||
},
|
||||
organization: {
|
||||
backToPrivate: '⬅️ Back to Private Garage',
|
||||
fallbackName: 'Company Garage',
|
||||
},
|
||||
address: {
|
||||
stairwell: 'Stairwell: {value}',
|
||||
floor: 'Floor: {value}',
|
||||
door: 'Door: {value}',
|
||||
hrsz: 'Parcel No.: {value}',
|
||||
placeholder: '—',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
export default {
|
||||
landing: {
|
||||
openGarage: 'Garázs Nyitása',
|
||||
heroTitle: 'Digitális Flotta',
|
||||
heroTitleAccent: 'Menedzsment',
|
||||
heroSubtitle: 'Valós idejű költségkövetés, automatikus értesítések és gamifikált pontrendszer – minden egy helyen.',
|
||||
cardStats: 'Statisztika',
|
||||
cardStatsLabel: 'Kezelt járművek',
|
||||
cardStatsValue: '1,245+',
|
||||
cardGamification: 'Gamifikáció',
|
||||
cardGamificationLabel: 'Havi Pontszám',
|
||||
cardCosts: 'Költségek',
|
||||
cardCostsFuel: 'Üzemanyag',
|
||||
cardCostsService: 'Szerviz',
|
||||
cardCostsTotal: 'Összesen',
|
||||
cardCostsUnit: 'Ft',
|
||||
},
|
||||
menu: {
|
||||
features: 'Funkciók',
|
||||
garage: 'Járművek',
|
||||
@@ -14,6 +30,26 @@ export default {
|
||||
logout: 'Kilépés',
|
||||
subtitle: 'Irányítópultod és járműveid áttekintése',
|
||||
switchLanguage: 'Nyelv váltása',
|
||||
close: 'Bezárás',
|
||||
noCompanies: 'Még nincs céged',
|
||||
newCompany: '➕ Új cég',
|
||||
myCompany: '🏢 Cégem',
|
||||
myCompanies: '🏢 Cégeim',
|
||||
personalGarage: '👤 Személyes Garázs',
|
||||
privateGarage: '👤 Privát garázs',
|
||||
company: '🏢 Cég',
|
||||
companyGarage: 'Cégem garázs',
|
||||
companyGarageLabel: '{name}_garázsa',
|
||||
companyGarageFallback: 'Cég_garázsa',
|
||||
personalGarageLabel: '{name}_garázsa',
|
||||
personalGarageFallback: 'Felhasználó_garázsa',
|
||||
companyPrefix: 'Cég',
|
||||
user: 'Felhasználó',
|
||||
welcomeCard: 'Üdvözlünk, {name}!',
|
||||
language: 'Nyelv',
|
||||
regionSettings: 'Területi beállítások',
|
||||
regionDescription: 'Dátum, idő és pénznem formátum',
|
||||
dashboard: 'Irányítópult',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Betöltés...',
|
||||
@@ -22,6 +58,27 @@ export default {
|
||||
statusUnknown: 'Ismeretlen',
|
||||
condition: 'Állapot',
|
||||
mileage: 'km',
|
||||
subtitle: 'Irányítópultod és járműveid áttekintése',
|
||||
guest: 'Vendég',
|
||||
// Bento Box layout
|
||||
myVehicles: 'Saját Járművek',
|
||||
vehicles: 'jármű',
|
||||
noVehicles: 'Még nincsenek járművek. Add hozzá az elsőt!',
|
||||
activeProcesses: 'Aktív Folyamatok / Értesítések',
|
||||
noNotifications: 'Minden rendben — nincs értesítés',
|
||||
totalVehicles: 'Összes Jármű',
|
||||
addVehicle: '+ Jármű Hozzáadása',
|
||||
refresh: 'Frissítés',
|
||||
statsAndPoints: 'Statisztika és Pontok',
|
||||
live: 'ÉLŐ',
|
||||
monthlyScore: 'Havi Pontszám',
|
||||
totalKm: 'Teljes Táv',
|
||||
fuelCost: 'Üzemanyag Költség',
|
||||
serviceCost: 'Szerviz Költség',
|
||||
co2Savings: 'CO₂ Megtakarítás',
|
||||
achievements: 'Eredmények',
|
||||
detailedView: 'Részletes Nézet',
|
||||
modalPlaceholder: 'A szekció tartalma hamarosan elérhető lesz.',
|
||||
},
|
||||
zen: {
|
||||
hide: 'UI elrejtése (Zen mód)',
|
||||
@@ -70,4 +127,230 @@ export default {
|
||||
resendFailed: 'Az újraküldés sikertelen. Kérlek próbáld újra később.',
|
||||
forgotFailed: 'Hiba történt a kérelem elküldésekor. Kérlek próbáld újra.',
|
||||
},
|
||||
profile: {
|
||||
title: 'Profil beállítások',
|
||||
loading: 'Profil adatok betöltése...',
|
||||
completion: 'Profil kitöltöttsége',
|
||||
accountInfo: 'Fiók adatok',
|
||||
email: 'Email',
|
||||
phone: 'Telefonszám',
|
||||
edit: 'Szerkesztés',
|
||||
cancel: 'Mégse',
|
||||
save: 'Mentés',
|
||||
changePassword: 'Jelszó módosítása',
|
||||
// Tabs
|
||||
tabPersonal: 'Személyes és Lakcím',
|
||||
tabDocs: 'Okmányok és Vészhelyzet',
|
||||
// Personal data
|
||||
personalData: 'Személyes adatok (KYC)',
|
||||
lastName: 'Vezetéknév',
|
||||
firstName: 'Keresztnév',
|
||||
birthPlace: 'Születési hely',
|
||||
birthDate: 'Születési dátum',
|
||||
mothersLastName: 'Anyja vezetékneve',
|
||||
mothersFirstName: 'Anyja keresztneve',
|
||||
// Address
|
||||
addressTitle: 'Lakcím adatok',
|
||||
zipCode: 'Irányítószám',
|
||||
city: 'Város',
|
||||
street: 'Utca',
|
||||
streetType: 'Közterület jellege',
|
||||
houseNumber: 'Házszám',
|
||||
stairwell: 'Lépcsőház',
|
||||
floor: 'Emelet',
|
||||
door: 'Ajtó',
|
||||
parcelId: 'Helyrajzi szám',
|
||||
hrsz: 'Hrsz.',
|
||||
selectPlaceholder: 'Válassz...',
|
||||
// Identity docs
|
||||
identityDocs: 'Okmányok (Identity Docs)',
|
||||
idCard: 'Személyi Igazolvány (ID_CARD)',
|
||||
drivingLicense: 'Jogosítvány (LICENSE)',
|
||||
docNumber: 'Szám',
|
||||
docExpiry: 'Lejárat',
|
||||
categories: 'Kategóriák',
|
||||
number: 'Szám:',
|
||||
expiry: 'Lejárat:',
|
||||
categoriesLabel: 'Kategóriák:',
|
||||
// ICE contact
|
||||
iceTitle: 'Vészhelyzeti Kapcsolattartó (ICE)',
|
||||
iceName: 'Név',
|
||||
iceRelation: 'Viszony',
|
||||
icePhone: 'Telefonszám',
|
||||
iceEmail: 'E-mail cím',
|
||||
iceNameLabel: 'Név:',
|
||||
iceRelationLabel: 'Viszony:',
|
||||
icePhoneLabel: 'Telefonszám:',
|
||||
iceEmailLabel: 'E-mail:',
|
||||
// Password modal
|
||||
passwordTitle: 'Jelszó módosítása',
|
||||
currentPassword: 'Jelenlegi jelszó',
|
||||
newPassword: 'Új jelszó',
|
||||
confirmPassword: 'Új jelszó újra',
|
||||
passwordRequired: 'Minden mező kitöltése kötelező.',
|
||||
passwordMismatch: 'Az új jelszó és a megerősítés nem egyezik.',
|
||||
passwordMinLength: 'Az új jelszónak legalább 6 karakter hosszúnak kell lennie.',
|
||||
passwordChanged: 'Jelszó sikeresen megváltoztatva!',
|
||||
passwordChangeFailed: 'A jelszó módosítása sikertelen.',
|
||||
// Save messages
|
||||
saveSuccess: 'A profil adatok és okmányok sikeresen frissítve!',
|
||||
saveError: 'Hiba történt a mentés során.',
|
||||
// Regional settings tab
|
||||
tabRegional: 'Területi beállítások',
|
||||
regionalLanguage: 'Nyelv',
|
||||
regionalDateFormat: 'Dátum formátum',
|
||||
regionalCurrency: 'Pénznem',
|
||||
dateFormatHU: 'ÉÉÉÉ. HH. NN.',
|
||||
dateFormatISO: 'ÉÉÉÉ-HH-NN',
|
||||
currencyHUF: 'HUF (Ft)',
|
||||
currencyEUR: 'EUR (€)',
|
||||
currencyUSD: 'USD ($)',
|
||||
// Back
|
||||
backToGarage: 'Vissza a Garázsba',
|
||||
// Diagnostic
|
||||
xray: '🩻 RÖNTGEN: person (address_* fields)',
|
||||
},
|
||||
kyc: {
|
||||
title: 'Garázs Aktiválás — Fiók kiegészítése',
|
||||
stepAddress: 'Lakcím',
|
||||
stepExtras: 'Kiegészítő',
|
||||
// Step 1
|
||||
addressTitle: '📍 Lakcím adatok',
|
||||
addressDescription: 'Az irányítószám és város kötelező a fiókod aktiválásához.',
|
||||
softKyc: '(Soft KYC)',
|
||||
country: 'Ország',
|
||||
zipCode: 'Irányítószám',
|
||||
city: 'Város',
|
||||
optionalAddress: '📝 További címrészletek (opcionális)',
|
||||
streetName: 'Utca neve',
|
||||
streetType: 'Típus',
|
||||
houseNumber: 'Házszám',
|
||||
// Step 2
|
||||
extrasTitle: '🔧 Kiegészítő adatok',
|
||||
extrasDescription: 'Ezeket az adatokat később is megadhatod a profilodban.',
|
||||
extrasOptional: '(Mind opcionális)',
|
||||
phoneNumber: '📞 Telefonszám',
|
||||
documentsTitle: '🪪 Okmányok adatai (opcionális)',
|
||||
idCard: 'Személyi igazolvány',
|
||||
drivingLicense: 'Jogosítvány',
|
||||
docNumber: 'Okmány száma',
|
||||
docExpiry: 'Lejárat',
|
||||
language: '🌐 Nyelv',
|
||||
currency: '💰 Pénznem',
|
||||
// Navigation
|
||||
back: 'Vissza',
|
||||
next: 'Tovább',
|
||||
submitting: 'Feldolgozás...',
|
||||
submit: 'Garázs Aktiválása 🚀',
|
||||
skip: 'Kihagyom, kitöltöm később a profilban →',
|
||||
// Validation
|
||||
zipRequired: 'Kérlek add meg az irányítószámot.',
|
||||
cityRequired: 'Kérlek add meg a várost.',
|
||||
addressRequired: 'A lakcím megadása kötelező a garázs aktiválásához.',
|
||||
// Errors
|
||||
submitError: 'A garázs aktiválása sikertelen. Kérlek próbáld újra.',
|
||||
},
|
||||
verifyEmail: {
|
||||
verifying: 'Fiók aktiválása...',
|
||||
verifyingDesc: 'Kérlek várj, amíg ellenőrizzük a megerősítő linket.',
|
||||
success: 'Sikeres aktiválás és bejelentkezés!',
|
||||
successDesc: 'A fiókod aktív lett, és automatikusan be vagy jelentkezve.',
|
||||
redirectCountdown: 'Átirányítás {seconds} másodperc múlva...',
|
||||
redirectNow: 'Átirányítás...',
|
||||
goToProfile: 'Tovább a Profilhoz',
|
||||
checkEmail: '📧 Ellenőrizd az e-mail postafiókodat',
|
||||
checkEmailDesc: 'Küldtünk neked egy megerősítő e-mailt. Kérlek, <strong>ellenőrizd a beérkező leveleidet és a spam mappát is</strong>.<br><br>Ha <strong>10 percen belül nem érkezik meg</strong> a levél, kérhetsz újat az alábbi gombra kattintva.',
|
||||
emailLabel: 'E-mail cím',
|
||||
emailPlaceholder: 'pelda@email.com',
|
||||
resendButton: 'Nem kaptam meg az e-mailt, küldjétek újra',
|
||||
resending: 'Küldés...',
|
||||
resendSuccess: 'Az új megerősítő e-mail elküldésre került!',
|
||||
resendFailed: 'Az újraküldés sikertelen. Kérlek próbáld újra később.',
|
||||
backToLogin: '← Vissza a bejelentkezéshez',
|
||||
tokenExpired: 'A link lejárt vagy érvénytelen.',
|
||||
},
|
||||
company: {
|
||||
// CompanyGarageView
|
||||
garageTitle: 'Céges garázs',
|
||||
user: 'Felhasználó',
|
||||
// Dashboard cards
|
||||
fleetTitle: 'Flotta áttekintés',
|
||||
fleetVehicles: 'Kezelt járművek: {count}',
|
||||
fleetActive: 'Aktív',
|
||||
fleetInactive: 'Inaktív',
|
||||
gamificationTitle: 'Gamifikáció',
|
||||
gamificationScore: 'Havi Pontszám: {score}',
|
||||
quickActionsTitle: 'Gyorsműveletek',
|
||||
quickNewWaybill: 'Új menetlevél',
|
||||
quickReportIssue: 'Hiba bejelentése',
|
||||
// CompanyOnboardingView
|
||||
onboardingTitle: 'Cég Regisztráció — 3 egyszerű lépésben',
|
||||
stepLegal: 'Cégadatok',
|
||||
stepAddress: 'Székhely',
|
||||
stepContact: 'Kapcsolat',
|
||||
// Step 1
|
||||
legalTitle: '⚖️ Jogi adatok',
|
||||
legalDescription: 'Add meg a céged adószámát, majd kattints a <strong>Cégadatok betöltése</strong> gombra az automatikus kitöltéshez.',
|
||||
taxNumber: 'Adószám',
|
||||
taxLookup: 'Cégadatok betöltése',
|
||||
taxLookupTitle: 'Cégadatok betöltése adószám alapján',
|
||||
fullName: 'Teljes cégnév',
|
||||
shortName: 'Rövid név',
|
||||
displayName: 'Megjelenítendő név',
|
||||
regNumber: 'Cégjegyzékszám',
|
||||
// Step 2
|
||||
addressTitle: '📍 Székhely és pénzügy',
|
||||
addressDescription: 'A cég székhelyének címe és az alapértelmezett pénzügyi beállítások.',
|
||||
country: 'Ország',
|
||||
language: 'Nyelv',
|
||||
currency: 'Pénznem',
|
||||
zipCode: 'Irányítószám',
|
||||
city: 'Város',
|
||||
streetName: 'Utca neve',
|
||||
streetType: 'Típus',
|
||||
houseNumber: 'Házszám',
|
||||
stairwell: 'Lépcsőház',
|
||||
floor: 'Emelet',
|
||||
door: 'Ajtó',
|
||||
parcelId: 'Helyrajzi szám',
|
||||
// Step 3
|
||||
contactTitle: '📞 Kapcsolat',
|
||||
contactDescription: 'Az elsődleges kapcsolattartó adatai (a bejelentkezett felhasználó) nem módosíthatók.',
|
||||
contactName: 'Kapcsolattartó neve',
|
||||
contactEmail: 'E-mail cím',
|
||||
contactPhone: 'Telefonszám',
|
||||
// Navigation
|
||||
cancel: 'Mégsem',
|
||||
back: 'Vissza',
|
||||
next: 'Tovább',
|
||||
submitting: 'Létrehozás...',
|
||||
submit: 'Cég Létrehozása 🚀',
|
||||
// Validation
|
||||
taxRequired: 'Kérlek add meg az adószámot.',
|
||||
fullNameRequired: 'Kérlek add meg a teljes cégnév.',
|
||||
shortNameRequired: 'Kérlek add meg a rövid nevet.',
|
||||
displayNameRequired: 'Kérlek add meg a megjelenítendő nevet.',
|
||||
regNumberRequired: 'Kérlek add meg a cégjegyzékszámot.',
|
||||
zipRequired: 'Kérlek add meg az irányítószámot.',
|
||||
cityRequired: 'Kérlek add meg a várost.',
|
||||
emailRequired: 'Kérlek add meg az elsődleges e-mail címet.',
|
||||
phoneRequired: 'Kérlek add meg az elsődleges telefonszámot.',
|
||||
// Errors
|
||||
lookupError: 'Nem sikerült betölteni a cégadatokat. Kérlek ellenőrizd az adószámot.',
|
||||
duplicateCompany: 'Ez a cég már regisztrálva van a rendszerben.',
|
||||
submitError: 'A cég létrehozása sikertelen. Kérlek próbáld újra.',
|
||||
// Success
|
||||
successMessage: 'A cég sikeresen létrejött! Átirányítás a garázsba...',
|
||||
},
|
||||
organization: {
|
||||
backToPrivate: '⬅️ Vissza a Privát Garázsba',
|
||||
fallbackName: 'Céges Garázs',
|
||||
},
|
||||
address: {
|
||||
stairwell: 'Lépcsőház: {value}',
|
||||
floor: 'Emelet: {value}',
|
||||
door: 'Ajtó: {value}',
|
||||
hrsz: 'Hrsz.: {value}',
|
||||
placeholder: '—',
|
||||
},
|
||||
}
|
||||
|
||||
83
frontend/src/i18n/ro.ts
Normal file
83
frontend/src/i18n/ro.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export default {
|
||||
menu: {
|
||||
features: 'Funcționalități',
|
||||
garage: 'Vehicule',
|
||||
calendar: 'Calendar Service',
|
||||
finance: 'Finanțe & Costuri',
|
||||
diagnostic: 'Diagnosticare Live',
|
||||
addVehicle: '+ Adăugați Vehicul Nou',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Bun venit',
|
||||
userMenu: 'Meniul utilizatorului',
|
||||
profile: 'Setări Profil',
|
||||
logout: 'Deconectare',
|
||||
subtitle: 'Tabloul de bord și prezentarea vehiculelor',
|
||||
switchLanguage: 'Schimbă limba',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Se încarcă...',
|
||||
errorRetry: 'Reîncercare',
|
||||
statusActive: 'Activ',
|
||||
statusUnknown: 'Necunoscut',
|
||||
condition: 'Stare',
|
||||
mileage: 'km',
|
||||
// Bento Box layout
|
||||
myVehicles: 'Vehiculele Mele',
|
||||
vehicles: 'vehicule',
|
||||
noVehicles: 'Încă nu aveți vehicule. Adăugați primul!',
|
||||
activeProcesses: 'Procese Active / Notificări',
|
||||
noNotifications: 'Totul în regulă — nicio notificare',
|
||||
totalVehicles: 'Total Vehicule',
|
||||
addVehicle: '+ Adăugați Vehicul',
|
||||
refresh: 'Reîmprospătare',
|
||||
statsAndPoints: 'Statistici & Puncte',
|
||||
live: 'LIVE',
|
||||
monthlyScore: 'Scor Lunar',
|
||||
totalKm: 'Distanță Totală',
|
||||
fuelCost: 'Cost Combustibil',
|
||||
serviceCost: 'Cost Service',
|
||||
co2Savings: 'Economii CO₂',
|
||||
achievements: 'Realizări',
|
||||
},
|
||||
zen: {
|
||||
hide: 'Ascunde UI (Mod Zen)',
|
||||
show: 'Arată UI',
|
||||
},
|
||||
auth: {
|
||||
invalid_credentials: 'Email sau parolă invalidă.',
|
||||
user_inactive: 'Contul dumneavoastră nu este încă activat. Vă rugăm să verificați emailul!',
|
||||
forgotPasswordMessage: 'Funcția de resetare a parolei este în dezvoltare. Vă rugăm să contactați administratorul!',
|
||||
resendTitle: 'Retrimiteți Emailul de Activare',
|
||||
resendDescription: 'Introduceți adresa de email și vom retrimite linkul de activare.',
|
||||
resendButton: 'Trimiteți Email',
|
||||
resend_cooldown: 'Retrimiteți ({seconds}s)',
|
||||
send_email: 'Trimiteți Email',
|
||||
resendSuccess: 'Emailul de activare a fost trimis! Verificați dosarele (inclusiv Spam).',
|
||||
resendLink: 'Nu ați primit emailul de activare?',
|
||||
backToLogin: 'Înapoi la Autentificare',
|
||||
loginTitle: 'Autentificare în Garaj',
|
||||
loginButton: 'Autentificare',
|
||||
loginLoading: 'Vă rugăm așteptați...',
|
||||
forgotPassword: 'Ați uitat parola?',
|
||||
rememberMe: 'Ține-mă minte',
|
||||
noAccount: 'Nu aveți încă un garaj?',
|
||||
registerLink: 'Înregistrare',
|
||||
registerTitle: 'Deschideți un Garaj Nou',
|
||||
registerButton: 'Înregistrare',
|
||||
haveAccount: 'Aveți deja un garaj?',
|
||||
loginLink: 'Autentificare',
|
||||
forgotTitle: 'Solicitați o Parolă Nouă',
|
||||
forgotDescription: 'Introduceți adresa de email și vă vom trimite un link de securitate.',
|
||||
forgotSuccess: 'Linkul de resetare a fost trimis pe email!',
|
||||
forgotSendButton: 'Trimiteți Linkul',
|
||||
inactiveMessage: 'Contul dumneavoastră nu este încă activat! Activați-l folosind linkul din email sau solicitați un nou email de activare.',
|
||||
resendActivationButton: 'Retrimiteți Emailul de Activare',
|
||||
resendActivationSuccess: 'Emailul de activare a fost retrimis!',
|
||||
socialDivider: 'Sau autentificați-vă cu un cont Social',
|
||||
loginFailed: 'Autentificare eșuată. Verificați datele de autentificare.',
|
||||
registerFailed: 'Înregistrare eșuată.',
|
||||
resendFailed: 'Retrimiterea a eșuat. Vă rugăm încercați din nou mai târziu.',
|
||||
forgotFailed: 'A apărut o eroare la trimiterea cererii. Vă rugăm încercați din nou.',
|
||||
},
|
||||
}
|
||||
83
frontend/src/i18n/sk.ts
Normal file
83
frontend/src/i18n/sk.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export default {
|
||||
menu: {
|
||||
features: 'Funkcie',
|
||||
garage: 'Vozidlá',
|
||||
calendar: 'Servisný kalendár',
|
||||
finance: 'Financie & Náklady',
|
||||
diagnostic: 'Živá diagnostika',
|
||||
addVehicle: '+ Pridať nové vozidlo',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Vitajte',
|
||||
userMenu: 'Používateľské menu',
|
||||
profile: 'Nastavenia profilu',
|
||||
logout: 'Odhlásiť sa',
|
||||
subtitle: 'Váš dashboard a prehľad vozidiel',
|
||||
switchLanguage: 'Prepnúť jazyk',
|
||||
},
|
||||
dashboard: {
|
||||
loading: 'Načítava sa...',
|
||||
errorRetry: 'Opakovať',
|
||||
statusActive: 'Aktívny',
|
||||
statusUnknown: 'Neznámy',
|
||||
condition: 'Stav',
|
||||
mileage: 'km',
|
||||
// Bento Box layout
|
||||
myVehicles: 'Moje Vozidlá',
|
||||
vehicles: 'vozidiel',
|
||||
noVehicles: 'Zatiaľ žiadne vozidlá. Pridajte svoje prvé!',
|
||||
activeProcesses: 'Aktívne Procesy / Notifikácie',
|
||||
noNotifications: 'Všetko v poriadku — žiadne notifikácie',
|
||||
totalVehicles: 'Celkový Počet Vozidiel',
|
||||
addVehicle: '+ Pridať Vozidlo',
|
||||
refresh: 'Obnoviť',
|
||||
statsAndPoints: 'Štatistiky & Body',
|
||||
live: 'ŽIVĚ',
|
||||
monthlyScore: 'Mesačné Skóre',
|
||||
totalKm: 'Celková Vzdialenosť',
|
||||
fuelCost: 'Náklady na Palivo',
|
||||
serviceCost: 'Náklady na Servis',
|
||||
co2Savings: 'Úspora CO₂',
|
||||
achievements: 'Úspechy',
|
||||
},
|
||||
zen: {
|
||||
hide: 'Skryť UI (Zen režim)',
|
||||
show: 'Zobraziť UI',
|
||||
},
|
||||
auth: {
|
||||
invalid_credentials: 'Neplatný email alebo heslo.',
|
||||
user_inactive: 'Váš účet ešte nie je aktivovaný. Prosím overte svoj email!',
|
||||
forgotPasswordMessage: 'Funkcia obnovenia hesla je vo vývoji. Prosím kontaktujte administrátora!',
|
||||
resendTitle: 'Znova odoslať aktivačný email',
|
||||
resendDescription: 'Zadajte svoju emailovú adresu a znova pošleme aktivačný odkaz.',
|
||||
resendButton: 'Odoslať email',
|
||||
resend_cooldown: 'Odoslať znova ({seconds}s)',
|
||||
send_email: 'Odoslať email',
|
||||
resendSuccess: 'Aktivačný email bol odoslaný! Skontrolujte svoje priečinky (vrátane Spamu).',
|
||||
resendLink: 'Nedostali ste aktivačný email?',
|
||||
backToLogin: 'Späť na prihlásenie',
|
||||
loginTitle: 'Prihlásenie do garáže',
|
||||
loginButton: 'Prihlásiť sa',
|
||||
loginLoading: 'Prosím čakajte...',
|
||||
forgotPassword: 'Zabudli ste heslo?',
|
||||
rememberMe: 'Zapamätať si ma',
|
||||
noAccount: 'Ešte nemáte garáž?',
|
||||
registerLink: 'Registrovať',
|
||||
registerTitle: 'Otvoriť novú garáž',
|
||||
registerButton: 'Registrovať',
|
||||
haveAccount: 'Už máte garáž?',
|
||||
loginLink: 'Prihlásiť sa',
|
||||
forgotTitle: 'Požiadať o nové heslo',
|
||||
forgotDescription: 'Zadajte svoju emailovú adresu a pošleme vám bezpečnostný odkaz.',
|
||||
forgotSuccess: 'Odkaz na obnovenie bol odoslaný na váš email!',
|
||||
forgotSendButton: 'Odoslať odkaz',
|
||||
inactiveMessage: 'Váš účet ešte nie je aktivovaný! Aktivujte ho pomocou odkazu v emaile alebo požiadajte o nový aktivačný email.',
|
||||
resendActivationButton: 'Znova odoslať aktivačný email',
|
||||
resendActivationSuccess: 'Aktivačný email bol znovu odoslaný!',
|
||||
socialDivider: 'Alebo sa prihláste pomocou sociálneho účtu',
|
||||
loginFailed: 'Prihlásenie zlyhalo. Skontrolujte svoje prihlasovacie údaje.',
|
||||
registerFailed: 'Registrácia zlyhala.',
|
||||
resendFailed: 'Odoslanie zlyhalo. Prosím skúste to znova neskôr.',
|
||||
forgotFailed: 'Pri odosielaní požiadavky došlo k chybe. Prosím skúste to znova.',
|
||||
},
|
||||
}
|
||||
82
frontend/src/layouts/OrganizationLayout.vue
Normal file
82
frontend/src/layouts/OrganizationLayout.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="organization-layout min-h-screen text-white">
|
||||
<!-- Modular header with BaseHeader + Lego components -->
|
||||
<BaseHeader>
|
||||
<template #left>
|
||||
<HeaderLogo />
|
||||
</template>
|
||||
|
||||
<template #center>
|
||||
<div class="font-semibold text-lg text-white tracking-wide">{{ orgDisplayName }} garázsa</div>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<HeaderCompanySwitcher />
|
||||
<HeaderProfile />
|
||||
</template>
|
||||
</BaseHeader>
|
||||
|
||||
<!-- Main content slot for child routes -->
|
||||
<main class="relative z-10">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
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'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// ── Resolve organization display name from route params ────────────
|
||||
const orgDisplayName = computed(() => {
|
||||
const id = Number(route.params.id)
|
||||
if (!id) return t('organization.fallbackName')
|
||||
const org = authStore.myOrganizations.find((o) => o.organization_id === id)
|
||||
return org?.display_name || org?.name || t('organization.fallbackName')
|
||||
})
|
||||
|
||||
// ── Resolve current organization object ────────────────────────────
|
||||
const currentOrganization = computed(() => {
|
||||
const id = Number(route.params.id)
|
||||
if (!id) return null
|
||||
return authStore.myOrganizations.find((o) => o.organization_id === id) || null
|
||||
})
|
||||
|
||||
// ── Apply theme when organization loads or changes ────────────────
|
||||
function applyOrgTheme() {
|
||||
const org = currentOrganization.value
|
||||
if (org?.visual_settings) {
|
||||
themeStore.applyTheme(org.visual_settings)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for organization changes (e.g. when switching orgs via switcher)
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
applyOrgTheme()
|
||||
},
|
||||
)
|
||||
|
||||
// Apply theme on mount
|
||||
onMounted(() => {
|
||||
applyOrgTheme()
|
||||
})
|
||||
|
||||
// Reset theme when leaving the organization view
|
||||
onUnmounted(() => {
|
||||
themeStore.resetTheme()
|
||||
})
|
||||
</script>
|
||||
48
frontend/src/layouts/PrivateLayout.vue
Normal file
48
frontend/src/layouts/PrivateLayout.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="private-layout min-h-screen text-white">
|
||||
<!-- Modular header with BaseHeader + Lego components -->
|
||||
<BaseHeader>
|
||||
<template #left>
|
||||
<HeaderLogo />
|
||||
</template>
|
||||
|
||||
<template #center>
|
||||
<!-- Center label: {firstName}_garage -->
|
||||
<span class="text-sm font-semibold text-white/70 tracking-wide">
|
||||
{{ centerLabel }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<HeaderCompanySwitcher />
|
||||
<HeaderProfile />
|
||||
</template>
|
||||
</BaseHeader>
|
||||
|
||||
<!-- Main content slot for child routes -->
|
||||
<main class="relative z-10">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
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'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Center label: shows "{firstName}_garage" ───────────────────────
|
||||
const centerLabel = computed(() => {
|
||||
const firstName = authStore.user?.first_name ||
|
||||
authStore.user?.email?.split('@')[0] ||
|
||||
t('header.user')
|
||||
return t('header.personalGarageLabel', { name: firstName })
|
||||
})
|
||||
</script>
|
||||
@@ -5,11 +5,20 @@ import App from './App.vue'
|
||||
import router from './router'
|
||||
import hu from './i18n/hu'
|
||||
import en from './i18n/en'
|
||||
import de from './i18n/de'
|
||||
import ro from './i18n/ro'
|
||||
import cz from './i18n/cz'
|
||||
import sk from './i18n/sk'
|
||||
import './assets/main.css'
|
||||
import 'flag-icons/css/flag-icons.css'
|
||||
|
||||
const messages = {
|
||||
hu,
|
||||
en,
|
||||
de,
|
||||
ro,
|
||||
cz,
|
||||
sk,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,11 +43,73 @@ function detectLocale(): string {
|
||||
|
||||
const initialLocale = detectLocale()
|
||||
|
||||
/**
|
||||
* DateTime format configurations per locale.
|
||||
*
|
||||
* Usage in Vue templates:
|
||||
* $d(date, 'short') → "2026. 06. 08." (hu) or "06/08/2026" (en)
|
||||
* $d(date, 'long') → "2026. június 8., hétfő" (hu) or "Monday, June 8, 2026" (en)
|
||||
*/
|
||||
const datetimeFormats: Record<string, Intl.DateTimeFormatOptions> = {
|
||||
hu: {
|
||||
short: { year: 'numeric', month: '2-digit', day: '2-digit' },
|
||||
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
|
||||
},
|
||||
en: {
|
||||
short: { year: 'numeric', month: '2-digit', day: '2-digit' },
|
||||
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Number / currency format configurations per locale.
|
||||
*
|
||||
* Usage in Vue templates:
|
||||
* $n(amount, 'currency') → "1 000 Ft" (hu) or "€1,000.00" (en)
|
||||
* $n(value, 'decimal') → "1 234,56" (hu) or "1,234.56" (en)
|
||||
*/
|
||||
const numberFormats: Record<string, Intl.NumberFormatOptions> = {
|
||||
hu: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'HUF',
|
||||
currencyDisplay: 'symbol',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
},
|
||||
decimal: {
|
||||
style: 'decimal',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
},
|
||||
en: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
currencyDisplay: 'symbol',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
decimal: {
|
||||
style: 'decimal',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
datetimeFormats,
|
||||
numberFormats,
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -11,45 +11,79 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
// Lazy-loaded — placeholder until the actual Dashboard view is built
|
||||
component: () => import('../views/DashboardView.vue')
|
||||
component: () => import('../layouts/PrivateLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('../views/DashboardView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/organization/:id',
|
||||
component: () => import('../layouts/OrganizationLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'organization-dashboard',
|
||||
component: () => import('../views/organization/CompanyGarageView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/complete-kyc',
|
||||
name: 'complete-kyc',
|
||||
// Lazy-loaded — placeholder until the actual KYC view is built
|
||||
component: () => import('../views/CompleteKycView.vue')
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
// Auth-protected profile settings page
|
||||
component: () => import('../views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/verify',
|
||||
name: 'verify',
|
||||
// Email verification page (accessible without auth)
|
||||
component: () => import('../views/VerifyEmailView.vue')
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
// Password reset page — reads ?token= from query param
|
||||
component: () => import('../views/ResetPasswordView.vue')
|
||||
},
|
||||
{
|
||||
path: '/company/onboard',
|
||||
name: 'company-onboard',
|
||||
component: () => import('../views/organization/CompanyOnboardingView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/company/garage',
|
||||
redirect: () => {
|
||||
// Redirect old /company/garage to /organization/:id
|
||||
// The auth store will be checked at runtime
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// ── Global navigation guard ──────────────────────────────────────────
|
||||
// If an authenticated user navigates to the root ("/"), redirect to "/dashboard".
|
||||
// Protected routes (meta.requiresAuth) redirect unauthenticated users to "/".
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const { useAuthStore } = await import('../stores/auth')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// CRITICAL: Wait for the auth store to finish initializing
|
||||
// (reading token from localStorage + fetching user profile).
|
||||
// Without this, the guard runs before init() completes on F5 refresh,
|
||||
// causing isAuthenticated to be falsely false -> user gets logged out.
|
||||
if (!authStore.isInitialized) {
|
||||
await authStore.init()
|
||||
}
|
||||
|
||||
// Redirect authenticated users away from landing
|
||||
if (to.path === '/') {
|
||||
if (authStore.isAuthenticated) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '../router'
|
||||
import api from '../api/axios'
|
||||
import type { OrganizationItem } from '../types/organization'
|
||||
|
||||
/**
|
||||
* Backend now sends address fields directly on the person object
|
||||
@@ -76,6 +77,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('access_token'))
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const myOrganizations = ref<OrganizationItem[]>([])
|
||||
const isInitialized = ref(false)
|
||||
|
||||
// ────────────────────────────── Getters ────────────────────────────
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
@@ -182,6 +185,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
// Immediately fetch the user profile
|
||||
await fetchUser()
|
||||
|
||||
// Fetch organizations after successful login so the header can show them
|
||||
await fetchMyOrganizations()
|
||||
|
||||
// Clear pending verification email after successful login
|
||||
localStorage.removeItem('pending_verification_email')
|
||||
|
||||
@@ -341,10 +347,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
if (token.value) {
|
||||
try {
|
||||
await fetchUser()
|
||||
// Load organizations so the header switcher has data immediately
|
||||
await fetchMyOrganizations()
|
||||
} catch {
|
||||
// Token invalid — already cleared by fetchUser → logout
|
||||
}
|
||||
}
|
||||
// Mark initialization as complete so the router guard can await it
|
||||
isInitialized.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,12 +481,94 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// ──────────────────────── Organization Actions ──────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the current user's organizations from GET /api/v1/organizations/my.
|
||||
* Stores them in myOrganizations state.
|
||||
*/
|
||||
async function fetchMyOrganizations() {
|
||||
try {
|
||||
const res = await api.get('/organizations/my')
|
||||
myOrganizations.value = res.data as OrganizationItem[]
|
||||
return myOrganizations.value
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch organizations:', err)
|
||||
myOrganizations.value = []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's first business organization (org_type === 'business' or similar).
|
||||
* Returns the organization item or null if none found.
|
||||
*/
|
||||
const businessOrganization = computed(() => {
|
||||
return myOrganizations.value.find(
|
||||
(org) => org.org_type === 'business' || org.org_type === 'fleet_owner'
|
||||
) || null
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the user has any business organization.
|
||||
*/
|
||||
const hasBusinessOrganization = computed(() => {
|
||||
return !!businessOrganization.value
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the user is currently in corporate mode
|
||||
* (active_organization_id is set and matches a business org).
|
||||
*/
|
||||
const isCorporateMode = computed(() => {
|
||||
return !!user.value?.active_organization_id
|
||||
})
|
||||
|
||||
/**
|
||||
* Switch the user's active organization.
|
||||
* PATCH /api/v1/users/me/active-organization with { organization_id }.
|
||||
* On success, updates the token and user state, then navigates.
|
||||
*/
|
||||
async function switchOrganization(orgId: number | null) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.patch('/users/me/active-organization', {
|
||||
organization_id: orgId,
|
||||
})
|
||||
|
||||
const data = res.data as {
|
||||
user: UserProfile
|
||||
access_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// Update the stored token
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
token.value = data.access_token
|
||||
|
||||
// Update the user state with the fresh profile
|
||||
user.value = data.user
|
||||
} catch (err: any) {
|
||||
error.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to switch organization'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
error,
|
||||
myOrganizations,
|
||||
isInitialized,
|
||||
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
@@ -484,6 +576,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
userRole,
|
||||
userName,
|
||||
userId,
|
||||
businessOrganization,
|
||||
hasBusinessOrganization,
|
||||
isCorporateMode,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
@@ -499,5 +594,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
logout,
|
||||
init,
|
||||
clearError,
|
||||
fetchMyOrganizations,
|
||||
switchOrganization,
|
||||
}
|
||||
})
|
||||
|
||||
81
frontend/src/stores/theme.ts
Normal file
81
frontend/src/stores/theme.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Default fallback values used when visual_settings are not provided.
|
||||
* These match the fallbacks in tailwind.config.js.
|
||||
*/
|
||||
const DEFAULT_THEME = {
|
||||
primary_color: '#10b981',
|
||||
secondary_color: '#047857',
|
||||
bg_color: '#0f172a',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* CSS custom property names mapped to visual_settings keys.
|
||||
*/
|
||||
const CSS_VAR_MAP: Record<string, string> = {
|
||||
primary_color: '--theme-primary',
|
||||
secondary_color: '--theme-secondary',
|
||||
bg_color: '--theme-bg',
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
// ────────────────────────────── State ──────────────────────────────
|
||||
/** Tracks whether a custom theme is currently applied. */
|
||||
const isCustomThemeActive = ref(false)
|
||||
|
||||
// ────────────────────────────── Actions ────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply a custom theme by setting CSS custom properties on :root.
|
||||
* Only known keys (primary_color, secondary_color, bg_color) are applied.
|
||||
* Unknown keys in visualSettings are silently ignored.
|
||||
*
|
||||
* @param visualSettings - A flat object with theme keys, e.g.
|
||||
* { primary_color: '#3b82f6', secondary_color: '#1d4ed8', bg_color: '#0f172a' }
|
||||
*/
|
||||
function applyTheme(visualSettings: Record<string, any>) {
|
||||
if (!visualSettings || typeof visualSettings !== 'object') {
|
||||
console.warn('[ThemeStore] applyTheme called with invalid settings:', visualSettings)
|
||||
return
|
||||
}
|
||||
|
||||
const root = document.documentElement
|
||||
|
||||
for (const [key, cssVar] of Object.entries(CSS_VAR_MAP)) {
|
||||
const value = visualSettings[key]
|
||||
if (value && typeof value === 'string') {
|
||||
root.style.setProperty(cssVar, value)
|
||||
} else {
|
||||
// Fallback to default if key is missing or invalid
|
||||
root.style.setProperty(cssVar, (DEFAULT_THEME as Record<string, string>)[key])
|
||||
}
|
||||
}
|
||||
|
||||
isCustomThemeActive.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the theme by removing all custom CSS properties from :root.
|
||||
* This causes the Tailwind fallback values (and the original design system)
|
||||
* to take effect again.
|
||||
*/
|
||||
function resetTheme() {
|
||||
const root = document.documentElement
|
||||
|
||||
for (const cssVar of Object.values(CSS_VAR_MAP)) {
|
||||
root.style.removeProperty(cssVar)
|
||||
}
|
||||
|
||||
isCustomThemeActive.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isCustomThemeActive,
|
||||
// Actions
|
||||
applyTheme,
|
||||
resetTheme,
|
||||
}
|
||||
})
|
||||
24
frontend/src/types/organization.ts
Normal file
24
frontend/src/types/organization.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Organization item returned from GET /api/v1/organizations/my
|
||||
* Maps to the backend Organization model fields.
|
||||
*/
|
||||
export interface OrganizationItem {
|
||||
organization_id: number
|
||||
status: string
|
||||
name: string
|
||||
full_name: string
|
||||
display_name: string | null
|
||||
tax_number: string | null
|
||||
country_code: string
|
||||
is_active: boolean
|
||||
is_deleted: boolean
|
||||
subscription_plan: string
|
||||
/** org_type is not in the current /my response but we keep it for future use */
|
||||
org_type?: string
|
||||
/**
|
||||
* JSONB visual settings for white-labeling / dynamic theming.
|
||||
* Contains keys like: primary_color, secondary_color, bg_color, wall_logo_url, etc.
|
||||
* Set via PATCH /organizations/{org_id}/visual-settings.
|
||||
*/
|
||||
visual_settings?: Record<string, any> | null
|
||||
}
|
||||
@@ -1,349 +1,402 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#04151F] flex flex-col items-center justify-center px-4 py-8">
|
||||
<!-- Header Logo / Brand -->
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||
Service<span class="text-[#00E5A0]">Finder</span>
|
||||
</h1>
|
||||
<p class="text-white/40 text-sm mt-1">Fiók aktiválás — KYC kitöltése</p>
|
||||
<div class="relative flex min-h-screen items-center justify-center text-white">
|
||||
<!-- Background Image (like VerifyEmailView and LandingView) -->
|
||||
<div class="fixed inset-0 z-0">
|
||||
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full max-w-lg mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="idx"
|
||||
class="flex items-center flex-1"
|
||||
>
|
||||
<!-- Step circle + label -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300"
|
||||
:class="stepCircleClass(idx)"
|
||||
>
|
||||
<span v-if="currentStep > idx + 1">✓</span>
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs mt-1.5 transition-colors duration-300"
|
||||
:class="currentStep >= idx + 1 ? 'text-[#00E5A0]' : 'text-white/30'"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Connector line -->
|
||||
<!-- Content Container -->
|
||||
<div class="relative z-10 mx-auto w-full max-w-2xl px-4 py-8">
|
||||
<!-- Header Logo / Brand -->
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||
Service<span class="text-[#00E5A0]">Finder</span>
|
||||
</h1>
|
||||
<p class="text-white/60 text-sm mt-2">{{ t('kyc.title') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar (2 steps only) -->
|
||||
<div class="w-full max-w-lg mx-auto mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
v-if="idx < steps.length - 1"
|
||||
class="flex-1 h-0.5 mx-3 rounded transition-colors duration-300"
|
||||
:class="currentStep > idx + 1 ? 'bg-[#00E5A0]' : 'bg-white/10'"
|
||||
/>
|
||||
v-for="(step, idx) in steps"
|
||||
:key="idx"
|
||||
class="flex items-center flex-1"
|
||||
>
|
||||
<!-- Step circle + label -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300"
|
||||
:class="stepCircleClass(idx)"
|
||||
>
|
||||
<span v-if="currentStep > idx + 1">✓</span>
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs mt-2 transition-colors duration-300 font-medium"
|
||||
:class="currentStep >= idx + 1 ? 'text-[#00E5A0]' : 'text-white/40'"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Connector line -->
|
||||
<div
|
||||
v-if="idx < steps.length - 1"
|
||||
class="flex-1 h-1 mx-4 rounded transition-all duration-300"
|
||||
:class="currentStep > idx + 1 ? 'bg-[#00E5A0]' : 'bg-white/10'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glassmorphism Card -->
|
||||
<div class="w-full max-w-lg">
|
||||
<div
|
||||
class="backdrop-blur-xl bg-white/5 border border-white/10 rounded-2xl shadow-2xl p-8 md:p-10 transition-all duration-500"
|
||||
>
|
||||
<!-- ==================== STEP 1: Personal ==================== -->
|
||||
<div v-if="currentStep === 1">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Személyes adatok</h2>
|
||||
<p class="text-[#00E5A0]/70 text-sm mb-6">
|
||||
Vezeték- és keresztnév megadása
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Last name -->
|
||||
<div>
|
||||
<label class="block text-sm text-white/60 mb-1">Vezetéknév</label>
|
||||
<input
|
||||
v-model="kycForm.last_name"
|
||||
type="text"
|
||||
placeholder="pl. Kovács"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- First name -->
|
||||
<div>
|
||||
<label class="block text-sm text-white/60 mb-1">Keresztnév</label>
|
||||
<input
|
||||
v-model="kycForm.first_name"
|
||||
type="text"
|
||||
placeholder="pl. István"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEP 2: Address (Soft KYC) ==================== -->
|
||||
<div v-if="currentStep === 2">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Lakcím adatok</h2>
|
||||
<p class="text-white/40 text-sm mb-6">Csak az irányítószám és város kötelező <span class="text-[#D4AF37]">(Soft KYC)</span></p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Country selector (EU-ready) -->
|
||||
<div>
|
||||
<label class="block text-sm text-white/60 mb-1">Ország</label>
|
||||
<select
|
||||
v-model="kycForm.region_code"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
>
|
||||
<option value="HU" class="bg-[#04151F]">🇭🇺 Magyarország</option>
|
||||
<option value="AT" class="bg-[#04151F]">🇦🇹 Ausztria</option>
|
||||
<option value="SK" class="bg-[#04151F]">🇸🇰 Szlovákia</option>
|
||||
<option value="DE" class="bg-[#04151F]">🇩🇪 Németország</option>
|
||||
<option value="RO" class="bg-[#04151F]">🇷🇴 Románia</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="col-span-1">
|
||||
<label class="block text-sm text-white/60 mb-1">Irányítószám</label>
|
||||
<input
|
||||
v-model="kycForm.address_zip"
|
||||
type="text"
|
||||
placeholder="1234"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm text-white/60 mb-1">Város</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="kycForm.address_city"
|
||||
type="text"
|
||||
placeholder="Budapest"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition pr-10"
|
||||
:class="{ 'opacity-60': isCityLoading }"
|
||||
/>
|
||||
<!-- Loading spinner -->
|
||||
<div
|
||||
v-if="isCityLoading"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<svg
|
||||
class="animate-spin h-5 w-5 text-[#00E5A0]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm text-white/60 mb-1">Utca neve <span class="text-white/20">(opcionális)</span></label>
|
||||
<input
|
||||
v-model="kycForm.address_street_name"
|
||||
type="text"
|
||||
placeholder="Kossuth Lajos"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/60 mb-1">Típus <span class="text-white/20">(opcionális)</span></label>
|
||||
<select
|
||||
v-model="kycForm.address_street_type"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
>
|
||||
<option value="" disabled class="bg-[#04151F]">Típus</option>
|
||||
<option value="utca" class="bg-[#04151F]">utca</option>
|
||||
<option value="út" class="bg-[#04151F]">út</option>
|
||||
<option value="tér" class="bg-[#04151F]">tér</option>
|
||||
<option value="körút" class="bg-[#04151F]">körút</option>
|
||||
<option value="sugárút" class="bg-[#04151F]">sugárút</option>
|
||||
<option value="köz" class="bg-[#04151F]">köz</option>
|
||||
<option value="sor" class="bg-[#04151F]">sor</option>
|
||||
<option value="liget" class="bg-[#04151F]">liget</option>
|
||||
<option value="park" class="bg-[#04151F]">park</option>
|
||||
<option value="dűlő" class="bg-[#04151F]">dűlő</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-white/60 mb-1">Házszám <span class="text-white/20">(opcionális)</span></label>
|
||||
<input
|
||||
v-model="kycForm.address_house_number"
|
||||
type="text"
|
||||
placeholder="10/A"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
@keydown.tab.exact="handleHouseNumberTab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEP 3: Security ==================== -->
|
||||
<div v-if="currentStep === 3">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Okmányok</h2>
|
||||
<p class="text-white/40 text-sm mb-6">Személyi igazolvány és jogosítvány adatok <span class="text-[#D4AF37]">(opcionális)</span></p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Identity Documents -->
|
||||
<div>
|
||||
<!-- ID Card -->
|
||||
<div class="bg-white/5 rounded-lg p-4 mb-3 border border-white/5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-2 h-2 rounded-full bg-white/20" />
|
||||
<span class="text-sm text-white/50">Személyi igazolvány <span class="text-white/20">(opcionális)</span></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">Okmány száma</label>
|
||||
<input
|
||||
v-model="kycForm.identity_docs.ID_CARD.number"
|
||||
type="text"
|
||||
placeholder="123456AB"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">Lejárat</label>
|
||||
<input
|
||||
v-model="kycForm.identity_docs.ID_CARD.expiry_date"
|
||||
type="date"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white [color-scheme:dark] focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Driver's License (optional) -->
|
||||
<div class="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-2 h-2 rounded-full bg-white/20" />
|
||||
<span class="text-sm text-white/50">Jogosítvány <span class="text-white/20">(opcionális)</span></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">Okmány száma</label>
|
||||
<input
|
||||
v-model="kycForm.identity_docs.LICENSE.number"
|
||||
type="text"
|
||||
placeholder="HU-1234567"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">Lejárat</label>
|
||||
<input
|
||||
v-model="kycForm.identity_docs.LICENSE.expiry_date"
|
||||
type="date"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white [color-scheme:dark] focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language & Currency Preferences -->
|
||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||
<div>
|
||||
<label class="block text-sm text-white/60 mb-1">Nyelv</label>
|
||||
<select
|
||||
v-model="kycForm.preferred_language"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
>
|
||||
<option value="hu" class="bg-[#04151F]">Magyar</option>
|
||||
<option value="en" class="bg-[#04151F]">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/60 mb-1">Pénznem</label>
|
||||
<select
|
||||
v-model="kycForm.preferred_currency"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
>
|
||||
<option value="HUF" class="bg-[#04151F]">HUF (Ft)</option>
|
||||
<option value="EUR" class="bg-[#04151F]">EUR (€)</option>
|
||||
<option value="USD" class="bg-[#04151F]">USD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== Navigation Buttons ==================== -->
|
||||
<div class="flex items-center justify-between mt-8 pt-6 border-t border-white/5">
|
||||
<!-- Back button -->
|
||||
<button
|
||||
v-if="currentStep > 1"
|
||||
@click="prevStep"
|
||||
class="px-5 py-2.5 rounded-lg text-white/60 hover:text-white border border-white/10 hover:border-white/30 transition-all text-sm"
|
||||
>
|
||||
← Vissza
|
||||
</button>
|
||||
<div v-else />
|
||||
|
||||
<!-- Next / Submit -->
|
||||
<button
|
||||
v-if="currentStep < 3"
|
||||
@click="nextStep"
|
||||
ref="nextButton"
|
||||
class="px-6 py-2.5 rounded-lg bg-[#00E5A0] text-[#04151F] font-semibold hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/20 transition-all text-sm"
|
||||
>
|
||||
Tovább →
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
@click="handleSubmit"
|
||||
:disabled="isSubmitting"
|
||||
class="px-6 py-2.5 rounded-lg font-semibold transition-all text-sm flex items-center gap-2"
|
||||
:class="isSubmitting
|
||||
? 'bg-[#00E5A0]/50 text-[#04151F]/70 cursor-not-allowed'
|
||||
: 'bg-[#00E5A0] text-[#04151F] hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/20'"
|
||||
>
|
||||
<!-- Spinner -->
|
||||
<svg
|
||||
v-if="isSubmitting"
|
||||
class="animate-spin h-4 w-4 text-[#04151F]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span>{{ isSubmitting ? 'Feldolgozás...' : 'Garázs Hitelesítése és Belépés' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<!-- Glassmorphism Card -->
|
||||
<div class="w-full max-w-lg mx-auto">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="mt-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
|
||||
class="rounded-2xl border border-white/[0.08] bg-white/[0.05] p-8 md:p-10 backdrop-blur-xl shadow-xl shadow-black/20 transition-all duration-500"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
<!-- ==================== STEP 1: Address (Soft KYC) ==================== -->
|
||||
<div v-if="currentStep === 1">
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('kyc.addressTitle') }}</h2>
|
||||
<p class="text-white/60 text-sm mb-6 leading-relaxed">
|
||||
{{ t('kyc.addressDescription') }}
|
||||
<span class="text-[#00E5A0]">{{ t('kyc.softKyc') }}</span>
|
||||
</p>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Country selector (EU-ready) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.country') }}</label>
|
||||
<select
|
||||
v-model="kycForm.region_code"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
>
|
||||
<option value="HU" class="bg-[#04151F]">🇭🇺 Magyarország</option>
|
||||
<option value="AT" class="bg-[#04151F]">🇦🇹 Ausztria</option>
|
||||
<option value="SK" class="bg-[#04151F]">🇸🇰 Szlovákia</option>
|
||||
<option value="DE" class="bg-[#04151F]">🇩🇪 Németország</option>
|
||||
<option value="RO" class="bg-[#04151F]">🇷🇴 Románia</option>
|
||||
<option value="CZ" class="bg-[#04151F]">🇨🇿 Csehország</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="col-span-1">
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('kyc.zipCode') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="kycForm.address_zip"
|
||||
type="text"
|
||||
placeholder="1234"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('kyc.city') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="kycForm.address_city"
|
||||
type="text"
|
||||
placeholder="Budapest"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all pr-10"
|
||||
:class="{ 'opacity-60': isCityLoading }"
|
||||
/>
|
||||
<!-- Loading spinner -->
|
||||
<div
|
||||
v-if="isCityLoading"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<svg
|
||||
class="animate-spin h-5 w-5 text-[#00E5A0]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional street details (collapsible) -->
|
||||
<div class="border-t border-white/10 pt-4">
|
||||
<button
|
||||
@click="showOptionalAddress = !showOptionalAddress"
|
||||
type="button"
|
||||
class="flex items-center justify-between w-full text-sm text-white/60 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<span>{{ t('kyc.optionalAddress') }}</span>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="{ 'rotate-180': showOptionalAddress }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="showOptionalAddress" class="mt-4 space-y-4">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.streetName') }}</label>
|
||||
<input
|
||||
v-model="kycForm.address_street_name"
|
||||
type="text"
|
||||
placeholder="Kossuth Lajos"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.streetType') }}</label>
|
||||
<select
|
||||
v-model="kycForm.address_street_type"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
>
|
||||
<option value="" disabled class="bg-[#04151F]">{{ t('kyc.streetType') }}</option>
|
||||
<option value="utca" class="bg-[#04151F]">utca</option>
|
||||
<option value="út" class="bg-[#04151F]">út</option>
|
||||
<option value="tér" class="bg-[#04151F]">tér</option>
|
||||
<option value="körút" class="bg-[#04151F]">körút</option>
|
||||
<option value="sugárút" class="bg-[#04151F]">sugárút</option>
|
||||
<option value="köz" class="bg-[#04151F]">köz</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.houseNumber') }}</label>
|
||||
<input
|
||||
v-model="kycForm.address_house_number"
|
||||
type="text"
|
||||
placeholder="10/A"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEP 2: Optional Extras ==================== -->
|
||||
<div v-if="currentStep === 2">
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('kyc.extrasTitle') }}</h2>
|
||||
<p class="text-white/60 text-sm mb-6 leading-relaxed">
|
||||
{{ t('kyc.extrasDescription') }} <span class="text-white/40">{{ t('kyc.extrasOptional') }}</span>
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Phone number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.phoneNumber') }}</label>
|
||||
<input
|
||||
v-model="kycForm.phone_number"
|
||||
type="tel"
|
||||
placeholder="+36 30 123 4567"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Identity Documents (collapsible) -->
|
||||
<div class="border-t border-white/10 pt-4">
|
||||
<button
|
||||
@click="showDocuments = !showDocuments"
|
||||
type="button"
|
||||
class="flex items-center justify-between w-full text-sm text-white/60 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<span>{{ t('kyc.documentsTitle') }}</span>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="{ 'rotate-180': showDocuments }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="showDocuments" class="mt-4 space-y-4">
|
||||
<!-- ID Card -->
|
||||
<div class="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-2 h-2 rounded-full bg-white/30" />
|
||||
<span class="text-sm text-white/70">{{ t('kyc.idCard') }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/50 mb-1">{{ t('kyc.docNumber') }}</label>
|
||||
<input
|
||||
v-model="kycForm.identity_docs.ID_CARD.number"
|
||||
type="text"
|
||||
placeholder="123456AB"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/50 mb-1">{{ t('kyc.docExpiry') }}</label>
|
||||
<input
|
||||
v-model="kycForm.identity_docs.ID_CARD.expiry_date"
|
||||
type="date"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white [color-scheme:dark] focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Driver's License -->
|
||||
<div class="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-2 h-2 rounded-full bg-white/30" />
|
||||
<span class="text-sm text-white/70">{{ t('kyc.drivingLicense') }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/50 mb-1">{{ t('kyc.docNumber') }}</label>
|
||||
<input
|
||||
v-model="kycForm.identity_docs.LICENSE.number"
|
||||
type="text"
|
||||
placeholder="HU-1234567"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/50 mb-1">{{ t('kyc.docExpiry') }}</label>
|
||||
<input
|
||||
v-model="kycForm.identity_docs.LICENSE.expiry_date"
|
||||
type="date"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white [color-scheme:dark] focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language & Currency Preferences -->
|
||||
<div class="grid grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.language') }}</label>
|
||||
<select
|
||||
v-model="kycForm.preferred_language"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
>
|
||||
<option value="hu" class="bg-[#04151F]">Magyar</option>
|
||||
<option value="en" class="bg-[#04151F]">English</option>
|
||||
<option value="de" class="bg-[#04151F]">Deutsch</option>
|
||||
<option value="sk" class="bg-[#04151F]">Slovenčina</option>
|
||||
<option value="ro" class="bg-[#04151F]">Română</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.currency') }}</label>
|
||||
<select
|
||||
v-model="kycForm.preferred_currency"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
>
|
||||
<option value="HUF" class="bg-[#04151F]">HUF (Ft)</option>
|
||||
<option value="EUR" class="bg-[#04151F]">EUR (€)</option>
|
||||
<option value="USD" class="bg-[#04151F]">USD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== Navigation Buttons ==================== -->
|
||||
<div class="flex items-center justify-between mt-8 pt-6 border-t border-white/5">
|
||||
<!-- Back button -->
|
||||
<button
|
||||
v-if="currentStep > 1"
|
||||
@click="prevStep"
|
||||
class="px-5 py-3 rounded-xl text-white/70 hover:text-white border border-white/10 hover:border-white/30 transition-all text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ t('kyc.back') }}
|
||||
</button>
|
||||
<div v-else />
|
||||
|
||||
<!-- Next / Submit -->
|
||||
<button
|
||||
v-if="currentStep < 2"
|
||||
@click="nextStep"
|
||||
ref="nextButton"
|
||||
class="px-6 py-3 rounded-xl bg-[#00E5A0] text-[#04151F] font-semibold hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/30 transition-all text-sm flex items-center gap-2"
|
||||
>
|
||||
{{ t('kyc.next') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
@click="handleSubmit"
|
||||
:disabled="isSubmitting"
|
||||
class="px-6 py-3 rounded-xl font-semibold transition-all text-sm flex items-center gap-2"
|
||||
:class="isSubmitting
|
||||
? 'bg-[#00E5A0]/50 text-[#04151F]/70 cursor-not-allowed'
|
||||
: 'bg-[#00E5A0] text-[#04151F] hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/30'"
|
||||
>
|
||||
<!-- Spinner -->
|
||||
<svg
|
||||
v-if="isSubmitting"
|
||||
class="animate-spin h-4 w-4 text-[#04151F]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span>{{ isSubmitting ? t('kyc.submitting') : t('kyc.submit') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="mt-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skip link (optional) -->
|
||||
<div class="text-center mt-6">
|
||||
<button
|
||||
@click="handleSkip"
|
||||
class="text-sm text-white/50 hover:text-white/70 transition-colors underline"
|
||||
>
|
||||
{{ t('kyc.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, nextTick } from 'vue'
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Steps definition ──
|
||||
// ── Steps definition (2 steps only) ──
|
||||
const steps = [
|
||||
{ label: 'Személyes', key: 'personal' },
|
||||
{ label: 'Lakcím', key: 'address' },
|
||||
{ label: 'Okmányok', key: 'security' },
|
||||
{ label: t('kyc.stepAddress'), key: 'address' },
|
||||
{ label: t('kyc.stepExtras'), key: 'extras' },
|
||||
]
|
||||
|
||||
const currentStep = ref(1)
|
||||
@@ -351,17 +404,18 @@ const isSubmitting = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const isCityLoading = ref(false)
|
||||
const nextButton = ref<HTMLButtonElement | null>(null)
|
||||
const showOptionalAddress = ref(false)
|
||||
const showDocuments = ref(false)
|
||||
|
||||
// ── KYC Form (minimal — only DB-required fields) ──
|
||||
// ── KYC Form (names removed - already provided in registration) ──
|
||||
const kycForm = reactive({
|
||||
last_name: '',
|
||||
first_name: '',
|
||||
region_code: 'HU',
|
||||
address_zip: '',
|
||||
address_city: '',
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
phone_number: '',
|
||||
identity_docs: {
|
||||
ID_CARD: { number: '', expiry_date: '' },
|
||||
LICENSE: { number: '', expiry_date: '' },
|
||||
@@ -391,7 +445,7 @@ watch(
|
||||
} finally {
|
||||
isCityLoading.value = false
|
||||
}
|
||||
}, 600) // 600ms késleltetés a gépelés után
|
||||
}, 600) // 600ms delay after typing
|
||||
} else {
|
||||
isCityLoading.value = false
|
||||
}
|
||||
@@ -402,38 +456,37 @@ watch(
|
||||
function stepCircleClass(idx: number): string {
|
||||
const stepNum = idx + 1
|
||||
if (currentStep.value > stepNum) {
|
||||
return 'bg-[#00E5A0] text-[#04151F]'
|
||||
return 'bg-[#00E5A0] text-[#04151F] shadow-lg shadow-[#00E5A0]/30'
|
||||
}
|
||||
if (currentStep.value === stepNum) {
|
||||
return 'bg-[#00E5A0]/20 text-[#00E5A0] border-2 border-[#00E5A0]'
|
||||
return 'bg-[#00E5A0]/20 text-[#00E5A0] border-2 border-[#00E5A0] shadow-lg shadow-[#00E5A0]/20'
|
||||
}
|
||||
return 'bg-white/5 text-white/30 border border-white/10'
|
||||
return 'bg-white/5 text-white/40 border border-white/10'
|
||||
}
|
||||
|
||||
// ── Simple step validation (Soft KYC) ──
|
||||
// ── Validation (Soft KYC) ──
|
||||
function validateStep(): boolean {
|
||||
errorMessage.value = null
|
||||
|
||||
if (currentStep.value === 1) {
|
||||
if (!kycForm.last_name) { errorMessage.value = 'Kérlek add meg a vezetéknevedet.'; return false }
|
||||
if (!kycForm.first_name) { errorMessage.value = 'Kérlek add meg a keresztnevedet.'; return false }
|
||||
if (!kycForm.address_zip) {
|
||||
errorMessage.value = t('kyc.zipRequired')
|
||||
return false
|
||||
}
|
||||
if (!kycForm.address_city) {
|
||||
errorMessage.value = t('kyc.cityRequired')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep.value === 2) {
|
||||
if (!kycForm.address_zip) { errorMessage.value = 'Kérlek add meg az irányítószámot.'; return false }
|
||||
if (!kycForm.address_city) { errorMessage.value = 'Kérlek add meg a várost.'; return false }
|
||||
// Soft KYC: Utca, típus, házszám már NEM kötelező
|
||||
}
|
||||
|
||||
// Step 3 has no required validations anymore (Soft KYC - all optional)
|
||||
|
||||
// Step 2 has no required fields (all optional)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
function nextStep() {
|
||||
if (!validateStep()) return
|
||||
if (currentStep.value < 3) {
|
||||
if (currentStep.value < 2) {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
@@ -445,16 +498,6 @@ function prevStep() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab order fix: Házszám → Tovább gomb ──
|
||||
function handleHouseNumberTab(event: KeyboardEvent) {
|
||||
if (currentStep.value === 2 && nextButton.value) {
|
||||
event.preventDefault()
|
||||
nextTick(() => {
|
||||
nextButton.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit ──
|
||||
async function handleSubmit() {
|
||||
if (!validateStep()) return
|
||||
@@ -463,10 +506,8 @@ async function handleSubmit() {
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
// Build the payload matching UserKYCComplete schema (Soft KYC)
|
||||
// Build the payload matching UserKYCComplete schema (no names - already in registration)
|
||||
const payload: any = {
|
||||
first_name: kycForm.first_name,
|
||||
last_name: kycForm.last_name,
|
||||
region_code: kycForm.region_code,
|
||||
address_zip: kycForm.address_zip,
|
||||
address_city: kycForm.address_city,
|
||||
@@ -474,10 +515,11 @@ async function handleSubmit() {
|
||||
preferred_currency: kycForm.preferred_currency,
|
||||
}
|
||||
|
||||
// Soft KYC: Csak akkor küldjük a részletes címet, ha ki van töltve
|
||||
// Optional fields
|
||||
if (kycForm.address_street_name) payload.address_street_name = kycForm.address_street_name
|
||||
if (kycForm.address_street_type) payload.address_street_type = kycForm.address_street_type
|
||||
if (kycForm.address_house_number) payload.address_house_number = kycForm.address_house_number
|
||||
if (kycForm.phone_number) payload.phone_number = kycForm.phone_number
|
||||
|
||||
// Only include identity_docs if at least one document is filled
|
||||
const hasIdCard = kycForm.identity_docs.ID_CARD.number || kycForm.identity_docs.ID_CARD.expiry_date
|
||||
@@ -507,9 +549,21 @@ async function handleSubmit() {
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'A KYC kitöltése sikertelen. Kérlek ellenőrizd az adatokat és próbáld újra.'
|
||||
t('kyc.submitError')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skip (minimal KYC - just address required) ──
|
||||
async function handleSkip() {
|
||||
// Can only skip if step 1 (address) is completed
|
||||
if (!kycForm.address_zip || !kycForm.address_city) {
|
||||
errorMessage.value = t('kyc.addressRequired')
|
||||
return
|
||||
}
|
||||
|
||||
// Submit with minimal data
|
||||
await handleSubmit()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen text-white">
|
||||
<!-- Garage Background Image (no dark overlay — bright, light garage) -->
|
||||
<!-- Garage Background Image -->
|
||||
<div class="fixed inset-0 z-0">
|
||||
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
|
||||
|
||||
@@ -21,117 +21,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Spatial UI Layer: Hotspots (always visible, even in Zen mode) ── -->
|
||||
<div class="fixed inset-0 z-[5] pointer-events-none">
|
||||
<div class="relative w-full h-full">
|
||||
<!-- Hotspot 1: Szervizkönyv/Naptár (bal fal) -->
|
||||
<div class="absolute top-[30%] left-[20%] pointer-events-auto group">
|
||||
<!-- Pulzáló hotspot kör -->
|
||||
<div class="relative flex items-center justify-center cursor-pointer">
|
||||
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring"></div>
|
||||
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring-delayed"></div>
|
||||
<div class="relative w-6 h-6 rounded-full bg-[#FF3366] shadow-[0_0_20px_#FF3366] animate-hotspot-pulse"></div>
|
||||
</div>
|
||||
<!-- Glassmorphism Bento Box (hover opens) -->
|
||||
<div class="absolute mt-4 w-64 opacity-0 pointer-events-none scale-95 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100 group-hover:pointer-events-auto bg-[#04151F]/80 backdrop-blur-xl border border-white/20 rounded-xl p-4 shadow-2xl z-40">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<svg class="w-6 h-6 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-bold text-white">Szerviznaptár</h3>
|
||||
</div>
|
||||
<p class="text-sm text-white/60">Közelgő események</p>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
|
||||
<span>Olajcsere</span>
|
||||
<span class="text-[#00E5A0]">2 hét</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
|
||||
<span>Műszaki vizsga</span>
|
||||
<span class="text-[#00E5A0]">3 hónap</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-white/40">
|
||||
<span>Gumi szezonális</span>
|
||||
<span class="text-yellow-400">1 hónap</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hotspot 2: Szerszámok/Beállítások (jobb fal) -->
|
||||
<div class="absolute top-[30%] right-[20%] pointer-events-auto group">
|
||||
<div class="relative flex items-center justify-center cursor-pointer">
|
||||
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring"></div>
|
||||
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring-delayed"></div>
|
||||
<div class="relative w-6 h-6 rounded-full bg-[#FF3366] shadow-[0_0_20px_#FF3366] animate-hotspot-pulse"></div>
|
||||
</div>
|
||||
<div class="absolute mt-4 w-64 opacity-0 pointer-events-none scale-95 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100 group-hover:pointer-events-auto bg-[#04151F]/80 backdrop-blur-xl border border-white/20 rounded-xl p-4 shadow-2xl z-40">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<svg class="w-6 h-6 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-bold text-white">Felszerelés</h3>
|
||||
</div>
|
||||
<p class="text-sm text-white/60">Alkatrészek és Skinek</p>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
|
||||
<span>Alkatrész katalógus</span>
|
||||
<span class="text-[#418890]">42 tétel</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
|
||||
<span>Skin csomagok</span>
|
||||
<span class="text-[#418890]">6 elérhető</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-white/40">
|
||||
<span>Beállítások</span>
|
||||
<span class="text-white/30">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hotspot 3: Diagnosztika (monitor) -->
|
||||
<div class="absolute top-[60%] right-[30%] pointer-events-auto group">
|
||||
<div class="relative flex items-center justify-center cursor-pointer">
|
||||
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring"></div>
|
||||
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring-delayed"></div>
|
||||
<div class="relative w-6 h-6 rounded-full bg-[#FF3366] shadow-[0_0_20px_#FF3366] animate-hotspot-pulse"></div>
|
||||
</div>
|
||||
<div class="absolute mt-4 w-64 opacity-0 pointer-events-none scale-95 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100 group-hover:pointer-events-auto bg-[#04151F]/80 backdrop-blur-xl border border-white/20 rounded-xl p-4 shadow-2xl z-40">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<svg class="w-6 h-6 text-[#70BC84]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-bold text-white">Élő Diagnosztika</h3>
|
||||
</div>
|
||||
<p class="text-sm text-white/60">Rendszerállapot</p>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
|
||||
<span>Motor állapot</span>
|
||||
<span class="text-[#70BC84]">● Optimális</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
|
||||
<span>Akkumulátor</span>
|
||||
<span class="text-[#70BC84]">● 87%</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-white/40">
|
||||
<span>Hibakódok</span>
|
||||
<span class="text-white/30">0 találat</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Zen Mode FAB (always visible, above everything) ── -->
|
||||
<!-- ── Zen Mode FAB ── -->
|
||||
<button
|
||||
@click="isUiVisible = !isUiVisible"
|
||||
class="fixed bottom-8 right-8 z-50 btn-premium rounded-full p-4 shadow-2xl shadow-black/40"
|
||||
:title="isUiVisible ? 'UI elrejtése (Zen mód)' : 'UI megjelenítése'"
|
||||
:title="isUiVisible ? t('zen.hide') : t('zen.show')"
|
||||
>
|
||||
<!-- Eye icon when visible, eye-off when hidden -->
|
||||
<svg
|
||||
v-if="isUiVisible"
|
||||
class="w-6 h-6"
|
||||
@@ -153,114 +48,304 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- ── Top Navbar (always visible, even in Zen mode) ──────────────── -->
|
||||
<DashboardHeader
|
||||
:first-name="authStore.user?.first_name || 'Vendég'"
|
||||
subtitle="Irányítópultod és járműveid áttekintése"
|
||||
/>
|
||||
|
||||
<!-- ── Content (above the overlay) with fade transition ── -->
|
||||
<!-- ── Content with fade transition ── -->
|
||||
<Transition name="fade">
|
||||
<div v-if="isUiVisible" class="relative z-10">
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<!-- ── Loading State ──────────────────────────────────────────── -->
|
||||
<div
|
||||
v-if="vehicleStore.isLoading"
|
||||
class="flex items-center justify-center py-20"
|
||||
>
|
||||
<!-- ── Bottom-aligned 5-card container ── -->
|
||||
<div class="flex flex-col justify-end min-h-[85vh] pb-8">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
|
||||
<!-- ── Loading State ── -->
|
||||
<div
|
||||
class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Garage Grid ────────────────────────────────────────────── -->
|
||||
<div
|
||||
v-if="!vehicleStore.isLoading"
|
||||
class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="vehicle in vehicleStore.vehicles"
|
||||
:key="vehicle.id"
|
||||
class="group relative overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.05] p-6 backdrop-blur-xl shadow-xl shadow-black/20 transition-all duration-300 hover:border-[#70BC84]/30 hover:bg-white/[0.08] hover:shadow-2xl hover:shadow-black/30"
|
||||
v-if="vehicleStore.isLoading"
|
||||
class="flex items-center justify-center py-20"
|
||||
>
|
||||
<!-- ── License Plate (EU-style) ──────────────────────────── -->
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]" />
|
||||
</div>
|
||||
|
||||
<!-- ── 5-Card Grid ── -->
|
||||
<div
|
||||
v-if="!vehicleStore.isLoading"
|
||||
class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
|
||||
style="perspective: 1200px;"
|
||||
>
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 1: 🚗 My Garage (Járműveim)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
v-if="vehicle.license_plate"
|
||||
class="mb-4 inline-flex items-center overflow-hidden rounded-md border border-gray-600 shadow-sm"
|
||||
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('vehicles')"
|
||||
>
|
||||
<!-- Blue EU band -->
|
||||
<div class="flex h-9 w-9 items-center justify-center bg-[#003399] text-[10px] font-bold leading-tight text-white">
|
||||
<span class="block text-center leading-tight">SF</span>
|
||||
<!-- 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('dashboard.myVehicles') }}</span>
|
||||
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
|
||||
</div>
|
||||
<!-- White plate area -->
|
||||
<div class="flex h-9 items-center bg-white px-3">
|
||||
<span class="font-mono text-sm font-bold tracking-widest text-gray-900">
|
||||
{{ vehicle.license_plate }}
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto space-y-2">
|
||||
<div
|
||||
v-for="vehicle in vehicleStore.vehicles.slice(0, 4)"
|
||||
:key="vehicle.id"
|
||||
class="rounded-xl border border-slate-200 bg-slate-50 p-3 transition-colors hover:bg-slate-100 cursor-pointer"
|
||||
>
|
||||
<!-- License Plate -->
|
||||
<div
|
||||
v-if="vehicle.license_plate"
|
||||
class="mb-1 inline-flex items-center overflow-hidden rounded-md border border-gray-400 shadow-sm"
|
||||
>
|
||||
<div class="flex h-6 w-6 items-center justify-center bg-[#003399] text-[7px] font-bold leading-tight text-white">
|
||||
<span class="block text-center leading-tight">SF</span>
|
||||
</div>
|
||||
<div class="flex h-6 items-center bg-white px-2">
|
||||
<span class="font-mono text-xs font-bold tracking-widest text-gray-900">
|
||||
{{ vehicle.license_plate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Brand + Model -->
|
||||
<h3 class="text-sm font-bold text-slate-800">
|
||||
{{ vehicle.brand || t('dashboard.statusUnknown') }}
|
||||
<span v-if="vehicle.model" class="font-normal text-slate-500"> {{ vehicle.model }}</span>
|
||||
</h3>
|
||||
<!-- Mileage -->
|
||||
<p class="mt-0.5 text-xs text-slate-400">
|
||||
{{ vehicle.current_mileage?.toLocaleString() || 0 }} {{ t('dashboard.mileage') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="vehicleStore.vehicles.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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('dashboard.noVehicles') }}</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('dashboard.addVehicle') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 2: 💰 Costs & TCO (Költségek & Analitika)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<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('financials')"
|
||||
>
|
||||
<!-- 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('dashboard.fuelCost') }} & TCO</span>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Monthly cost summary -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p class="text-xs text-slate-500 uppercase tracking-wider font-semibold">{{ t('dashboard.monthlyScore') }}</p>
|
||||
<p class="text-2xl font-extrabold text-slate-800 mt-1">₿ 2,450</p>
|
||||
</div>
|
||||
<!-- Stat rows -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
|
||||
<span class="text-slate-500">{{ t('dashboard.totalKm') }}</span>
|
||||
<span class="font-semibold text-slate-800">12,430 km</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
|
||||
<span class="text-slate-500">{{ t('dashboard.fuelCost') }}</span>
|
||||
<span class="font-semibold text-slate-800">₿ 0.42</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
|
||||
<span class="text-slate-500">{{ t('dashboard.serviceCost') }}</span>
|
||||
<span class="font-semibold text-slate-800">₿ 1.28</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
|
||||
<span class="text-slate-500">{{ t('dashboard.co2Savings') }}</span>
|
||||
<span class="font-semibold text-emerald-600">-340 kg</span>
|
||||
</div>
|
||||
</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.finance') }} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 3: 🔧 Service Finder (Szerviz kereső)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<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')"
|
||||
>
|
||||
<!-- 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>
|
||||
</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"
|
||||
>
|
||||
<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') }} 🔍
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 4: 🏆 Gamification (Játékosítás)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<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('gamification')"
|
||||
>
|
||||
<!-- 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('dashboard.statsAndPoints') }}</span>
|
||||
<span 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('dashboard.live') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Brand + Model ─────────────────────────────────────── -->
|
||||
<h3 class="text-xl font-bold text-white">
|
||||
{{ vehicle.brand || 'Ismeretlen' }}
|
||||
<span v-if="vehicle.model" class="font-normal text-white/70">{{ vehicle.model }}</span>
|
||||
</h3>
|
||||
|
||||
<!-- Year of manufacture -->
|
||||
<p v-if="vehicle.year_of_manufacture" class="mt-0.5 text-sm text-white/40">
|
||||
{{ vehicle.year_of_manufacture }}
|
||||
</p>
|
||||
|
||||
<!-- ── Divider ────────────────────────────────────────────── -->
|
||||
<div class="my-4 border-t border-white/[0.06]" />
|
||||
|
||||
<!-- ── Condition Score Bar ────────────────────────────────── -->
|
||||
<div class="mb-3">
|
||||
<div class="mb-1 flex items-center justify-between text-xs">
|
||||
<span class="text-white/50">Állapot</span>
|
||||
<span class="font-semibold text-[#70BC84]">{{ vehicle.condition_score }}%</span>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Score card -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
|
||||
</div>
|
||||
<!-- Achievement badges -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
|
||||
{{ t('dashboard.achievements') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600"
|
||||
>
|
||||
{{ badge.icon }} {{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-[#70BC84] to-[#008CA4] transition-all duration-500"
|
||||
:style="{ width: Math.min(vehicle.condition_score, 100) + '%' }"
|
||||
/>
|
||||
<!-- 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('dashboard.refresh') }} 🏆
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
Card 5: 🛡️ My Profile & Trust (Profil & Trust Score)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<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('stats')"
|
||||
>
|
||||
<!-- 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('header.profile') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Mileage ────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-2 text-sm text-white/60">
|
||||
<svg class="h-4 w-4 text-white/30" 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>{{ vehicle.current_mileage?.toLocaleString() || 0 }} km</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Status Badge ───────────────────────────────────────── -->
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="vehicle.status === 'active'
|
||||
? 'bg-[#70BC84]/15 text-[#70BC84]'
|
||||
: 'bg-white/5 text-white/40'"
|
||||
>
|
||||
<span
|
||||
class="mr-1.5 inline-block h-1.5 w-1.5 rounded-full"
|
||||
:class="vehicle.status === 'active' ? 'bg-[#70BC84]' : 'bg-white/20'"
|
||||
/>
|
||||
{{ vehicle.status === 'active' ? 'Aktív' : vehicle.status }}
|
||||
</span>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- User info -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
|
||||
</div>
|
||||
<!-- Trust Score -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
|
||||
<span class="text-sm font-bold text-emerald-600">A+</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick stats -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
|
||||
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
|
||||
</div>
|
||||
</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('header.profile') }} →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end relative z-10 content wrapper -->
|
||||
</Transition>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
Flip Modal – 3D átforduló részletes nézet
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
v-if="activeCard"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
|
||||
@click.self="activeCard = null"
|
||||
>
|
||||
<div class="bg-white w-11/12 max-w-6xl h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
|
||||
<!-- Close button (X) -->
|
||||
<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="activeCard = null"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- Dynamic content area -->
|
||||
<div class="flex-1 p-8 overflow-y-auto text-slate-800">
|
||||
<h2 class="text-2xl font-bold mb-6">
|
||||
{{ t('dashboard.detailedView') }}: <span class="text-slate-500">{{ activeCard }}</span>
|
||||
</h2>
|
||||
<p class="text-slate-500">
|
||||
{{ t('dashboard.modalPlaceholder') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end min-h-screen -->
|
||||
</template>
|
||||
|
||||
@@ -269,17 +354,55 @@ import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useVehicleStore } from '../stores/vehicle'
|
||||
import DashboardHeader from '../components/DashboardHeader.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
// ── Zen Mode: UI láthatóság állapota ─────────────────────────────────
|
||||
// ── Zen Mode ──
|
||||
const isUiVisible = ref(true)
|
||||
|
||||
// ── Flip Modal State ──
|
||||
const activeCard = ref<string | null>(null)
|
||||
const openCard = (cardId: string) => {
|
||||
activeCard.value = cardId
|
||||
}
|
||||
|
||||
// ── Placeholder notifications ──
|
||||
const notifications = ref([
|
||||
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
|
||||
{ icon: '⛽', title: 'Fuel cost updated — +₿ 0.08', time: '5 hours ago' },
|
||||
{ icon: '🏆', title: 'You earned "Eco Driver" badge!', time: '1 day ago' },
|
||||
{ icon: '📊', title: 'Monthly report is ready', time: '2 days ago' },
|
||||
])
|
||||
|
||||
// ── Placeholder badges ──
|
||||
const badges = ref([
|
||||
{ icon: '🌱', label: 'Eco Driver' },
|
||||
{ icon: '📏', label: '10k km Club' },
|
||||
{ icon: '🔧', label: 'Service Pro' },
|
||||
{ icon: '⭐', label: 'Top Rater' },
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
vehicleStore.fetchVehicles()
|
||||
authStore.fetchMyOrganizations()
|
||||
})
|
||||
</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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-[#062535]/95 via-[#062535]/70 to-[#062535]/40"></div>
|
||||
</div>
|
||||
|
||||
<!-- FIXED Top Bar (Kőbe vésett fejléc) -->
|
||||
<!-- FIXED Top Bar -->
|
||||
<header class="fixed top-0 left-0 w-full z-50 bg-[#062535]/80 backdrop-blur-md border-b border-[#65A5A0]/20">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
<span class="text-2xl font-extrabold tracking-wider hidden sm:block"><span class="text-white">SERVICE</span> <span class="text-[#418890]">FINDER</span></span>
|
||||
</router-link>
|
||||
|
||||
<!-- Right: Language Switcher + "Garázs Nyitása" Button (Premium) -->
|
||||
<!-- Right: Language Switcher + "Open Garage" Button (Premium) -->
|
||||
<div class="flex items-center gap-3">
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
@click="showLogin = true"
|
||||
class="btn-premium px-6 py-2 text-sm"
|
||||
>
|
||||
Garázs Nyitása
|
||||
{{ t('landing.openGarage') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,52 +35,49 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 min-h-screen items-center py-12">
|
||||
|
||||
<!-- LEFT COLUMN: Text Content (Lassan megy lefelé) -->
|
||||
<div
|
||||
<!-- LEFT COLUMN: Text Content -->
|
||||
<div
|
||||
class="space-y-6"
|
||||
:style="{ transform: `translateY(${scrollY * 0.15}px)` }"
|
||||
>
|
||||
<h1 class="text-5xl md:text-6xl lg:text-7xl font-extrabold leading-tight">
|
||||
Digitális Flotta<br/>
|
||||
<span class="text-gradient-premium">Menedzsment</span>
|
||||
{{ t('landing.heroTitle') }}<br/>
|
||||
<span class="text-gradient-premium">{{ t('landing.heroTitleAccent') }}</span>
|
||||
</h1>
|
||||
<p class="text-white/80 text-lg md:text-xl max-w-xl">
|
||||
Valós idejű költségkövetés, automatikus értesítések és gamifikált pontrendszer – minden egy helyen.
|
||||
{{ t('landing.heroSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Játékos Aszimmetrikus Kártyák (Gyorsan úszik felfelé) -->
|
||||
<div
|
||||
<!-- RIGHT COLUMN: Asymmetric Cards -->
|
||||
<div
|
||||
class="relative h-[600px]"
|
||||
:style="{ transform: `translateY(${scrollY * -0.25}px)` }"
|
||||
>
|
||||
|
||||
<!-- Kártya 1: Statisztika (Hátul, fent, kisebb) -->
|
||||
<div
|
||||
<!-- Card 1: Statistics -->
|
||||
<div
|
||||
class="absolute top-0 left-0 w-[280px] z-10 transform -rotate-2 transition-transform duration-300 hover:scale-105 hover:rotate-0"
|
||||
>
|
||||
<!-- Fejléc Sáv (Sötétebb) -->
|
||||
<div class="bg-[#04151f]/60 backdrop-blur-md rounded-t-xl px-5 py-3 border-t border-x border-[#38bdf8]/20">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[#38bdf8]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
|
||||
<polyline points="16 7 22 7 22 13"/>
|
||||
</svg>
|
||||
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">Statisztika</span>
|
||||
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">{{ t('landing.cardStats') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tartalom (Világosabb Üveg) -->
|
||||
<div class="bg-[#062535]/40 backdrop-blur-md rounded-b-xl px-5 py-4 border-b border-x border-[#38bdf8]/20 shadow-[0_8px_30px_rgba(6,37,53,0.6)]">
|
||||
<div class="text-white/60 text-xs uppercase tracking-wide mb-2">Kezelt járművek</div>
|
||||
<div class="text-[#38bdf8] text-4xl font-bold font-mono">1,245<span class="text-2xl">+</span></div>
|
||||
<div class="text-white/60 text-xs uppercase tracking-wide mb-2">{{ t('landing.cardStatsLabel') }}</div>
|
||||
<div class="text-[#38bdf8] text-4xl font-bold font-mono">{{ t('landing.cardStatsValue') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kártya 2: Gamifikáció (Középen, kiemelve, NEON) -->
|
||||
<div
|
||||
<!-- Card 2: Gamification -->
|
||||
<div
|
||||
class="absolute top-[120px] right-0 w-[320px] z-30 transform rotate-1 transition-transform duration-300 hover:scale-105 hover:rotate-0"
|
||||
>
|
||||
<!-- Fejléc Sáv (Sötétebb) -->
|
||||
<div class="bg-[#04151f]/60 backdrop-blur-md rounded-t-xl px-5 py-3 border-t border-x border-[#38bdf8]">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[#38bdf8]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -91,12 +88,11 @@
|
||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/>
|
||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>
|
||||
</svg>
|
||||
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">Gamifikáció</span>
|
||||
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">{{ t('landing.cardGamification') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tartalom (Világosabb Üveg + NEON Keret) -->
|
||||
<div class="bg-[#062535]/40 backdrop-blur-md rounded-b-xl px-5 py-5 border-b border-x border-[#38bdf8] shadow-[0_0_25px_rgba(56,189,248,0.3)]">
|
||||
<div class="text-white/60 text-xs uppercase tracking-wide mb-3">Havi Pontszám</div>
|
||||
<div class="text-white/60 text-xs uppercase tracking-wide mb-3">{{ t('landing.cardGamificationLabel') }}</div>
|
||||
<div class="text-[#38bdf8] text-5xl font-bold font-mono mb-4">2,840</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 h-2 bg-[#0B212F]/50 rounded-full overflow-hidden">
|
||||
@@ -107,21 +103,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kártya 3: Költségek (Lent, elöl, átfedi a gamifikációt) -->
|
||||
<div
|
||||
<!-- Card 3: Costs -->
|
||||
<div
|
||||
class="absolute top-[320px] left-[40px] w-[300px] z-40 transform -rotate-1 transition-transform duration-300 hover:scale-105 hover:rotate-0"
|
||||
>
|
||||
<!-- Fejléc Sáv (Sötétebb) -->
|
||||
<div class="bg-[#04151f]/60 backdrop-blur-md rounded-t-xl px-5 py-3 border-t border-x border-[#65A5A0]/30">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[#65A5A0]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="1" x2="12" y2="23"/>
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">Költségek</span>
|
||||
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">{{ t('landing.cardCosts') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tartalom (Világosabb Üveg) -->
|
||||
<div class="bg-[#062535]/40 backdrop-blur-md rounded-b-xl px-5 py-4 border-b border-x border-[#65A5A0]/30 shadow-[0_8px_30px_rgba(6,37,53,0.6)]">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -132,7 +126,7 @@
|
||||
<path d="M14 22V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v18"/>
|
||||
<path d="M14 13h2a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2h0a2 2 0 0 0 2-2V9.83a2 2 0 0 0-.59-1.42L18 5"/>
|
||||
</svg>
|
||||
<span class="text-white/70 text-xs">Üzemanyag</span>
|
||||
<span class="text-white/70 text-xs">{{ t('landing.cardCostsFuel') }}</span>
|
||||
</div>
|
||||
<span class="text-white text-lg font-bold font-mono">32.5k</span>
|
||||
</div>
|
||||
@@ -141,14 +135,14 @@
|
||||
<svg class="w-4 h-4 text-[#65A5A0]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
<span class="text-white/70 text-xs">Szerviz</span>
|
||||
<span class="text-white/70 text-xs">{{ t('landing.cardCostsService') }}</span>
|
||||
</div>
|
||||
<span class="text-white text-lg font-bold font-mono">48.0k</span>
|
||||
</div>
|
||||
<div class="pt-2 border-t border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-white/60 text-xs uppercase">Összesen</span>
|
||||
<span class="text-[#38bdf8] text-xl font-bold font-mono">80.5k Ft</span>
|
||||
<span class="text-white/60 text-xs uppercase">{{ t('landing.cardCostsTotal') }}</span>
|
||||
<span class="text-[#38bdf8] text-xl font-bold font-mono">80.5k {{ t('landing.cardCostsUnit') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,14 +163,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Logo from '@/components/Logo.vue'
|
||||
import LoginModal from '@/components/LoginModal.vue'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const showLogin = ref(false)
|
||||
const scrollY = ref(0)
|
||||
|
||||
// JS Alapú Parallax Görgetés
|
||||
// JS-based Parallax Scrolling
|
||||
function handleScroll() {
|
||||
scrollY.value = window.scrollY
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,8 +21,8 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="mb-4 text-2xl font-bold text-white">Fiók aktiválása...</h1>
|
||||
<p class="mb-4 text-white/60">Kérlek várj, amíg ellenőrizzük a megerősítő linket.</p>
|
||||
<h1 class="mb-4 text-2xl font-bold text-white">{{ t('verifyEmail.verifying') }}</h1>
|
||||
<p class="mb-4 text-white/60">{{ t('verifyEmail.verifyingDesc') }}</p>
|
||||
</template>
|
||||
|
||||
<!-- ── STATE: Verify Success (Magic Link - Auto-Login) ── -->
|
||||
@@ -35,17 +35,17 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="mb-4 text-2xl font-bold text-white">Sikeres aktiválás és bejelentkezés!</h1>
|
||||
<h1 class="mb-4 text-2xl font-bold text-white">{{ t('verifyEmail.success') }}</h1>
|
||||
<p class="mb-8 text-white/60 leading-relaxed">
|
||||
A fiókod aktív lett, és automatikusan be vagy jelentkezve.
|
||||
{{ autoRedirectCountdown > 0 ? `Átirányítás ${autoRedirectCountdown} másodperc múlva...` : 'Átirányítás...' }}
|
||||
{{ t('verifyEmail.successDesc') }}
|
||||
{{ autoRedirectCountdown > 0 ? t('verifyEmail.redirectCountdown', { seconds: autoRedirectCountdown }) : t('verifyEmail.redirectNow') }}
|
||||
</p>
|
||||
<!-- Premium CTA Button (opcionális gyorsabb átirányításhoz) -->
|
||||
<button
|
||||
@click="goToKyc"
|
||||
class="btn-premium w-full px-4 py-3 text-lg"
|
||||
>
|
||||
Tovább a Profilhoz
|
||||
{{ t('verifyEmail.goToProfile') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="mb-4 text-2xl font-bold text-white">
|
||||
Erősítsd meg az e-mail címed
|
||||
{{ t('verifyEmail.checkEmail') }}
|
||||
</h1>
|
||||
|
||||
<!-- Error message (shown when token verification failed) -->
|
||||
@@ -74,20 +74,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="mb-8 text-white/60 leading-relaxed">
|
||||
Kérlek, erősítsd meg az e-mail címedet a kiküldött linkre kattintva!
|
||||
<p class="mb-8 text-white/60 leading-relaxed" v-html="t('verifyEmail.checkEmailDesc')">
|
||||
</p>
|
||||
|
||||
<!-- Email Input -->
|
||||
<div class="mb-6">
|
||||
<label for="verify-email" class="mb-2 block text-sm font-medium text-white/80 text-left">
|
||||
E-mail cím
|
||||
{{ t('verifyEmail.emailLabel') }}
|
||||
</label>
|
||||
<input
|
||||
id="verify-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="pelda@email.com"
|
||||
:placeholder="t('verifyEmail.emailPlaceholder')"
|
||||
autocomplete="email"
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
|
||||
/>
|
||||
@@ -99,7 +98,7 @@
|
||||
:disabled="isResending || !email"
|
||||
class="btn-premium w-full px-4 py-3 text-lg"
|
||||
>
|
||||
{{ isResending ? 'Küldés...' : 'Nem kaptam meg az e-mailt, küldjétek újra' }}
|
||||
{{ isResending ? t('verifyEmail.resending') : t('verifyEmail.resendButton') }}
|
||||
</button>
|
||||
|
||||
<!-- Resend Feedback Message -->
|
||||
@@ -110,6 +109,16 @@
|
||||
>
|
||||
{{ feedbackMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Back to Login Link -->
|
||||
<div class="mt-6 text-center">
|
||||
<router-link
|
||||
to="/"
|
||||
class="text-sm text-white/60 hover:text-[#75A882] transition-colors duration-200 underline"
|
||||
>
|
||||
{{ t('verifyEmail.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,11 +128,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import FingerprintJS from '@fingerprintjs/fingerprintjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Device Fingerprinting (FingerprintJS) ──
|
||||
@@ -207,7 +218,7 @@ async function processToken(token: string) {
|
||||
// Failure — token expired or invalid
|
||||
isVerifying.value = false
|
||||
verifySuccess.value = false
|
||||
verifyError.value = 'A link lejárt vagy érvénytelen.'
|
||||
verifyError.value = t('verifyEmail.tokenExpired')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,10 +254,10 @@ async function handleResend() {
|
||||
try {
|
||||
await authStore.resendVerification(email.value)
|
||||
feedbackType.value = 'success'
|
||||
feedbackMessage.value = 'Az új megerősítő e-mail elküldésre került!'
|
||||
feedbackMessage.value = t('verifyEmail.resendSuccess')
|
||||
} catch (err: any) {
|
||||
feedbackType.value = 'error'
|
||||
feedbackMessage.value = authStore.error || 'Az újraküldés sikertelen. Kérlek próbáld újra később.'
|
||||
feedbackMessage.value = authStore.error || t('verifyEmail.resendFailed')
|
||||
} finally {
|
||||
isResending.value = false
|
||||
}
|
||||
|
||||
283
frontend/src/views/organization/CompanyGarageView.vue
Normal file
283
frontend/src/views/organization/CompanyGarageView.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen">
|
||||
<!-- Background Image: garage_clean.png (clean garage, no hotspots) -->
|
||||
<div class="fixed inset-0 z-0">
|
||||
<div class="absolute inset-0 bg-[url('/garage_clean.png')] bg-cover bg-center bg-fixed"></div>
|
||||
</div>
|
||||
|
||||
<!-- 8-Column Bento Box Exhibition Layout — floor-aligned grid with perspective -->
|
||||
<div class="relative z-20 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-8 gap-6 lg:gap-12 xl:gap-16 p-4 xl:p-8 min-h-[85vh] items-end"
|
||||
style="perspective: 1200px;"
|
||||
>
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
CARD 1 — LEFT TOWER (col 1-2)
|
||||
Fleet Overview — solid 95% white background, grounded 3D
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="rollup-left col-span-1 lg:col-span-2 h-[40vh]">
|
||||
<GlassCard
|
||||
variant="standard"
|
||||
:hoverable="false"
|
||||
customClass="bg-white/95 backdrop-blur-sm hover:bg-slate-100/95 shadow-2xl drop-shadow-xl rounded-t-lg rounded-b-sm border-b-[8px] border-slate-300"
|
||||
class="h-full flex flex-col"
|
||||
>
|
||||
<template #default>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-8 h-8 text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-slate-900">{{ t('company.fleetTitle') }}</h3>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-slate-900">{{ t('company.fleetVehicles', { count: 12 }) }}</p>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-green-400"></span>
|
||||
<span class="text-green-700">10 {{ t('company.fleetActive') }}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-red-400"></span>
|
||||
<span class="text-red-700">2 {{ t('company.fleetInactive') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Fleet breakdown list — subtle inner boxes -->
|
||||
<div class="mt-2 space-y-2">
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
|
||||
<span class="text-slate-600">{{ t('company.totalKm') || 'Total KM' }}</span>
|
||||
<span class="font-semibold text-slate-900">48,230 km</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
|
||||
<span class="text-slate-600">{{ t('company.avgAge') || 'Avg. Age' }}</span>
|
||||
<span class="font-semibold text-slate-900">3.2 years</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
|
||||
<span class="text-slate-600">{{ t('company.nextService') || 'Next Service' }}</span>
|
||||
<span class="font-semibold text-slate-900">4 vehicles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
CENTER WRAPPER (col 3-6) — flex column, bottom-aligned
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="col-span-1 lg:col-span-4 h-[80vh] flex flex-col justify-end pb-0">
|
||||
<!-- CARD 2 — CENTER-TOP FLOATING (half-width, floats up)
|
||||
Dark blue background, glowing white/yellow text -->
|
||||
<div class="w-1/2 mx-auto aspect-square mb-auto mt-4 floating-center">
|
||||
<GlassCard
|
||||
variant="premium"
|
||||
:hoverable="false"
|
||||
customClass="bg-slate-900/95 backdrop-blur-sm hover:bg-slate-800/95 shadow-[0_10px_40px_rgba(0,0,0,0.3)] rounded-3xl border border-white/10"
|
||||
class="h-full flex flex-col"
|
||||
>
|
||||
<template #default>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-yellow-400 animate-pulse" />
|
||||
<h3 class="text-lg font-semibold text-white drop-shadow-md">
|
||||
{{ t('company.notificationsTitle') || 'Daily Notifications' }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(notif, idx) in notifications"
|
||||
:key="idx"
|
||||
class="flex items-start gap-3 rounded-lg bg-white/10 p-3 text-sm"
|
||||
>
|
||||
<span class="mt-0.5 text-base text-yellow-400">{{ notif.icon }}</span>
|
||||
<div>
|
||||
<p class="text-gray-100 font-medium">{{ notif.title }}</p>
|
||||
<p class="text-gray-400 text-xs mt-0.5">{{ notif.time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notifications.length === 0"
|
||||
class="flex flex-col items-center justify-center py-8 text-gray-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>
|
||||
</template>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<!-- CARD 4 — CENTER-BOTTOM ROLLUP (full-width, sits on floor)
|
||||
95% white background, dark text -->
|
||||
<div class="w-full h-[50vh] rollup-base mt-auto">
|
||||
<GlassCard
|
||||
variant="standard"
|
||||
:hoverable="false"
|
||||
customClass="bg-white/95 backdrop-blur-sm hover:bg-slate-100/95 shadow-2xl drop-shadow-xl rounded-t-lg rounded-b-sm border-b-[8px] border-slate-300"
|
||||
class="h-full flex flex-col"
|
||||
>
|
||||
<template #default>
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-8 h-8 text-slate-700" 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>
|
||||
<h3 class="text-lg font-semibold text-slate-900">
|
||||
{{ t('company.recentCostsTitle') || 'Recent Costs & Repairs' }}
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Cost summary row -->
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
|
||||
<span class="text-slate-600">{{ t('company.totalCosts') || 'Total Costs' }}</span>
|
||||
<span class="font-semibold text-slate-900">₿ 12,450</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
|
||||
<span class="text-slate-600">{{ t('company.avgPerVehicle') || 'Avg / Vehicle' }}</span>
|
||||
<span class="font-semibold text-slate-900">₿ 1,037</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
|
||||
<span class="text-slate-600">{{ t('company.monthlyBudget') || 'Monthly Budget' }}</span>
|
||||
<span class="font-semibold text-[#70BC84]">₿ 15,000</span>
|
||||
</div>
|
||||
<!-- Recent cost items list -->
|
||||
<div class="mt-2 pt-2 border-t border-slate-200">
|
||||
<p class="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
{{ t('company.latestEntries') || 'Latest Entries' }}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-slate-900">Oil Change — BMW X5</span>
|
||||
<span class="text-xs text-slate-600">Budapest, Kárász Auto</span>
|
||||
</div>
|
||||
<span class="font-semibold text-slate-900">₿ 0.18</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-slate-900">Brake Pads — Audi A3</span>
|
||||
<span class="text-xs text-slate-600">Győr, AutoCenter</span>
|
||||
</div>
|
||||
<span class="font-semibold text-slate-900">₿ 0.42</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-slate-900">Tire Change — VW Golf</span>
|
||||
<span class="text-xs text-slate-600">Debrecen, Gumiservice</span>
|
||||
</div>
|
||||
<span class="font-semibold text-slate-900">₿ 0.09</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
CARD 3 — RIGHT TOWER (col 7-8)
|
||||
Quick Actions — 95% white background, dark text
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="rollup-right col-span-1 lg:col-span-2 h-[40vh]">
|
||||
<GlassCard
|
||||
variant="standard"
|
||||
:hoverable="false"
|
||||
customClass="bg-white/95 backdrop-blur-sm hover:bg-slate-100/95 shadow-2xl drop-shadow-xl rounded-t-lg rounded-b-sm border-b-[8px] border-slate-300"
|
||||
class="h-full flex flex-col"
|
||||
>
|
||||
<template #default>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-8 h-8 text-slate-700" 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>
|
||||
<h3 class="text-lg font-semibold text-slate-900">{{ t('company.quickActionsTitle') }}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 mt-2">
|
||||
<button class="w-full px-4 py-3 rounded-xl bg-slate-100/80 hover:bg-slate-200/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-slate-200">
|
||||
{{ t('company.quickNewWaybill') }}
|
||||
</button>
|
||||
<button class="w-full px-4 py-3 rounded-xl bg-slate-100/80 hover:bg-slate-200/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-slate-200">
|
||||
{{ t('company.quickReportIssue') }}
|
||||
</button>
|
||||
<button class="w-full px-4 py-3 rounded-xl bg-amber-50/80 hover:bg-amber-100/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-amber-200">
|
||||
{{ t('company.quickSchedule') || 'Schedule Service' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Recent activity mini-list -->
|
||||
<div class="mt-4 pt-3 border-t border-slate-200">
|
||||
<p class="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
|
||||
{{ t('company.recentActivity') || 'Recent Activity' }}
|
||||
</p>
|
||||
<div class="space-y-1.5 text-sm text-slate-600">
|
||||
<p>• {{ t('company.activityAdded') || 'Added' }} BMW X5</p>
|
||||
<p>• {{ t('company.activityServiced') || 'Serviced' }} Audi A3</p>
|
||||
<p>• {{ t('company.activityInvoice') || 'Invoice' }} #INV-0421</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import GlassCard from '../../components/ui/GlassCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── Placeholder notifications ──
|
||||
const notifications = ref([
|
||||
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
|
||||
{ icon: '⛽', title: 'Fuel cost updated — +₿ 0.08', time: '5 hours ago' },
|
||||
{ icon: '📊', title: 'Monthly report is ready', time: '1 day ago' },
|
||||
{ icon: '🛞', title: 'Tire change reminder — VW Golf', time: '2 days ago' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── 3D Rollup Standee Animations (subtle perspective) ── */
|
||||
|
||||
.rollup-left {
|
||||
transform: rotateY(4deg) scale(0.98);
|
||||
transition: transform 0.5s ease-out;
|
||||
}
|
||||
.rollup-left:hover {
|
||||
transform: rotateY(0deg) scale(1.0);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.rollup-right {
|
||||
transform: rotateY(-4deg) scale(0.98);
|
||||
transition: transform 0.5s ease-out;
|
||||
}
|
||||
.rollup-right:hover {
|
||||
transform: rotateY(0deg) scale(1.0);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.floating-center {
|
||||
transform: translateY(0px);
|
||||
transition: transform 0.5s ease-out;
|
||||
}
|
||||
.floating-center:hover {
|
||||
transform: translateY(-5px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.rollup-base {
|
||||
transform: translateY(4px) scale(0.98);
|
||||
transition: transform 0.5s ease-out;
|
||||
}
|
||||
.rollup-base:hover {
|
||||
transform: translateY(0px) scale(1.0);
|
||||
z-index: 20;
|
||||
}
|
||||
</style>
|
||||
761
frontend/src/views/organization/CompanyOnboardingView.vue
Normal file
761
frontend/src/views/organization/CompanyOnboardingView.vue
Normal file
@@ -0,0 +1,761 @@
|
||||
<template>
|
||||
<div class="relative flex min-h-screen items-center justify-center text-white">
|
||||
<!-- Background Image -->
|
||||
<div class="fixed inset-0 z-0">
|
||||
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="relative z-10 mx-auto w-full max-w-2xl px-4 py-8">
|
||||
<!-- Header Logo / Brand -->
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||
Service<span class="text-[#00E5A0]">Finder</span>
|
||||
</h1>
|
||||
<p class="text-white/60 text-sm mt-2">{{ t('company.onboardingTitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Stepper (3 steps) -->
|
||||
<div class="w-full max-w-lg mx-auto mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="idx"
|
||||
class="flex items-center flex-1"
|
||||
>
|
||||
<!-- Step circle + label -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300"
|
||||
:class="stepCircleClass(idx)"
|
||||
>
|
||||
<span v-if="currentStep > idx + 1">✓</span>
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs mt-2 transition-colors duration-300 font-medium text-center"
|
||||
:class="currentStep >= idx + 1 ? 'text-[#00E5A0]' : 'text-white/40'"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Connector line -->
|
||||
<div
|
||||
v-if="idx < steps.length - 1"
|
||||
class="flex-1 h-1 mx-4 rounded transition-all duration-300"
|
||||
:class="currentStep > idx + 1 ? 'bg-[#00E5A0]' : 'bg-white/10'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glassmorphism Card with strong shadow -->
|
||||
<div class="w-full max-w-lg mx-auto">
|
||||
<div
|
||||
class="rounded-2xl border border-white/[0.08] bg-slate-800/95 p-8 md:p-10 backdrop-blur-xl shadow-2xl shadow-black/50 transition-all duration-500"
|
||||
>
|
||||
<!-- ==================== STEP 1: Legal Data ==================== -->
|
||||
<div v-if="currentStep === 1">
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('company.legalTitle') }}</h2>
|
||||
<p class="text-white/60 text-sm mb-6 leading-relaxed" v-html="t('company.legalDescription')">
|
||||
</p>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Tax number + Lookup button (always visible) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.taxNumber') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<div class="flex gap-2 items-end">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="form.tax_number"
|
||||
@input="onTaxNumberChange"
|
||||
type="text"
|
||||
placeholder="12345678-1-43"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="lookupTaxNumber"
|
||||
:disabled="isTaxLookupLoading || !form.tax_number"
|
||||
class="px-4 py-3 rounded-xl bg-[#00E5A0]/10 border border-[#00E5A0]/30 text-[#00E5A0] hover:bg-[#00E5A0]/20 hover:border-[#00E5A0]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2 text-sm font-medium whitespace-nowrap"
|
||||
:title="t('company.taxLookupTitle')"
|
||||
>
|
||||
<svg
|
||||
v-if="isTaxLookupLoading"
|
||||
class="animate-spin h-4 w-4 text-[#00E5A0]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{{ t('company.taxLookup') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden fields, shown only after tax lookup or manual reveal -->
|
||||
<template v-if="taxLookedUp">
|
||||
<!-- Full company name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.fullName') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
placeholder="pl. Kovács és Társa Kft."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Short name + Display name -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.shortName') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
@input="autoFillDisplayName"
|
||||
type="text"
|
||||
placeholder="pl. Kovács Kft."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.displayName') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.display_name"
|
||||
type="text"
|
||||
placeholder="pl. Kovács Kft."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.regNumber') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.reg_number"
|
||||
type="text"
|
||||
placeholder="01-02-3456789"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEP 2: Address & Finance ==================== -->
|
||||
<div v-if="currentStep === 2">
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('company.addressTitle') }}</h2>
|
||||
<p class="text-white/60 text-sm mb-6 leading-relaxed">
|
||||
{{ t('company.addressDescription') }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Country, Language, Currency -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.country') }}</label>
|
||||
<select
|
||||
v-model="form.country_code"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
>
|
||||
<option value="HU" class="bg-[#04151F]">🇭🇺 Magyarország</option>
|
||||
<option value="AT" class="bg-[#04151F]">🇦🇹 Ausztria</option>
|
||||
<option value="SK" class="bg-[#04151F]">🇸🇰 Szlovákia</option>
|
||||
<option value="DE" class="bg-[#04151F]">🇩🇪 Németország</option>
|
||||
<option value="RO" class="bg-[#04151F]">🇷🇴 Románia</option>
|
||||
<option value="CZ" class="bg-[#04151F]">🇨🇿 Csehország</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.language') }}</label>
|
||||
<select
|
||||
v-model="form.language"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
>
|
||||
<option value="hu" class="bg-[#04151F]">Magyar</option>
|
||||
<option value="en" class="bg-[#04151F]">English</option>
|
||||
<option value="de" class="bg-[#04151F]">Deutsch</option>
|
||||
<option value="sk" class="bg-[#04151F]">Slovenčina</option>
|
||||
<option value="ro" class="bg-[#04151F]">Română</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.currency') }}</label>
|
||||
<select
|
||||
v-model="form.default_currency"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
>
|
||||
<option value="HUF" class="bg-[#04151F]">HUF (Ft)</option>
|
||||
<option value="EUR" class="bg-[#04151F]">EUR (€)</option>
|
||||
<option value="USD" class="bg-[#04151F]">USD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ZIP + City -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="col-span-1">
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.zipCode') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_zip"
|
||||
type="text"
|
||||
placeholder="1234"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.city') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="form.address_city"
|
||||
type="text"
|
||||
placeholder="Budapest"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all pr-10"
|
||||
:class="{ 'opacity-60': isCityLoading }"
|
||||
/>
|
||||
<!-- Loading spinner -->
|
||||
<div
|
||||
v-if="isCityLoading"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<svg
|
||||
class="animate-spin h-5 w-5 text-[#00E5A0]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Street details -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.streetName') }}</label>
|
||||
<input
|
||||
v-model="form.address_street_name"
|
||||
type="text"
|
||||
placeholder="Kossuth Lajos"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.streetType') }}</label>
|
||||
<select
|
||||
v-model="form.address_street_type"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
>
|
||||
<option value="" disabled class="bg-[#04151F]">{{ t('company.streetType') }}</option>
|
||||
<option value="utca" class="bg-[#04151F]">utca</option>
|
||||
<option value="út" class="bg-[#04151F]">út</option>
|
||||
<option value="tér" class="bg-[#04151F]">tér</option>
|
||||
<option value="körút" class="bg-[#04151F]">körút</option>
|
||||
<option value="sugárút" class="bg-[#04151F]">sugárút</option>
|
||||
<option value="köz" class="bg-[#04151F]">köz</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- House number + additional address fields -->
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.houseNumber') }}</label>
|
||||
<input
|
||||
v-model="form.address_house_number"
|
||||
type="text"
|
||||
placeholder="10/A"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.stairwell') }}</label>
|
||||
<input
|
||||
v-model="form.address_stairwell"
|
||||
type="text"
|
||||
placeholder="1."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.floor') }}</label>
|
||||
<input
|
||||
v-model="form.address_floor"
|
||||
type="text"
|
||||
placeholder="2."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.door') }}</label>
|
||||
<input
|
||||
v-model="form.address_door"
|
||||
type="text"
|
||||
placeholder="5"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HRsz (parcel ID) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.parcelId') }}</label>
|
||||
<input
|
||||
v-model="form.address_hrsz"
|
||||
type="text"
|
||||
placeholder="12345/1"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEP 3: Contacts ==================== -->
|
||||
<div v-if="currentStep === 3">
|
||||
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('company.contactTitle') }}</h2>
|
||||
<p class="text-white/60 text-sm mb-6 leading-relaxed">
|
||||
{{ t('company.contactDescription') }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Primary contact name (read-only) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.contactName') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="primaryContact.full_name"
|
||||
type="text"
|
||||
disabled
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white/60 placeholder-white/20 cursor-not-allowed opacity-70"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Primary email (read-only) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.contactEmail') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="primaryContact.email"
|
||||
type="email"
|
||||
disabled
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white/60 placeholder-white/20 cursor-not-allowed opacity-70"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Primary phone (read-only) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('company.contactPhone') }} <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="primaryContact.phone"
|
||||
type="tel"
|
||||
disabled
|
||||
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white/60 placeholder-white/20 cursor-not-allowed opacity-70"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== Navigation Buttons ==================== -->
|
||||
<div class="flex items-center justify-between mt-8 pt-6 border-t border-white/5">
|
||||
<!-- Cancel / Mégsem button (always visible) -->
|
||||
<button
|
||||
@click="cancelOnboarding"
|
||||
class="px-4 py-3 rounded-xl text-white/50 hover:text-white/80 border border-white/10 hover:border-white/30 transition-all text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{{ t('company.cancel') }}
|
||||
</button>
|
||||
|
||||
<!-- Back button -->
|
||||
<button
|
||||
v-if="currentStep > 1"
|
||||
@click="prevStep"
|
||||
class="px-5 py-3 rounded-xl text-white/70 hover:text-white border border-white/10 hover:border-white/30 transition-all text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ t('company.back') }}
|
||||
</button>
|
||||
<div v-else class="flex-1" />
|
||||
|
||||
<!-- Next / Submit -->
|
||||
<button
|
||||
v-if="currentStep < 3"
|
||||
@click="nextStep"
|
||||
:disabled="isDuplicateCompany"
|
||||
class="px-6 py-3 rounded-xl bg-[#00E5A0] text-[#04151F] font-semibold hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/30 transition-all text-sm flex items-center gap-2"
|
||||
:class="isDuplicateCompany ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
>
|
||||
{{ t('company.next') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
@click="handleSubmit"
|
||||
:disabled="isSubmitting"
|
||||
class="px-6 py-3 rounded-xl font-semibold transition-all text-sm flex items-center gap-2"
|
||||
:class="isSubmitting
|
||||
? 'bg-[#00E5A0]/50 text-[#04151F]/70 cursor-not-allowed'
|
||||
: 'bg-[#00E5A0] text-[#04151F] hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/30'"
|
||||
>
|
||||
<!-- Spinner -->
|
||||
<svg
|
||||
v-if="isSubmitting"
|
||||
class="animate-spin h-4 w-4 text-[#04151F]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span>{{ isSubmitting ? t('company.submitting') : t('company.submit') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="mt-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Success message -->
|
||||
<div
|
||||
v-if="successMessage"
|
||||
class="mt-4 p-3 rounded-xl bg-[#00E5A0]/10 border border-[#00E5A0]/20 text-[#00E5A0] text-sm"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../../api/axios'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Steps definition (3 steps) ──
|
||||
const steps = [
|
||||
{ label: t('company.stepLegal'), key: 'legal' },
|
||||
{ label: t('company.stepAddress'), key: 'address' },
|
||||
{ label: t('company.stepContact'), key: 'contact' },
|
||||
]
|
||||
|
||||
const currentStep = ref(1)
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
const isCityLoading = ref(false)
|
||||
const isTaxLookupLoading = ref(false)
|
||||
const taxLookedUp = ref(false)
|
||||
const isDuplicateCompany = ref(false)
|
||||
|
||||
// ── CorpOnboardIn Form ──
|
||||
const form = reactive({
|
||||
full_name: '',
|
||||
name: '',
|
||||
display_name: '',
|
||||
tax_number: '',
|
||||
reg_number: '',
|
||||
country_code: 'HU',
|
||||
language: 'hu',
|
||||
default_currency: 'HUF',
|
||||
address_zip: '',
|
||||
address_city: '',
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
address_stairwell: '',
|
||||
address_floor: '',
|
||||
address_door: '',
|
||||
address_hrsz: '',
|
||||
})
|
||||
|
||||
// ── Primary contact ──
|
||||
const primaryContact = reactive({
|
||||
email: '',
|
||||
phone: '',
|
||||
full_name: '',
|
||||
})
|
||||
|
||||
// ── Pre-fill primary contact with logged-in user data ──
|
||||
onMounted(() => {
|
||||
if (authStore.user) {
|
||||
const person = authStore.user.person
|
||||
if (authStore.user.email) {
|
||||
primaryContact.email = authStore.user.email
|
||||
}
|
||||
if (person) {
|
||||
if (person.first_name || person.last_name) {
|
||||
primaryContact.full_name = [person.first_name, person.last_name].filter(Boolean).join(' ')
|
||||
}
|
||||
if (person.phone) {
|
||||
primaryContact.phone = person.phone
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Auto-fill display_name from name if display_name is empty ──
|
||||
function autoFillDisplayName() {
|
||||
if (!form.display_name) {
|
||||
form.display_name = form.name
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step circle styling ──
|
||||
function stepCircleClass(idx: number): string {
|
||||
const stepNum = idx + 1
|
||||
if (currentStep.value > stepNum) {
|
||||
return 'bg-[#00E5A0] text-[#04151F] shadow-lg shadow-[#00E5A0]/30'
|
||||
}
|
||||
if (currentStep.value === stepNum) {
|
||||
return 'bg-[#00E5A0]/20 text-[#00E5A0] border-2 border-[#00E5A0] shadow-lg shadow-[#00E5A0]/20'
|
||||
}
|
||||
return 'bg-white/5 text-white/40 border border-white/10'
|
||||
}
|
||||
|
||||
// ── Validation per step ──
|
||||
function validateStep(): boolean {
|
||||
errorMessage.value = null
|
||||
|
||||
if (currentStep.value === 1) {
|
||||
if (!form.tax_number) {
|
||||
errorMessage.value = t('company.taxRequired')
|
||||
return false
|
||||
}
|
||||
if (taxLookedUp.value) {
|
||||
if (!form.full_name) {
|
||||
errorMessage.value = t('company.fullNameRequired')
|
||||
return false
|
||||
}
|
||||
if (!form.name) {
|
||||
errorMessage.value = t('company.shortNameRequired')
|
||||
return false
|
||||
}
|
||||
if (!form.display_name) {
|
||||
errorMessage.value = t('company.displayNameRequired')
|
||||
return false
|
||||
}
|
||||
if (!form.reg_number) {
|
||||
errorMessage.value = t('company.regNumberRequired')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep.value === 2) {
|
||||
if (!form.address_zip) {
|
||||
errorMessage.value = t('company.zipRequired')
|
||||
return false
|
||||
}
|
||||
if (!form.address_city) {
|
||||
errorMessage.value = t('company.cityRequired')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep.value === 3) {
|
||||
if (!primaryContact.email) {
|
||||
errorMessage.value = t('company.emailRequired')
|
||||
return false
|
||||
}
|
||||
if (!primaryContact.phone) {
|
||||
errorMessage.value = t('company.phoneRequired')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Reset duplicate flag when tax number changes ──
|
||||
function onTaxNumberChange() {
|
||||
if (isDuplicateCompany.value) {
|
||||
isDuplicateCompany.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
function nextStep() {
|
||||
// Block navigation if duplicate company was detected
|
||||
if (isDuplicateCompany.value) return
|
||||
if (!validateStep()) return
|
||||
if (currentStep.value < 3) {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value--
|
||||
errorMessage.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cancel / Mégsem ──
|
||||
function cancelOnboarding() {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
// ── Build contacts array ──
|
||||
function buildContacts(): Array<Record<string, any>> {
|
||||
const contacts: Array<Record<string, any>> = []
|
||||
|
||||
// Primary contact
|
||||
const primary: Record<string, any> = {
|
||||
email: primaryContact.email,
|
||||
phone: primaryContact.phone,
|
||||
is_primary: true,
|
||||
}
|
||||
if (primaryContact.full_name) primary.full_name = primaryContact.full_name
|
||||
contacts.push(primary)
|
||||
|
||||
return contacts
|
||||
}
|
||||
|
||||
// ── Tax number lookup ──
|
||||
async function lookupTaxNumber() {
|
||||
if (!form.tax_number) return
|
||||
isTaxLookupLoading.value = true
|
||||
errorMessage.value = null
|
||||
isDuplicateCompany.value = false
|
||||
try {
|
||||
const res = await api.get('/organizations/lookup-tax/' + form.tax_number)
|
||||
const data = res.data
|
||||
// Auto-fill form fields from response
|
||||
if (data.full_name) form.full_name = data.full_name
|
||||
if (data.name) {
|
||||
form.name = data.name
|
||||
// Auto-fill display_name if empty
|
||||
if (!form.display_name) {
|
||||
form.display_name = data.name
|
||||
}
|
||||
}
|
||||
if (data.address_zip) form.address_zip = data.address_zip
|
||||
if (data.address_city) form.address_city = data.address_city
|
||||
if (data.address_street_name) form.address_street_name = data.address_street_name
|
||||
if (data.address_street_type) form.address_street_type = data.address_street_type
|
||||
if (data.address_house_number) form.address_house_number = data.address_house_number
|
||||
// Reveal the hidden fields
|
||||
taxLookedUp.value = true
|
||||
} catch (err: any) {
|
||||
// Detect 409 Conflict — duplicate company
|
||||
if (err.response?.status === 409) {
|
||||
isDuplicateCompany.value = true
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
t('company.duplicateCompany')
|
||||
} else {
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
t('company.lookupError')
|
||||
}
|
||||
} finally {
|
||||
isTaxLookupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit ──
|
||||
async function handleSubmit() {
|
||||
if (!validateStep()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
full_name: form.full_name,
|
||||
name: form.name,
|
||||
display_name: form.display_name,
|
||||
tax_number: form.tax_number,
|
||||
reg_number: form.reg_number,
|
||||
country_code: form.country_code,
|
||||
language: form.language,
|
||||
default_currency: form.default_currency,
|
||||
address_zip: form.address_zip,
|
||||
address_city: form.address_city,
|
||||
contacts: buildContacts(),
|
||||
}
|
||||
|
||||
// Optional address fields
|
||||
if (form.address_street_name) payload.address_street_name = form.address_street_name
|
||||
if (form.address_street_type) payload.address_street_type = form.address_street_type
|
||||
if (form.address_house_number) payload.address_house_number = form.address_house_number
|
||||
if (form.address_stairwell) payload.address_stairwell = form.address_stairwell
|
||||
if (form.address_floor) payload.address_floor = form.address_floor
|
||||
if (form.address_door) payload.address_door = form.address_door
|
||||
if (form.address_hrsz) payload.address_hrsz = form.address_hrsz
|
||||
|
||||
await api.post('/organizations/onboard', payload)
|
||||
|
||||
successMessage.value = t('company.successMessage')
|
||||
|
||||
// Refresh the organization list so the header updates immediately
|
||||
await authStore.fetchMyOrganizations()
|
||||
|
||||
// Navigate to company garage after a short delay
|
||||
setTimeout(() => {
|
||||
router.push('/company/garage')
|
||||
}, 1500)
|
||||
} catch (err: any) {
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
t('company.submitError')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -21,7 +21,13 @@ export default {
|
||||
'sf-dark-lighter': '#0a2a40',
|
||||
'sf-dark-card': 'rgba(255,255,255,0.04)',
|
||||
'sf-dark-border': 'rgba(255,255,255,0.10)',
|
||||
'sf-dark-glass': 'rgba(255,255,255,0.05)'
|
||||
'sf-dark-glass': 'rgba(255,255,255,0.05)',
|
||||
// ── Dynamic theme (White-labeling) ──────────────────────────
|
||||
// These reference CSS custom properties set by the theme store.
|
||||
// Fallback values are used when no custom theme is active.
|
||||
'theme-primary': 'var(--theme-primary, #10b981)',
|
||||
'theme-secondary': 'var(--theme-secondary, #047857)',
|
||||
'theme-bg': 'var(--theme-bg, #0f172a)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
|
||||
139
plans/logic_spec_dashboard_header_restructure.md
Normal file
139
plans/logic_spec_dashboard_header_restructure.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 📐 Logic Spec: Dashboard Header Restructure
|
||||
|
||||
## 1. Cél és Masterbook 2 illeszkedés
|
||||
|
||||
**Cél:** A privát garázs dashboard fejlécének (`DashboardHeader.vue`) átalakítása a felhasználó specifikációja szerint:
|
||||
- **Bal:** 👋 Üdvözlés + keresztnév
|
||||
- **Közép:** `{firstName}_garázsa` (i18n kulcsból)
|
||||
- **Jobb:** Cégváltó dropdown → Nyelvváltó → Avatár menü (sorrendben)
|
||||
- **Layout struktúra:** Marad a `PrivateLayout` + `DashboardHeader` felosztás
|
||||
|
||||
**Masterbook 2.0 illeszkedés:** A feladat illeszkedik az Epic 11 (Public Frontend) és a B2C dashboard UX specifikációjához.
|
||||
|
||||
---
|
||||
|
||||
## 2. Jelenlegi Problémák (`DashboardHeader.vue` - 685 sor)
|
||||
|
||||
| Probléma | Leírás |
|
||||
|----------|--------|
|
||||
| P1 - Túl komplex | 7 funkció egy komponensben (WelcomeCard, Logo, Funkciók menu, Center label, Org dropdown, LanguageSwitcher, Avatar) |
|
||||
| P2 - Welcome Card | Lebegő elem, nem a header része - megtartható, de auditálni kell |
|
||||
| P3 - Funkciók menu | Nem illik a header-be, 100+ sor Bento dropdown - **ELTÁVOLÍTANDÓ** |
|
||||
| P4 - Logo + Brand | 2 sor + kép - **ELTÁVOLÍTANDÓ** a header-ből |
|
||||
| P5 - Center label | Duplikált logika a jobb oldali org dropdown-nal |
|
||||
| P6 - Jobb oldal sorrend | Jelenleg: Org dropdown → LanguageSwitcher → Avatar. **Megtartandó sorrendben** |
|
||||
|
||||
---
|
||||
|
||||
## 3. Új 3-Oszlopos Header Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ [BAL] [KÖZÉP] [JOBB] │
|
||||
│ 👋 Üdv, János! János_garázsa [🏢 Cégem ▼] [🌐] [👤] │
|
||||
│ ↓ ↓ ↓ │
|
||||
│ Org Dropdown Lang Avatar│
|
||||
│ (cég listával) Switcher│
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.1 Bal oldal: Welcome + keresztnév
|
||||
|
||||
```vue
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">👋</span>
|
||||
<span class="text-sm font-medium text-white/80">
|
||||
{{ t('header.welcome') }}, <span class="font-semibold text-white">{{ displayName }}</span>!
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`displayName` computed** - már létezik, elsődlegesen `props.firstName`, fallback email prefix.
|
||||
|
||||
### 3.2 Középső: `{firstName}_garázsa`
|
||||
|
||||
```vue
|
||||
<div class="absolute left-1/2 -translate-x-1/2">
|
||||
<span class="text-sm font-semibold text-white/70 tracking-wide">
|
||||
{{ centerLabel }}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`centerLabel` computed logika:**
|
||||
- Ha `isOnCompanyGarage` (szervezeti nézetben): `t('header.companyGarageLabel', { name: activeCompanyName })`
|
||||
- Ha `isCorporateMode` (vállalati módban): `t('header.companyGarageLabel', { name: activeCompanyName })`
|
||||
- Különben (személyes mód): `t('header.personalGarageLabel', { name: firstName })`
|
||||
|
||||
### 3.3 Jobb oldal (sorrendben: Cég → Nyelv → Avatár)
|
||||
|
||||
**Cég dropdown** - Meglévő funkció tisztítva:
|
||||
- Megtartani: org-dropdown-container (dropdown cég listával)
|
||||
- Eltávolítani: duplikált "Funkciók menüben lévő Organization Menu Item"
|
||||
- Állapotok: nincs cég → "➕ Új cég" (navigáció `/company/onboard`); van cég → "🏢 Cégem" (dropdown); corporate mód → "👤 Személyes Garázs" (visszaváltás)
|
||||
|
||||
**Nyelvváltó** - Változatlan, 6 nyelv támogatása.
|
||||
|
||||
**Avatár menü** - Változatlan (Profile, Logout).
|
||||
|
||||
---
|
||||
|
||||
## 4. Eltávolítandó elemek
|
||||
|
||||
| Elem | Sorok | Hova kerül? |
|
||||
|------|-------|-------------|
|
||||
| Logo + Brand | 30-43 | ELTÁVOLÍTVA |
|
||||
| Funkciók menu (Bento) | 46-154 | ELTÁVOLÍTVA, később `DashboardView.vue`-ba |
|
||||
| Funkciók-belüli Org Menu Item | 119-135 | ELTÁVOLÍTVA (duplikáció) |
|
||||
|
||||
**Megtartva:** Floating Welcome Card (2-23. sorok) - vizuálisan elkülönül.
|
||||
|
||||
---
|
||||
|
||||
## 5. Érintett fájlok
|
||||
|
||||
| Fájl | Művelet | Mérték |
|
||||
|------|---------|--------|
|
||||
| `frontend/src/components/DashboardHeader.vue` | **ÁTÍRÁS** ~685 → ~300 sor | Nagy |
|
||||
| `frontend/src/i18n/hu.ts` | Ellenőrzés (várhatóan nincs új kulcs) | Apró |
|
||||
| `frontend/src/i18n/en.ts` | Ellenőrzés (várhatóan nincs új kulcs) | Apró |
|
||||
|
||||
**Nem érintett:** `PrivateLayout.vue`, `OrganizationLayout.vue`, `LanguageSwitcher.vue`, `auth.ts`
|
||||
|
||||
---
|
||||
|
||||
## 6. AuthStore használt értékei
|
||||
|
||||
| Érték | Típus |
|
||||
|-------|-------|
|
||||
| `authStore.user?.first_name` | `string \| undefined` |
|
||||
| `authStore.user?.email` | `string \| undefined` |
|
||||
| `authStore.myOrganizations` | `OrganizationItem[]` |
|
||||
| `authStore.hasBusinessOrganization` | `boolean` (computed) |
|
||||
| `authStore.isCorporateMode` | `boolean` (computed) |
|
||||
| `authStore.businessOrganization` | `OrganizationItem \| undefined` (computed) |
|
||||
| `authStore.switchOrganization(id \| null)` | `function` (async) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Tesztelési terv
|
||||
|
||||
1. `vite build` hiba nélkül
|
||||
2. Vizuális: Bal 👋 Welcome; Közép `{name}_garázsa`; Jobb: Cég → 🌐 → Avatár
|
||||
3. Cég dropdown működik (list, select, back)
|
||||
4. LanguageSwitcher működik (6 nyelv)
|
||||
5. Avatár menü működik (Profile, Logout)
|
||||
|
||||
---
|
||||
|
||||
## 8. Módosítási sorrend
|
||||
|
||||
1. `DashboardHeader.vue` - Teljes átírás
|
||||
2. `hu.ts` / `en.ts` - Ellenőrzés
|
||||
3. `vite build` - Verifikáció
|
||||
|
||||
---
|
||||
|
||||
## ⏸️ Jóváhagyási pont
|
||||
|
||||
**Kérem a felhasználó jóváhagyását a fenti specifikációhoz!**
|
||||
232
plans/logic_spec_opten_integration.md
Normal file
232
plans/logic_spec_opten_integration.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# logic_spec_opten_integration.md — Opten REST API Integrációs Terv
|
||||
|
||||
## Modul Célja
|
||||
A jelenlegi mock `GET /lookup-tax/{tax_number}` végpont lecserélése valós [Opten REST API](https://www.opten.hu/) hívásra. A frontend [`CompanyOnboardingView.vue`](frontend/src/views/organization/CompanyOnboardingView.vue:654) `lookupTaxNumber()` függvénye már készen áll a valós adatok fogadására — csak a backend oldali integráció hiányzik.
|
||||
|
||||
## Masterbook 2.0 Illeszkedés
|
||||
- **DDD Séma:** `identity.organizations` — a cégadatok automatikus kitöltése csökkenti a hibalehetőséget.
|
||||
- **Geo-logika:** Csak magyar (HU) adószámok esetén aktív. Más országok esetén a mock marad, vagy külön integráció később.
|
||||
|
||||
---
|
||||
|
||||
## Érintett Fájlok és Műveletek
|
||||
|
||||
| # | Fájl | Művelet | Leírás |
|
||||
|---|------|---------|--------|
|
||||
| 1 | [`backend/app/core/config.py`](backend/app/core/config.py:11) | ✏️ Módosítás | `OPTEN_API_KEY`, `OPTEN_API_BASE_URL`, `OPTEN_CACHE_TTL` hozzáadása a `Settings` osztályhoz |
|
||||
| 2 | [`backend/app/services/opten_service.py`](backend/app/services/opten_service.py) | ➕ **ÚJ fájl** | `OptenService` osztály: `httpx.AsyncClient` + `tenacity` retry logika |
|
||||
| 3 | [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py) | ➕ **ÚJ fájl** | `TaxLookupResponse` Pydantic séma |
|
||||
| 4 | [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:125) | ✏️ Módosítás | Mock végpont → valós `OptenService.lookup_by_tax_number()` hívás |
|
||||
| 5 | [`backend/requirements.txt`](backend/requirements.txt:17) | 🟢 **Nincs teendő** | `httpx` már szerepel. `tenacity` opcionális (retry logikához) |
|
||||
| 6 | [`frontend/src/views/organization/CompanyOnboardingView.vue`](frontend/src/views/organization/CompanyOnboardingView.vue:654) | 🟢 **Már kész** | `lookupTaxNumber()` teljes mértékben használható |
|
||||
|
||||
---
|
||||
|
||||
## Adatmodell — TaxLookupResponse
|
||||
|
||||
```python
|
||||
# backend/app/schemas/organization.py
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
class TaxLookupResponse(BaseModel):
|
||||
full_name: str
|
||||
name: str
|
||||
display_name: Optional[str] = None
|
||||
address_zip: str
|
||||
address_city: str
|
||||
address_street_name: str
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: str
|
||||
legal_form: Optional[str] = None # pl. "Kft.", "Bt.", "Zrt."
|
||||
status: Optional[str] = None # pl. "active", "dissolved"
|
||||
```
|
||||
|
||||
### Frontend Kompatibilitás
|
||||
A fenti mezők teljes mértékben lefedik a frontend [`CompanyOnboardingView.vue:654-684`](frontend/src/views/organization/CompanyOnboardingView.vue:654) által várt mezőket. Az új `legal_form` és `status` mezők a frontend `tax_lookup_result` objektumban tárolódhatnak későbbi felhasználásra.
|
||||
|
||||
---
|
||||
|
||||
## Service Réteg — OptenService
|
||||
|
||||
### Osztály Felépítése
|
||||
|
||||
```python
|
||||
# backend/app/services/opten_service.py
|
||||
import logging
|
||||
from typing import Optional
|
||||
import httpx
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OptenService:
|
||||
def __init__(self, api_key: str, base_url: str = "https://api.opten.hu/v2"):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers={"X-Api-Key": self.api_key, "Accept": "application/json"},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=5))
|
||||
async def lookup_by_tax_number(self, tax_number: str) -> dict:
|
||||
"""Lekérdezi az Opten API-t adószám alapján."""
|
||||
normalized = self._normalize_tax_number(tax_number)
|
||||
response = await self.client.get(f"/company/{normalized}")
|
||||
response.raise_for_status()
|
||||
return self._map_opten_response(response.json())
|
||||
|
||||
def _normalize_tax_number(self, raw: str) -> str:
|
||||
"""Csak számjegyeket tart meg (8 vagy 10 vagy 11 karakter)."""
|
||||
digits = "".join(c for c in raw if c.isdigit())
|
||||
# HU adószám: 8 számjegy (egyéni) vagy 10-11 számjegy (cég, áfával)
|
||||
return digits[:11]
|
||||
|
||||
def _map_opten_response(self, data: dict) -> dict:
|
||||
"""Opten API válasz → TaxLookupResponse formátumba."""
|
||||
return {
|
||||
"full_name": data.get("cegnev", ""),
|
||||
"name": data.get("rovitett_cegnev", data.get("cegnev", "")),
|
||||
"display_name": data.get("rovitett_cegnev"),
|
||||
"address_zip": data.get("iranyitoszam", ""),
|
||||
"address_city": data.get("telepules", ""),
|
||||
"address_street_name": data.get("kozt_nev", ""),
|
||||
"address_street_type": data.get("kozt_jelleg", ""),
|
||||
"address_house_number": str(data.get("hazszam", "")),
|
||||
"legal_form": data.get("cegforma", ""),
|
||||
"status": data.get("ceg_statusz", ""),
|
||||
}
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
```
|
||||
|
||||
### Gráciális Fallback
|
||||
Ha az `OPTEN_API_KEY` nincs beállítva (üres string), a végpont NEM dob hibát, hanem visszaadja a mock adatokat:
|
||||
|
||||
```python
|
||||
# backend/app/api/v1/endpoints/organizations.py
|
||||
@router.get("/lookup-tax/{tax_number}", response_model=TaxLookupResponse)
|
||||
async def lookup_tax_number(tax_number: str, db: AsyncSession = Depends(get_db)):
|
||||
settings = get_settings()
|
||||
if not settings.OPTEN_API_KEY:
|
||||
logger.warning("OPTEN_API_KEY not set — returning mock data")
|
||||
return await _mock_lookup(tax_number)
|
||||
service = OptenService(settings.OPTEN_API_KEY, settings.OPTEN_API_BASE_URL)
|
||||
try:
|
||||
result = await service.lookup_by_tax_number(tax_number)
|
||||
return result
|
||||
except httpx.HTTPStatusError as e:
|
||||
return await _handle_opten_error(e)
|
||||
finally:
|
||||
await service.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Geo-Logika
|
||||
|
||||
| Ország | Forrás | Státusz |
|
||||
|--------|--------|---------|
|
||||
| Magyarország (HU) | Opten REST API | ✅ Ezen terv |
|
||||
| Más EU országok | Mock (később: saját provider) | ⏳ Tervezett |
|
||||
|
||||
Csak magyar adószámok esetén aktív az Opten hívás. A `_normalize_tax_number()` metódus alapján: ha a szám nem felel meg a HU formátumnak (8-11 számjegy), a végpont mock adatokat ad vissza.
|
||||
|
||||
---
|
||||
|
||||
## Hibakezelési Mátrix
|
||||
|
||||
| Opten Hiba | HTTP Státusz | Felhasználói Üzenet |
|
||||
|------------|-------------|---------------------|
|
||||
| 404 — Nincs ilyen cég | 404 | "Nem található cég ezzel az adószámmal." |
|
||||
| 403 — Rossz API kulcs | 500 | "Belső szolgáltatói hiba. Kérjük, próbáld később." |
|
||||
| 429 — Rate limit | 429 | "Túl sok lekérdezés. Kérjük, várj egy percet." |
|
||||
| 422 — Érvénytelen adószám | 422 | "Érvénytelen adószám formátum." |
|
||||
| 504 — Gateway timeout | 504 | "A szolgáltató nem válaszol. Próbáld újra később." |
|
||||
| Ismeretlen hiba | 502 | "Váratlan hiba történt. Próbáld újra később." |
|
||||
|
||||
### `_handle_opten_error()` pszeudokód
|
||||
```python
|
||||
async def _handle_opten_error(e: httpx.HTTPStatusError) -> dict:
|
||||
status_map = {
|
||||
404: (404, "Nem található cég ezzel az adószámmal."),
|
||||
403: (500, "Belső szolgáltatói hiba."),
|
||||
429: (429, "Túl sok lekérdezés. Várj egy percet."),
|
||||
422: (422, "Érvénytelen adószám formátum."),
|
||||
504: (504, "A szolgáltató nem válaszol."),
|
||||
}
|
||||
http_code, msg = status_map.get(e.response.status_code, (502, "Váratlan hiba."))
|
||||
raise HTTPException(status_code=http_code, detail=msg)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Biztonsági Megfontolások
|
||||
|
||||
| # | Intézkedés | Leírás |
|
||||
|---|-----------|--------|
|
||||
| 1 | `.env`-ben tartás | `OPTEN_API_KEY` csak a `.env` fájlban, SOHOHA hardcode-olva |
|
||||
| 2 | Rate limiting | Nginx/API Gateway szintű korlátozás a `/lookup-tax/` végpontra |
|
||||
| 3 | Audit log | Minden Opten hívás naplózása: tax_number, timestamp, sikeresség |
|
||||
| 4 | Cache | `OPTEN_CACHE_TTL` (alapértelmezett: 3600s) — redis cache a gyakori ismétlődésekhez |
|
||||
| 5 | Adatvédelem | A visszaadott adatok csak a szervezet létrehozásához használhatók |
|
||||
|
||||
---
|
||||
|
||||
## Teszt Terv
|
||||
|
||||
### Unit tesztek
|
||||
| Teszt | Leírás |
|
||||
|-------|--------|
|
||||
| `test_normalize_tax_number` | 8, 10, 11 jegyű, kötőjeles, szóközös bemenetek |
|
||||
| `test_map_opten_response` | Opten JSON → TaxLookupResponse mezőtérkép |
|
||||
| `test_mock_fallback` | Ha nincs API kulcs, mock adat jön |
|
||||
| `test_error_mapping` | Minden hibaeset lefedése |
|
||||
|
||||
### Integrációs tesztek
|
||||
```bash
|
||||
# Teszt élő Opten API-val (ha van API kulcs)
|
||||
docker compose exec sf_api python3 -c "
|
||||
from app.services.opten_service import OptenService
|
||||
import asyncio
|
||||
async def test():
|
||||
s = OptenService('teszt_kulcs')
|
||||
result = await s.lookup_by_tax_number('12345678')
|
||||
print(result)
|
||||
await s.close()
|
||||
asyncio.run(test())
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Végrehajtási Terv (6 Kártya)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[1. Config: OPTEN_API_KEY] --> B[2. Séma: TaxLookupResponse]
|
||||
A --> C[3. Service: OptenService]
|
||||
B --> D[4. Endpoint: Mock → Valós]
|
||||
C --> D
|
||||
D --> E[5. Tesztek]
|
||||
E --> F[6. Dokumentáció]
|
||||
```
|
||||
|
||||
| # | Kártya Név | Függőség | Becsült Méret |
|
||||
|---|-----------|----------|---------------|
|
||||
| 1 | Config: `OPTEN_API_KEY` hozzáadása | Nincs | S (< 10 sor) |
|
||||
| 2 | Séma: `TaxLookupResponse` Pydantic | 1 | S (< 20 sor) |
|
||||
| 3 | Service: `OptenService` osztály | Nincs | M (~60 sor) |
|
||||
| 4 | Endpoint: Mock → valós hívás | 2, 3 | S (~20 sor) |
|
||||
| 5 | Tesztek (unit + smoke) | 4 | M (~50 sor) |
|
||||
| 6 | Dokumentáció frissítése | 5 | S (< 20 sor) |
|
||||
|
||||
---
|
||||
|
||||
## Kockázatok
|
||||
- **Opten API kulcs beszerzése** — a fejlesztés mock módban is végezhető.
|
||||
- **Opten API változás** — a `_map_opten_response()` metódus könnyen adaptálható.
|
||||
- **Éles rate limit** — szükség esetén Opten előfizetés frissítése.
|
||||
Reference in New Issue
Block a user