céges meghívó kezelése,
This commit is contained in:
431
.roo/history.md
431
.roo/history.md
@@ -19,209 +19,362 @@ Backend SQL hibák javítása a GET /admin/users végponton (Address outerjoin,
|
||||
- **Sticky Bulk Action Bar:** A tömeges műveletek sáv (`BulkActionBar`) `sticky` pozícionálást kapott, hogy görgetéskor is látszódjon
|
||||
- **Clear/X gomb:** A keresőmezőbe egy `X` gomb került, ami egy kattintással törli a keresési feltételt
|
||||
|
||||
## 2026-06-16 - Fix 404 API Router & Redesign Dashboard Launcher Card
|
||||
## 2026-06-17 - P0 UI Card Cleanup & Custom Tag 500 Fix
|
||||
|
||||
### 🎯 Cél
|
||||
A Dashboard Service Finder kártya (Card 3) átalakítása elegáns, kétgombos indítópulttá (Launcher), valamint a providers API 404 hiba kivizsgálása és javítása.
|
||||
Három kritikus hiba javítása: (1) "Nincs kategória" fantom badge eltüntetése a provider kártyákról, (2) Telefon/Weboldal ikonok megjelenítése a kártyákon, (3) 500-as hiba javítása custom tag mentéskor.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. BACKEND AUDIT - [`api.py`](backend/app/api/v1/api.py:38):**
|
||||
- Ellenőrizve: `providers` router regisztrálva van (`include_router(providers.router, prefix="/providers")`)
|
||||
- Ellenőrizve: providers végpontokon NINCS trailing slash (`/categories`, `/search`, `/quick-add`)
|
||||
- **Route-ok élőben is ellenőrizve:** `GET /api/v1/providers/categories`, `GET /api/v1/providers/search`, `POST /api/v1/providers/quick-add` mind aktívak
|
||||
**1. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:367):**
|
||||
- **"Nincs kategória" javítás:** A `v-if="provider.category"` feltétel helyett `v-if="provider.categories && provider.categories.length > 0"` lett beállítva. Ha a `categories` array üres, de a régi `category` string létezik, azt használja. Csak ha egyik sincs, akkor jelenik meg a "Nincs kategória" felirat.
|
||||
- **Telefon/Web ikonok:** Az address blokk után egy új contact info sor került be, amely a `contact_phone` mezőhöz telefon ikont + linket (`tel:`), a `website` mezőhöz weboldal ikont + linket (`target="_blank"`) jelenít meg.
|
||||
- **TypeScript interface bővítés:** A `ProviderSearchResult` interfész kibővítve a hiányzó mezőkkel: `contact_phone`, `contact_email`, `website`, `tags`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`.
|
||||
|
||||
**2. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:223):**
|
||||
- `fetchCategories()` már rendelkezik try/catch blokkal, 11 hardcoded fallback kategóriával
|
||||
- Nincs szükség módosításra
|
||||
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:661):**
|
||||
- **`_slugify()` függvény:** Új segédfüggvény, amely biztonságos slug-ot generál magyar ékezetes karakterekből (á→a, é→e, í→i, ó→o, ö→o, ő→o, ú→u, ü→u, ű→u), majd nem-alfanumerikus karaktereket alulvonásra cserél.
|
||||
- **`_create_new_tags()` javítás:**
|
||||
- `try-except` blokkba csomagolva a teljes beszúrási logika
|
||||
- `logger.error(..., exc_info=True)` hibanaplózás hozzáadva
|
||||
- `parent_id=None` és `path=None` explicit beállítva
|
||||
- A kulcs generálás most a `_slugify()` függvényt használja
|
||||
- Egyetlen hibás címke nem szakítja meg a teljes folyamatot (`continue`)
|
||||
|
||||
**3. FRONTEND - [`DashboardView.vue`](frontend/src/views/DashboardView.vue:187):**
|
||||
- **Card 3 teljes átalakítása:** A 3-lépéses kontextuális kereső űrlap (input, 2 dropdown, keresés gomb) ELTÁVOLÍTVA
|
||||
- **Launcher dizájn:** Két nagy gomb, szépen formázva:
|
||||
- `🔍 Szervizek Keresése` — nagy, zöld gradient gomb, `isSearchModalOpen` modalt nyit
|
||||
- `➕ Új Szolgáltató Rögzítése` — szekunder, dashed border gomb, `isSfQuickAddOpen` modalt nyit (meglévő ProviderQuickAddModal)
|
||||
- **Search Modal (Placeholder):** Teleportált modál, benne: `🔍` ikon, "Részletes kereső térképpel hamarosan..." szöveg, fejlesztés alatt státusz
|
||||
- Régi `sfSearchLocation`, `sfSearchCategory`, `sfSearchVehicleId`, `sfCategories`, `fetchSfCategories()`, `onSfSearch()` eltávolítva
|
||||
### ✅ Eredmény
|
||||
- "Nincs kategória" fantom badge megszűnt: a feltétel most a `categories` tömböt ellenőrzi
|
||||
- Telefon és weboldal linkek megjelennek a kártyákon (ha vannak)
|
||||
- Custom tag mentés 500 helyett try-except blokkban fut, hibanaplózással
|
||||
- Python szintaxis ellenőrzés: OK
|
||||
|
||||
## 2026-06-17 - P0 Bugfix: Provider Data Persistence & Schema Alignment
|
||||
## 2026-06-17 - P0 UI Overhaul: Plus Code, Universal Categories, Detail Modal
|
||||
|
||||
### 🎯 Cél
|
||||
Kritikus adatperzisztencia hiba javítása a Service Finder Provider rendszerében. A frontend edit modal nem hívott backend API-t, így a szerkesztett adatok elvesztek. Emellett a contact mezők (phone, email, website, tags) nem kerültek perzisztálásra a quick-add során, és a search nem adta vissza ezeket.
|
||||
Három területet fed le: (1) Plus Code integráció a backendben és adatbázisban, (2) Univerzális kategóriák megjelenítése a ProviderEditModal és QuickAdd modalokban, (3) ProviderDetailModal gazdag újraírása kategória chipekkel, kontakt grid-del és navigációs gombbal.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py:1) (NEW):**
|
||||
- `ProviderQuickAddIn`: `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva
|
||||
- `ProviderSearchResult`: `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva
|
||||
- `ProviderUpdateIn` (NEW): name, city, address_zip, street, contact_phone, contact_email, website, tags
|
||||
- `ProviderUpdateResponse` (NEW): id, name, status, message
|
||||
**1. BACKEND - Plus Code mező:**
|
||||
- [`Organization`](backend/app/models/marketplace/organization.py:86) model: `plus_code: Mapped[Optional[str]] = mapped_column(String(20))`
|
||||
- [`ProviderSearchResult`](backend/app/schemas/provider.py:115): `plus_code: Optional[str] = None`
|
||||
- [`ProviderQuickAddIn`](backend/app/schemas/provider.py:148): `plus_code: Optional[str] = Field(None, max_length=20)`
|
||||
- [`ProviderUpdateIn`](backend/app/schemas/provider.py:180): `plus_code: Optional[str] = Field(None, max_length=20)`
|
||||
- [`provider_service.py`](backend/app/services/provider_service.py:262): plus_code hozzáadva a search SELECT-hez, quick_add Organization creation-höz, és update null-safety checkhez
|
||||
- `sync_engine` sikeresen lefuttatva, a `plus_code` oszlop létrejött
|
||||
|
||||
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:486):**
|
||||
- `quick_add_provider()`: contact mezők mentése ServiceProfile-ba
|
||||
- `search_providers()`: LEFT JOIN ServiceProfile, contact mezők visszaadása
|
||||
- `update_provider()` (NEW): Organization + ServiceProfile atomi frissítése
|
||||
**2. FRONTEND - Univerzális kategóriák:**
|
||||
- [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue): `allLevel1Categories` computed property, amely a checked Level 0 szülők Level 1 gyermekeit gyűjti össze deduplikálva
|
||||
- [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue): Ugyanaz a `allLevel1Categories` logika
|
||||
- Plus Code input mező hozzáadva mindkét modal "Alapadatok" tabjához
|
||||
|
||||
**3. BACKEND - [`providers.py`](backend/app/api/v1/endpoints/providers.py:134):**
|
||||
- `PUT /providers/{id}` végpont hozzáadva (authentikált, hibakezeléssel)
|
||||
**3. FRONTEND - Detail Modal Rework:**
|
||||
- [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue): Teljes újraírás
|
||||
- Kategória chipek (szintenként eltérő színnel: Level0=lila, Level1=kék, Level2=teal, Level3=szürke)
|
||||
- Kontakt grid: Telefon (tel: link), Email (mailto: link), Weboldal (_blank, external icon)
|
||||
- Helyszín szekció: cím + plus_code + "Navigáció" gomb (Google Maps URL)
|
||||
- Hiányzó adatok esetén i18n fallback szövegek
|
||||
|
||||
**4. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue:191):**
|
||||
- **CRITICAL FIX**: `handleSave()` most `api.put()`-et hív, nem csak eventet emitál
|
||||
- `@save` → `@saved` (past tense, API call után)
|
||||
- `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők támogatása
|
||||
- `source` kivéve a payload-ból (backend-only)
|
||||
**4. i18n:**
|
||||
- [`en.ts`](frontend/src/i18n/en.ts): plus_code és detail modal kulcsok
|
||||
- [`hu.ts`](frontend/src/i18n/hu.ts): plus_code és detail modal magyar fordítások
|
||||
|
||||
**5. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:253):**
|
||||
- Telefon, email, weboldal, címkék mezők hozzáadva a formhoz
|
||||
- Tag management (vessző/pontosvessző parsing, remove gomb)
|
||||
- Payload bővítése contact mezőkkel
|
||||
### ✅ Eredmény
|
||||
- Backend Python import ellenőrzés: OK (provider.py, provider_service.py, providers.py endpoints)
|
||||
- Frontend Vite build: OK (87 modules, 6.07s)
|
||||
- Adatbázis sync_engine: OK (plus_code oszlop létrejött)
|
||||
|
||||
**6. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:375):**
|
||||
- `@save` → `@saved` event binding
|
||||
- `handleEditSaved()`: mentés után automatikus keresés újrafuttatás
|
||||
## 2026-06-17 - P0 Triage Fix: Data Persistence & UNION ALL Column Mismatch
|
||||
|
||||
### 🎯 Cél
|
||||
Két kritikus hiba javítása a Provider Search/Update folyamatban:
|
||||
1. **Data Persistence Black Hole**: Organization ID 4859 (Shell Dunakeszi) `plus_code` és `contact_phone` mezői nem perzisztáltak mentés után
|
||||
2. **UNION ALL 500 error**: `CompoundSelect must have identical numbers of columns` hiba a search végponton
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:445):**
|
||||
- `plus_code` hozzáadva a `ProviderSearchResult` konstruktorhoz (`getattr(row, "plus_code", None)`) - a mező SELECT-ben volt, de a Python objektumba nem került át
|
||||
- `plus_code` oszlop (`literal(None).label("plus_code")`) hozzáadva a `UNION ALL` második (`staging_stmt`) és harmadik (`crowd_stmt`) ágához, hogy a 15 oszlopszám konzisztens legyen
|
||||
- **P0 CRITICAL BUGFIX**: Ha nincs `ServiceProfile` rekord az Organization-höz, a `update_provider` most létrehozza azt (fingerprint + location + contact adatokkal), ahelyett hogy csak logolná a hiányt és eldobná a contact/category adatokat
|
||||
- `ServiceStatus` import hozzáadva a provider_service.py-hoz
|
||||
|
||||
**2. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue:617):**
|
||||
- `allLevel1Categories` szétbontva `vehicleLevel1Categories`-re (Level 0 checkboxoktól függő) és `universalLevel1Categories`-re (mindig látható, `categoryTree.value.filter(c => c.level === 1)`)
|
||||
- Új UI blokk "Iparágak és Univerzális Szolgáltatások" címmel, amber színű dashed border-rel
|
||||
|
||||
### ✅ Verifikáció
|
||||
- `sync_engine` lefuttatva: **1061 elem OK, 0 javítás, 0 shadow data** - rendszer tökéletesen szinkronban
|
||||
- `plus_code=8FVX+3W`, `contact_phone=+36 20 123 4567`, `contact_email=shell.dunakeszi@example.com` sikeresen mentve és visszaolvasva ID 4859-hez
|
||||
- API `/api/v1/providers/search` hibátlanul fut (nincs 500-as UNION ALL hiba)
|
||||
- Adatbázis szinten ellenőrizve: `fleet.organizations.plus_code` és `marketplace.service_profiles.contact_phone/contact_email` helyesen perzisztálva
|
||||
|
||||
## 2026-06-17 - P1 Critical Align: Atomizált címmezők a Provider sémákban
|
||||
## 2026-06-17 - P0 Ownership & Access Control Fix: OrganizationMember + owner_id
|
||||
|
||||
### 🎯 Cél
|
||||
A `street: Optional[str]` mező eltávolítása és helyette atomizált címmezők (`address_street_name`, `address_street_type`, `address_house_number`) bevezetése a Pydantic sémákban, backend service-ben és frontend űrlapokon. A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek.
|
||||
Két P0 kritikus hiba javítása a Provider Quick-Add és Update folyamatban:
|
||||
1. **Ownerless Providers**: A `quick_add_provider` `owner_id=None`-t használt, így az újonnan létrehozott szolgáltatóknak nem volt tulajdonosa
|
||||
2. **Missing OrganizationMember**: A 6-lépéses folyamat 5. lépésnél megállt - nem jött létre `OrganizationMember` rekord, így senki sem volt kapcsolatban a céggel
|
||||
3. **No Access Control**: A `update_provider` végpont bárki által hívható volt - nem ellenőrizte, hogy a hívó user jogosult-e szerkeszteni az adott szervezetet
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py):**
|
||||
- `street` mező ELTÁVOLÍTVA a `ProviderQuickAddIn` és `ProviderUpdateIn` sémákból
|
||||
- `address_street_name`, `address_street_type`, `address_house_number` mezők HOZZÁADVA mindkét sémához
|
||||
- `ProviderSearchResult` bővítve az atomizált címmezőkkel
|
||||
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:468):**
|
||||
- **`owner_id=None` → `owner_id=user_id`** (531. sor): A létrehozott Organization most a hitelesített user-hez tartozik
|
||||
- **6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA** (585-600. sor): Teljesen új blokk a gamifikáció előtt:
|
||||
```python
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=user_id,
|
||||
role=OrgUserRole.OWNER,
|
||||
is_permanent=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(member)
|
||||
await db.flush()
|
||||
```
|
||||
- **🔐 ACCESS CONTROL ELLENŐRZÉS** (841-863. sor): A `update_provider` függvény elején új blokk, amely ellenőrzi, hogy a user rendelkezik-e OWNER vagy ADMIN szerepkörrel az adott Organization-ben. Ha nem, `PermissionError`-t dob.
|
||||
|
||||
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py):**
|
||||
- `quick_add_provider()`: `data.street` → `data.address_street_name`, `data.address_street_type`, `data.address_house_number` az Organization és Branch táblákban
|
||||
- `update_provider()`: ugyanez az atomizált címkezelés
|
||||
- `search_providers()`: az org SELECT most már tartalmazza az `address_street_name`, `address_street_type`, `address_house_number` mezőket
|
||||
- A Branch létrehozásánál a `street_name`, `street_type`, `house_number` mezők külön-külön töltődnek
|
||||
|
||||
**3. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue):**
|
||||
- A régi egyesített "Cím (Utca, házszám)" mező helyett 3 külön mező: Utca neve (text), Közterület jellege (select/dropdown 15 opcióval), Házszám (text)
|
||||
- Payload az új atomizált kulcsokkal megy a backend felé
|
||||
|
||||
**4. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue):**
|
||||
- Ugyanaz a 3 mezős szétbontás, a form populate az atomizált mezőkből történik
|
||||
- Payload atomizált kulcsokkal
|
||||
|
||||
**5. FRONTEND - [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue):**
|
||||
- Az intelligens címösszefűzés (`formattedAddress` computed) a `provider.address_street_name + ' ' + provider.address_street_type + ' ' + provider.address_house_number` alapján történik
|
||||
- `hasAddress` computed ellenőrzi az atomizált mezők meglétét
|
||||
**2. BACKEND - [`providers.py`](backend/app/api/v1/endpoints/providers.py:311):**
|
||||
- `PermissionError` → `HTTPException(403)` kezelés hozzáadva a `update_service_provider` végponthoz
|
||||
|
||||
### ✅ Verifikáció
|
||||
- `sync_engine` lefuttatva: **1061 elem OK, 0 javítás, 0 shadow data** - rendszer tökéletesen szinkronban
|
||||
- Python syntax check: minden fájl szintaktikailag helyes
|
||||
- `owner_id=2` (admin user) sikeresen beállítva a létrehozott Organization-ben
|
||||
- `OrganizationMember(user_id=2, role=OWNER)` rekord sikeresen létrejött
|
||||
- Admin user sikeresen frissítheti a provider adatokat (200 OK)
|
||||
- Érvénytelen token esetén 401 Unauthorized
|
||||
- Docker konténer újraépítve: `docker compose up -d --build sf_api`
|
||||
- Tesztadatok (org 59, 60, 61) tisztítva
|
||||
|
||||
## 2026-06-17 - Provider Update & Search Fix Csomag (#264)
|
||||
## 2026-06-17 - P0 Critical Bugfix: Quick-Add Provider 422 category_id null
|
||||
|
||||
### 🎯 Cél
|
||||
Két kritikus hiba javítása a provider endpointokban:
|
||||
1. **PUT /providers/{id} → 404**: A konténer nem volt újraindítva a providers modul kódváltoztatásai után
|
||||
2. **GET /providers/search → 500**: `.astext` hiba JSONB subscripten + UNION oszlopszám mismatch
|
||||
3. **Multi-source update**: Az `update_provider` csak Organization-ben keresett, de a search 3 forrást használ
|
||||
A `POST /api/v1/providers/quick-add` endpoint 422-es hibát dobott, amikor a frontend `category_id: null`-t küldött. A 4-level kategória rendszer bevezetésével a frontend már a `category_ids` tömböt használja, de a backend `ProviderQuickAddIn` séma továbbra is kötelező `int`-ként várta a `category_id`-t.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. BACKEND - [`provider_service.py:207`](backend/app/services/provider_service.py:207):**
|
||||
- `.astext` → `cast()` javítás: `Organization.external_integration_config["source"].astext` → `cast(Organization.external_integration_config["source"], String)`
|
||||
- **Root cause**: JSON oszlop subscript-je `BinaryExpression`-t ad vissza, amelyen nincs `.astext`
|
||||
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py:135):**
|
||||
- `ProviderQuickAddIn.category_id` típusa `int` → `Optional[int] = None` (opcionális)
|
||||
- Új mezők: `category_ids: Optional[List[int]]` és `new_tags: Optional[List[str]]` a 4-level kategória rendszer támogatásához
|
||||
|
||||
**2. BACKEND - [`provider_service.py:232-274`](backend/app/services/provider_service.py:232):**
|
||||
- UNION oszlopszám mismatch javítva: staging és crowd SELECT-ekhez hozzáadva a hiányzó `contact_phone`, `contact_email`, `website`, `specialization_tags` mezők (14 oszlopra egységesítve)
|
||||
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:468):**
|
||||
- `quick_add_provider()` most már kezeli a `category_id=None` esetet (kihagyja az elsődleges kategória lekérdezést)
|
||||
- A `category_ids` és `new_tags` mezők feldolgozása: ServiceExpertise kapcsolatok létrehozása a 4-level kategória rendszerből
|
||||
- Deduplikáció: az összes kategória ID egyesítve, duplikátumok eltávolítva
|
||||
|
||||
**3. BACKEND - [`provider_service.py:477-622`](backend/app/services/provider_service.py:477):**
|
||||
- Multi-source update logika: `update_provider()` most már mindhárom forrást támogatja:
|
||||
1. `fleet.organizations` (verified orgs) - közvetlen frissítés
|
||||
2. `marketplace.service_staging` (robot adatok) - migrálás Organization-be
|
||||
3. `marketplace.service_providers` (crowdsourced) - migrálás Organization-be
|
||||
**3. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:781):**
|
||||
- `phone` → `contact_phone` és `email` → `contact_email` mezőnevek javítva (illeszkedés a backend Pydantic sémához)
|
||||
|
||||
**4. INFRA - [`pre_start.sh`](backend/app/scripts/pre_start.sh):**
|
||||
- Dokumentálva: a `uvicorn` `--reload` nélkül fut, kódváltoztatás után `docker compose restart sf_api` szükséges
|
||||
### ✅ Teszt eredmény
|
||||
- A pontosan reprodukált hiba (Bokebo Kft., category_id=null, category_ids=[149]) **HTTP 201** választ ad
|
||||
- Adatbázisban: Organization létrejött, ServiceProfile létrejött, ServiceExpertise kapcsolat a 149-es kategóriával létrejött
|
||||
- Gamification: 500 pont jóváírva
|
||||
|
||||
**5. BACKEND - [`provider_service.py:578-596`](backend/app/services/provider_service.py:578):**
|
||||
- **Adatvédelmi javítás (2026-06-17):** A címmezők (`address_city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`) most már **csak akkor íródnak felül**, ha a frontend explicit nem-`null` értéket küld. Ez megakadályozza, hogy a meglévő címadatok véletlenül `null`-ra állítódjanak, amikor a felhasználó csak más mezőket szerkeszt.
|
||||
- **Trigger:** A Gitea kártya visszautasításra került (`denied` státusz) a felhasználó által: *"az adatok tárolása minden esetben bontottan történjen meg és ha hiányzik valamelyik az alap cím tárolási adatból akkor vissza kell tenni."*
|
||||
## 2026-06-17: Bug investigation - Quick-add provider cégek megjelennek a Cégeim menüben
|
||||
|
||||
### ✅ Verifikáció
|
||||
- **PUT /providers/58**: 200 OK ✅
|
||||
- **PUT /providers/58 (partial update - csak zip)**: 200 OK ✅ (többi mező nem nullázódik)
|
||||
- **GET /providers/search?q=Dunakeszi**: 200 OK ✅ (2 provider)
|
||||
- **GET /providers/categories**: 200 OK ✅ (11 categories)
|
||||
- **Login**: 200 OK ✅
|
||||
**Kártya:** #266
|
||||
**Szerepkör:** Architect
|
||||
**Állapot:** Tervezés kész
|
||||
|
||||
## 2026-06-17 - i18n: Hiányzó provider címmező fordítások hozzáadása
|
||||
### Felfedezett hibák
|
||||
|
||||
1. **`GET /organizations/my` endpoint** (`backend/app/api/v1/endpoints/organizations.py:183-186`): Nincs `org_type` szűrés, minden szervezetet visszaad, ahol a user tag az `OrganizationMember` táblában.
|
||||
|
||||
2. **`HeaderCompanySwitcher.vue:142-146`**: A `companyOrganizations` computed property csak az `individual` típust szűri ki, a `service_provider` típusú szervezeteket nem.
|
||||
|
||||
3. **`quick_add_provider()`** (`backend/app/services/provider_service.py:638-646`): Létrehoz `OrganizationMember`-et `role=OWNER`-rel, ami miatt a `GET /my` visszaadja a szolgáltatót.
|
||||
|
||||
### Javítási terv
|
||||
|
||||
1. `organizations.py:183-186`: `org_type` szűrés hozzáadása (csak `business`, `fleet_owner`, `individual`)
|
||||
2. `organizations.py:193-206`: `owner_id` mező hozzáadása a response-hoz
|
||||
3. `HeaderCompanySwitcher.vue:142-146`: `service_provider` és `service` típusok kiszűrése
|
||||
4. Adatbázis audit: hiányzó `owner_id`-k ellenőrzése a régi service_providereknél
|
||||
|
||||
### Kapcsolódó fájlok
|
||||
- `plans/logic_spec_provider_cegeim_bugfix.md` - Részletes logic_spec
|
||||
|
||||
## 2026-06-17: Bugfix implementáció - Quick-add provider cégek a Cégeim menüben (#266)
|
||||
|
||||
**Szerepkör:** Code (Fast Coder)
|
||||
**Állapot:** Implementálva és tesztelve
|
||||
|
||||
### Végrehajtott módosítások
|
||||
|
||||
1. **`backend/app/api/v1/endpoints/organizations.py:183-186`** — `GET /my` endpoint:
|
||||
- Hozzáadva `org_type` szűrés: csak `business`, `fleet_owner`, `individual` típusú szervezetek visszaadása
|
||||
- `owner_id` mező hozzáadva a response dict-hez
|
||||
|
||||
2. **`frontend/src/components/header/HeaderCompanySwitcher.vue:142-146`** — `companyOrganizations` computed:
|
||||
- `service_provider` és `service` típusok kiszűrése (biztonsági réteg)
|
||||
|
||||
### Verifikáció
|
||||
- ✅ `sync_engine.py` — 1065 elem OK, 0 hiba
|
||||
- ✅ Backend Python import check — OK
|
||||
- ✅ Frontend `npm run build` — 5.97s, 0 hiba
|
||||
|
||||
### 🔧 Javítás (v2) — 2026-06-17
|
||||
|
||||
**Probléma:** A whitelist (`in_([business, fleet_owner, individual])`) túl szigorú volt, kizárta a valódi cégeket (pl. `club` típus).
|
||||
|
||||
**Megoldás:**
|
||||
1. **`backend/app/api/v1/endpoints/organizations.py:193`** — `in_([...])` → `notin_([OrgType.service_provider, OrgType.service])`
|
||||
- Blacklist megközelítés: csak a service_provider és service típusokat szűrjük ki
|
||||
- Minden más típus (business, fleet_owner, individual, club, stb.) megjelenik
|
||||
|
||||
### Verifikáció
|
||||
- ✅ `sync_engine.py` — 1065 elem OK, 0 hiba
|
||||
- ✅ Backend Python import check — OK
|
||||
- ✅ `notin_` szintaxis ellenőrizve — OK
|
||||
|
||||
## 2026-06-17 - Garázsválasztó Bugfix: service_provider org-ok nem látszottak OWNER-nek sem
|
||||
|
||||
### 🎯 Cél
|
||||
A ProviderEditModal.vue 6 darab `provider.*` i18n kulcsa hiányzott mindkét nyelvi modulból (`hu.ts`, `en.ts`), így a felhasználói felületen a kulcsnevek (pl. `provider.streetNameLabel`) jelentek meg a lefordított szöveg helyett.
|
||||
A saját tulajdonú cégek (service_provider org_type) megjelenítése a garázsválasztó gombban, ha a felhasználó OWNER joggal rendelkezik.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. FRONTEND - [`hu.ts`](frontend/src/i18n/hu.ts:1300):**
|
||||
- `provider.streetNameLabel`: `'Utca neve'`
|
||||
- `provider.streetNamePlaceholder`: `'Pl. Egressy'`
|
||||
- `provider.streetTypeLabel`: `'Közterület jellege'`
|
||||
- `provider.streetTypePlaceholder`: `'Válassz típust...'`
|
||||
- `provider.houseNumberLabel`: `'Házszám'`
|
||||
- `provider.houseNumberPlaceholder`: `'Pl. 4'`
|
||||
**1. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:177-228):**
|
||||
- A `GET /my` végpont most már `select(Organization, OrganizationMember.role)`-t használ, hogy a felhasználó szerepkörét is lekérdezze
|
||||
- A `service_provider`/`service` típusú org-ok utólagos szűrése: csak akkor jelennek meg, ha a user role = `OWNER`
|
||||
- A válaszban új `user_role` mező került visszaadásra
|
||||
|
||||
**2. FRONTEND - [`en.ts`](frontend/src/i18n/en.ts:1300):**
|
||||
- `provider.streetNameLabel`: `'Street Name'`
|
||||
- `provider.streetNamePlaceholder`: `'e.g. Egressy'`
|
||||
- `provider.streetTypeLabel`: `'Street Type'`
|
||||
- `provider.streetTypePlaceholder`: `'Select type...'`
|
||||
- `provider.houseNumberLabel`: `'House Number'`
|
||||
- `provider.houseNumberPlaceholder`: `'e.g. 4'`
|
||||
**2. FRONTEND - [`HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue:144-158):**
|
||||
- A `companyOrganizations` computed property most már a `user_role` mezőt is figyelembe veszi
|
||||
- `service_provider`/`service` típus csak `user_role === 'OWNER'` esetén jelenik meg
|
||||
|
||||
**3. FRONTEND - [`organization.ts`](frontend/src/types/organization.ts:25-30):**
|
||||
- Új `user_role?: string` mező az `OrganizationItem` interfészben
|
||||
|
||||
### Verifikáció
|
||||
- ✅ Backend syntax check — OK
|
||||
- ✅ API teszt: 4 org visszatér (1 individual + 3 service_provider OWNER)
|
||||
- ✅ `user_role` mező jelen van a válaszban (ADMIN, OWNER)
|
||||
- ✅ Nincs regresszió: non-OWNER role esetén a service_provider org-ok ki vannak szűrve
|
||||
|
||||
## 2026-06-17: Root cause fix - quick_add_provider owner_id és role javítás (#266)
|
||||
|
||||
### 🔍 Felismert probléma
|
||||
A `quick_add_provider` függvény a crowdsourcingból felvett szolgáltatóknál `owner_id=user_id`-t állított be és `OWNER` szerepkörű tagot hozott létre. Ez hibás, mert:
|
||||
- A közösségi jelöléssel felvett cégeknek NINCS tulajdonosa (`owner_id=NULL`)
|
||||
- Aki felvesz egy céget a Service Finder-be, az NEM lesz a cég tulajdonosa
|
||||
- Az `OWNER` szerepkör miatt ezek a cégek megjelentek a "Cégeim" garázsválasztóban
|
||||
|
||||
### 🔧 Végrehajtott módosítások
|
||||
|
||||
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:541):**
|
||||
- `owner_id=user_id` → `owner_id=None` — crowdsourcingból felvett szolgáltatónak nincs tulajdonosa
|
||||
|
||||
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:641):**
|
||||
- `role=OrgUserRole.OWNER` → `role=OrgUserRole.ADMIN` — a felhasználó ADMIN jogot kap (szerkesztheti), de nem tulajdonos
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Mindkét i18n fájl szintaktikailag helyes (Node.js require sikeres)
|
||||
- A ProviderEditModal.vue összes `t('provider.*')` hívása le van fedve
|
||||
- ✅ Sync engine: séma konzisztens (1065 elem OK)
|
||||
- ✅ API teszt: `GET /api/v1/organizations/my` → 1 org (Test Company, individual)
|
||||
- ✅ service_provider org-ok (57, 58, 62) NEM jelennek meg a garázsválasztóban
|
||||
- ✅ A meglévő adatok (owner_id=2, role=OWNER) a régi kóddal jöttek létre, az új kód már helyesen hozza létre az új provider-eket
|
||||
|
||||
## 2026-06-17 - "Dunakeszi, Dunakeszi" duplikáció javítása + irányítószám megjelenítés
|
||||
## 2026-06-17 - P1 Garage Selector Implementation & OrganizationMember Audit
|
||||
|
||||
### 🎯 Cél
|
||||
A szervizkereső oldalon a kártyán "Dunakeszi, Dunakeszi" duplikált városnév jelent meg, mert a backend `address` mezője megegyezett a `city` mezővel. A részletes nézetből hiányzott az irányítószám.
|
||||
Dashboard "Garage Selector" (cégválasztó) pontosítása, hogy a bejelentkezett felhasználó azon Organization rekordokat lássa, ahol:
|
||||
a) Technikai tulajdonos (`owner_id`) VAGY
|
||||
b) Aktív tag (`OrganizationMember`)
|
||||
|
||||
Emellett az `OrganizationMember` tábla hiányzó oszlopainak pótlása és audit jelentés készítése.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:198):**
|
||||
- Az `address` mezőt `Organization.address_city.label("address")`-ről `func.concat(...)`-re változtattuk, ami az összes atomizált címmezőt (irányítószám, város, utca, közterület, házszám) fűzi össze.
|
||||
- Példa eredmény: `"2120 Dunakeszi, Egressy utca 4"` a korábbi `"Dunakeszi"` helyett.
|
||||
**1. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:198):**
|
||||
- `get_my_organizations` query javítva: `INNER JOIN` → `LEFT JOIN` + `OR` feltétel (`owner_id` VAGY `OrganizationMember.user_id`)
|
||||
- `DISTINCT` hozzáadva a duplikációk elkerülésére
|
||||
- `user_role` mező visszaadása a frontend számára
|
||||
- `_get_user_role()` segédfüggvény implementálva
|
||||
|
||||
**2. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:543):**
|
||||
- Új `formatCardAddress(provider)` metódus, ami a kártyán az atomizált címmezőkből építi fel a címet.
|
||||
- Fallback: ha nincs atomizált adat, a backend által összefűzött `address` mezőt használja.
|
||||
**2. BACKEND - [`organization.py`](backend/app/models/marketplace/organization.py:195):**
|
||||
- `OrganizationMember` modellhez hozzáadva: `status` (String(20)), `created_at`, `updated_at` oszlopok
|
||||
- Ezek az oszlopok hiányoztak az adatbázisból, de a kód már hivatkozott rájuk
|
||||
|
||||
**3. FRONTEND - [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue:237):**
|
||||
- A `formattedAddress` computed property most már tartalmazza az `address_zip` mezőt is.
|
||||
- Formátum: `"2120 Dunakeszi, Egressy utca 4"`
|
||||
**3. ADATBÁZIS - `sync_engine`:**
|
||||
- 3 hiányzó oszlop sikeresen hozzáadva a `fleet.organization_members` táblához
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Backend API: `GET /api/v1/providers/search?city=Dunakeszi` → `address` = `"2120 Dunakeszi, Egressy utca 4"` ✅
|
||||
- Backend API: Autónyíri Kft. (id=58) → `address_zip=2120`, `address_street_name=Egressy`, `address_street_type=utca`, `address_house_number=4` ✅
|
||||
- Frontend build: `npm run build` sikeres, 0 hiba ✅
|
||||
- Backend konténer újraindítva: `docker compose restart sf_api` ✅
|
||||
**4. DOKUMENTÁCIÓ - [`organization_member_audit_report_2026-06-17.md`](docs/organization_member_audit_report_2026-06-17.md):**
|
||||
- Részletes audit jelentés az OrganizationMember tábláról
|
||||
- Hiányzó API végpontok azonosítása (tagkezelés, meghívókezelés)
|
||||
- Javasolt javítási sorrend
|
||||
|
||||
## 2026-06-17 - Kártya fő szolgáltatás (category) mindig megjelenítése
|
||||
### ✅ Eredmény
|
||||
- `GET /api/v1/organizations/my` helyesen adja vissza a tulajdonosi és tagsági viszonyokat
|
||||
- `user_role` mező elérhető a frontend számára
|
||||
- Adatbázis séma konzisztens a kóddal
|
||||
- Audit jelentés elkészítve a docs mappában
|
||||
|
||||
## 2026-06-17 - P0 Garage Selector Fix & Team Management API Implementation
|
||||
|
||||
### 🎯 Cél
|
||||
A szervizkereső kártyákon a fő szolgáltatás (category) mindig látszódjon. Ha van, akkor a kategória neve, ha nincs, akkor egy szaggatott vonalú placeholder.
|
||||
A P0 Garage Selector hiba végleges javítása (a `GET /organizations/my` 500-as hibát dobott JSON oszlopok miatt) és a hiányzó Team Management API végpontok implementálása.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. FRONTEND - [`ServiceFinderView.vue:263`](frontend/src/views/ServiceFinderView.vue:263):**
|
||||
- A category badge `v-if="provider.category"` helyett `v-if`/`v-else` szerkezet:
|
||||
- Ha van category: `bg-sf-accent/10` háttér, `text-sf-accent` szín
|
||||
- Ha nincs: szaggatott vonalú (`border-dashed`) placeholder "Nincs kategória" / "No category"
|
||||
**1. BACKEND - [`organization.py`](backend/app/models/marketplace/organization.py:187-190):**
|
||||
- `OrganizationMember` modellhez hozzáadva: `invited_email` (String(255)) és `expires_at` (DateTime(timezone=True)) oszlopok
|
||||
- `OrgUserRole` enum szinkronizálva az adatbázisban lévő értékekkel: `OWNER, ADMIN, MANAGER, MEMBER, AGENT`
|
||||
- `default=OrgUserRole.DRIVER` → `default=OrgUserRole.MEMBER`
|
||||
|
||||
**2. FRONTEND - [`hu.ts:1237`](frontend/src/i18n/hu.ts:1237):**
|
||||
- `serviceFinder.noCategory`: `'Nincs kategória'`
|
||||
**2. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:199-218):**
|
||||
- **`GET /organizations/my` SQL javítás:** A `DISTINCT` a teljes Organization modellen JSON oszlopok miatt hibát dobott (`could not identify an equality operator for type json`)
|
||||
- **Megoldás:** Subquery approach: először csak az ID-kat szedjük DISTINCT-tel, majd a második query-ben `selectinload(Organization.members)`-szel töltjük be a teljes objektumokat
|
||||
- `selectinload` import hozzáadva a `sqlalchemy.orm`-ból
|
||||
|
||||
**3. FRONTEND - [`en.ts:1237`](frontend/src/i18n/en.ts:1237):**
|
||||
- `serviceFinder.noCategory`: `'No category'`
|
||||
**3. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:326-607):**
|
||||
- **`GET /{org_id}/members`** — Tagok listázása (user email + role + status)
|
||||
- **`POST /{org_id}/invitations`** — Meghívó küldése (invited_email, expires_at=+7 nap)
|
||||
- **`PATCH /{org_id}/members/{member_id}`** — Szerepkör módosítása (utolsó OWNER védelme)
|
||||
- **`DELETE /{org_id}/members/{member_id}`** — Tag eltávolítása / meghívó visszavonása
|
||||
- **`_check_org_admin_access()`** — Segédfüggvény: csak OWNER/ADMIN férhet hozzá
|
||||
- Pydantic modellek: `MemberResponse`, `MemberRoleUpdate`, `InvitationCreate`
|
||||
- Régi duplikált `POST /invitations` és `POST /invitations/{token}/accept` végpontok eltávolítva
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Frontend build: `npm run build` sikeres, 0 hiba ✅
|
||||
**4. ADATBÁZIS - `sync_engine`:**
|
||||
- 2 új oszlop (`invited_email`, `expires_at`) sikeresen hozzáadva a `fleet.organization_members` táblához
|
||||
|
||||
### ✅ Teszt eredmény
|
||||
- ✅ `GET /api/v1/organizations/my` → **200 OK** (1 org: Test Company, user_role: ADMIN)
|
||||
- ✅ `GET /api/v1/organizations/1/members` → **200 OK** (1 member: admin@profibot.hu)
|
||||
- ✅ `POST /api/v1/organizations/1/invitations` → **201 Created** (invited_email, expires_at, role=MANAGER)
|
||||
- ✅ `PATCH /api/v1/organizations/1/members/25` → **200 OK** (role MANAGER → ADMIN)
|
||||
- ✅ `DELETE /api/v1/organizations/1/members/25` → **200 OK** (invitation revoked)
|
||||
|
||||
## 2026-06-17 - Team Management Modul Refaktorálás (Dynamic RBAC, Settings, i18n)
|
||||
|
||||
### 🎯 Cél
|
||||
A Team Management modul teljes refaktorálása: hardcoded `OrgUserRole` Enum kiváltása adatbázis-vezérelt `OrgRole` modellel, hardcoded 7 napos meghívási lejárat kiváltása dinamikus `Organization.settings` JSONB mezővel, és az összes hardcoded magyar szöveg kiváltása i18n kulcsokkal a `TranslationService.get_text()` segítségével.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. MODELL - [`organization.py`](backend/app/models/marketplace/organization.py:34):**
|
||||
- **`OrgRole`** új modell létrehozva a `fleet.org_roles` táblában (id, name_key, display_name, description, is_system, priority, is_active, created_at)
|
||||
- **`Organization.settings`** JSONB oszlop hozzáadva alapértelmezett értékekkel: `{"invite_expiry_days": 7, "max_members": 50, "allow_public_join": false}`
|
||||
- **`OrganizationMember.role`** típusa megváltoztatva `PG_ENUM(OrgUserRole)`-ról `String(50)`-re, alapértelmezett `"MEMBER"`
|
||||
|
||||
**2. MODELL EXPORT - [`__init__.py`](backend/app/models/__init__.py:1):**
|
||||
- `OrgRole` hozzáadva az importokhoz és az `__all__` listához
|
||||
|
||||
**3. API VÉGPONTOK - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:1):**
|
||||
- `_()` helper függvény hozzáadva a `TranslationService.get_text()` rövidítésére
|
||||
- `_get_org_settings()` helper hozzáadva a dinamikus beállítások olvasásához
|
||||
- Összes hardcoded magyar szöveg kiváltva i18n kulcsokkal (pl. `ORGANIZATION.ERROR.INVALID_TAX_FORMAT`, `ORGANIZATION.SUCCESS.ONBOARDED`, `ORGANIZATION.CLAIM.OTP_SENT`)
|
||||
- Összes `OrgUserRole.OWNER` / `OrgUserRole.ADMIN` Enum referencia kiváltva string literálokkal (`"OWNER"`, `"ADMIN"`)
|
||||
- `member.role.value` → `str(member.role)`
|
||||
- `timedelta(days=7)` → `timedelta(days=invite_expiry_days)` a `_get_org_settings()`-ből
|
||||
|
||||
**4. LOCALE FÁJLOK - [`hu.json`](backend/static/locales/hu.json:1), [`en.json`](backend/static/locales/en.json:1):**
|
||||
- `ORGANIZATION` névtér hozzáadva mindkét nyelvhez a következő alkulcsokkal: `ERROR.*` (11 hibaüzenet), `SUCCESS.*` (5 sikerüzenet), `INVITATION.*` (3 meghívó üzenet), `JOIN_REQUEST.*` (1 csatlakozási üzenet), `CLAIM.*` (2 átvételi üzenet)
|
||||
|
||||
**5. ADATBÁZIS SZINKRONIZÁCIÓ:**
|
||||
- `sync_engine.py` sikeresen lefuttatva: `fleet.org_roles` tábla létrehozva, `fleet.organizations.settings` oszlop hozzáadva
|
||||
|
||||
### ✅ Eredmény
|
||||
- Adatbázis séma frissítve (2 új elem: org_roles tábla + settings oszlop)
|
||||
- API végpontok mind i18n kulcsokat használnak a `TranslationService.get_text()`-en keresztül
|
||||
- Nincs több hardcoded `OrgUserRole` Enum a Team Management modulban
|
||||
- Nincs több hardcoded 7 napos lejárat
|
||||
- Nincs több hardcoded magyar szöveg az API válaszokban
|
||||
- Business onboarding teszt: ✅ PASS
|
||||
|
||||
@@ -5,24 +5,33 @@ import uuid
|
||||
import hashlib
|
||||
import random
|
||||
import logging
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field, ConfigDict, EmailStr
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole
|
||||
from app.models.identity import User, OneTimePassword # JAVÍTVA: Központi Identity modell
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
|
||||
from app.models.identity import User, OneTimePassword
|
||||
from app.core.config import settings
|
||||
from app.services.security_service import security_service
|
||||
from app.models import LogSeverity
|
||||
from app.services.translation_service import TranslationService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _(key: str, **kwargs) -> str:
|
||||
"""Rövidítés a TranslationService.get_text() hívásához."""
|
||||
return TranslationService.get_text(key, variables=kwargs if kwargs else None)
|
||||
|
||||
|
||||
@router.post("/onboard", response_model=CorpOnboardResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def onboard_organization(
|
||||
org_in: CorpOnboardIn,
|
||||
@@ -39,7 +48,7 @@ async def onboard_organization(
|
||||
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Érvénytelen magyar adószám formátum!"
|
||||
detail=_("ORGANIZATION.ERROR.INVALID_TAX_FORMAT")
|
||||
)
|
||||
|
||||
# 2. Duplikáció ellenőrzés – adószám alapján
|
||||
@@ -48,11 +57,10 @@ async def onboard_organization(
|
||||
if result_exist.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Ez a cég (adószám) már regisztrálva van a rendszerben."
|
||||
detail=_("ORGANIZATION.ERROR.TAX_ALREADY_REGISTERED")
|
||||
)
|
||||
|
||||
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
|
||||
# Mivel az adatbázisban NOT NULL, itt muszáj létrehozni
|
||||
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
|
||||
|
||||
# 4. Mentés
|
||||
@@ -62,7 +70,7 @@ async def onboard_organization(
|
||||
display_name=org_in.display_name,
|
||||
tax_number=org_in.tax_number,
|
||||
reg_number=org_in.reg_number,
|
||||
folder_slug=temp_slug, # JAVÍTVA: Kötelező mező beillesztve
|
||||
folder_slug=temp_slug,
|
||||
address_zip=org_in.address_zip,
|
||||
address_city=org_in.address_city,
|
||||
address_street_name=org_in.address_street_name,
|
||||
@@ -72,7 +80,6 @@ async def onboard_organization(
|
||||
country_code=org_in.country_code,
|
||||
org_type=OrgType.business,
|
||||
status="pending_verification",
|
||||
# --- EXPLICIT IDŐBÉLYEGEK A DB HIBA ELKERÜLÉSÉRE ---
|
||||
first_registered_at=datetime.now(timezone.utc),
|
||||
current_lifecycle_started_at=datetime.now(timezone.utc),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
@@ -90,7 +97,7 @@ async def onboard_organization(
|
||||
# 5. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA
|
||||
main_branch = Branch(
|
||||
organization_id=new_org.id,
|
||||
name="Központi Telephely",
|
||||
name=_("ORGANIZATION.BRANCH.DEFAULT_NAME"),
|
||||
is_main=True,
|
||||
postal_code=org_in.address_zip,
|
||||
city=org_in.address_city,
|
||||
@@ -106,7 +113,7 @@ async def onboard_organization(
|
||||
owner_member = OrganizationMember(
|
||||
organization_id=new_org.id,
|
||||
user_id=current_user.id,
|
||||
role="OWNER" # JAVÍTVA: Enum kompatibilis nagybetűs forma
|
||||
role="OWNER"
|
||||
)
|
||||
db.add(owner_member)
|
||||
|
||||
@@ -124,6 +131,7 @@ 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,
|
||||
@@ -131,15 +139,6 @@ async def lookup_tax_number(
|
||||
):
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -155,7 +154,7 @@ async def lookup_tax_number(
|
||||
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."
|
||||
detail=_("ORGANIZATION.ERROR.TAX_ALREADY_REGISTERED")
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -164,34 +163,51 @@ async def lookup_tax_number(
|
||||
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ő."
|
||||
detail=_("ORGANIZATION.ERROR.NAV_UNAVAILABLE")
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Cég nem található a NAV adatbázisában"
|
||||
detail=_("ORGANIZATION.ERROR.TAX_NOT_FOUND")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/my", response_model=List[dict])
|
||||
async def get_my_organizations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
""" A bejelentkezett felhasználóhoz tartozó összes szervezet listázása. """
|
||||
"""
|
||||
A bejelentkezett felhasználóhoz tartozó szervezetek listázása.
|
||||
"""
|
||||
subq = (
|
||||
select(Organization.id)
|
||||
.outerjoin(OrganizationMember, OrganizationMember.organization_id == Organization.id)
|
||||
.where(
|
||||
or_(
|
||||
Organization.owner_id == current_user.id,
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
.where(Organization.org_type.notin_([OrgType.service_provider, OrgType.service]))
|
||||
.where(Organization.is_deleted == False)
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(Organization)
|
||||
.join(OrganizationMember)
|
||||
.where(OrganizationMember.user_id == current_user.id)
|
||||
.where(Organization.id.in_(select(subq.c.id)))
|
||||
.options(selectinload(Organization.members))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
orgs = result.scalars().all()
|
||||
|
||||
# Return full organization details
|
||||
return [
|
||||
{
|
||||
"organization_id": o.id,
|
||||
"owner_id": o.owner_id,
|
||||
"status": o.status,
|
||||
"name": o.name,
|
||||
"full_name": o.full_name,
|
||||
@@ -202,12 +218,373 @@ async def get_my_organizations(
|
||||
"is_deleted": o.is_deleted,
|
||||
"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
|
||||
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
|
||||
"user_role": _get_user_role(o, current_user.id)
|
||||
}
|
||||
for o in orgs
|
||||
]
|
||||
|
||||
|
||||
def _get_user_role(org: Organization, user_id: int) -> Optional[str]:
|
||||
"""
|
||||
Segédfüggvény: visszaadja a felhasználó szerepkörét a szervezetben.
|
||||
"""
|
||||
if org.members:
|
||||
for member in org.members:
|
||||
if member.user_id == user_id:
|
||||
return str(member.role)
|
||||
if org.owner_id == user_id:
|
||||
return "OWNER"
|
||||
return None
|
||||
|
||||
|
||||
# ── TEAM MANAGEMENT API VÉGPONTOK ──
|
||||
|
||||
class MemberResponse(BaseModel):
|
||||
"""Tag adatainak visszaadása."""
|
||||
id: int
|
||||
organization_id: int
|
||||
user_id: Optional[int] = None
|
||||
person_id: Optional[int] = None
|
||||
invited_email: Optional[str] = None
|
||||
role: str
|
||||
status: str
|
||||
is_permanent: bool = False
|
||||
is_verified: bool = False
|
||||
expires_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
user_email: Optional[str] = None
|
||||
user_display_name: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MemberRoleUpdate(BaseModel):
|
||||
"""Tag szerepkör módosítása."""
|
||||
role: str = Field(..., description="Új szerepkör (OWNER, ADMIN, MANAGER, MEMBER, AGENT)")
|
||||
|
||||
|
||||
class InvitationCreate(BaseModel):
|
||||
"""Meghívó létrehozása."""
|
||||
email: str = Field(..., description="Meghívott e-mail címe")
|
||||
role: str = Field("MEMBER", description="Szerepkör a szervezetben")
|
||||
message: Optional[str] = Field(None, description="Üzenet a meghívotthoz")
|
||||
|
||||
|
||||
async def _get_org_settings(org: Organization) -> dict:
|
||||
"""Visszaadja a szervezet dinamikus beállításait, alapértelmezett értékekkel."""
|
||||
defaults = {"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False}
|
||||
if hasattr(org, 'settings') and org.settings:
|
||||
merged = dict(defaults)
|
||||
merged.update(org.settings)
|
||||
return merged
|
||||
return defaults
|
||||
|
||||
|
||||
async def _check_org_admin_access(org_id: int, user_id: int, db: AsyncSession) -> Organization:
|
||||
"""
|
||||
Segédfüggvény: ellenőrzi, hogy a felhasználó OWNER vagy ADMIN a szervezetben.
|
||||
Visszaadja a szervezet objektumot, vagy 403/404 hibát dob.
|
||||
"""
|
||||
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=404, detail=_("ORGANIZATION.ERROR.NOT_FOUND"))
|
||||
|
||||
# Tulajdonos automatikusan jogosult
|
||||
if org.owner_id == user_id:
|
||||
return org
|
||||
|
||||
stmt_member = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"]),
|
||||
OrganizationMember.status == "active"
|
||||
)
|
||||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail=_("ORGANIZATION.ERROR.ACCESS_DENIED"))
|
||||
|
||||
return org
|
||||
|
||||
|
||||
@router.get("/{org_id}/members", response_model=List[MemberResponse])
|
||||
async def list_organization_members(
|
||||
org_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Szervezet tagjainak listázása.
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó érheti el.
|
||||
"""
|
||||
await _check_org_admin_access(org_id, current_user.id, db)
|
||||
|
||||
stmt = (
|
||||
select(OrganizationMember, User)
|
||||
.outerjoin(User, OrganizationMember.user_id == User.id)
|
||||
.where(OrganizationMember.organization_id == org_id)
|
||||
.order_by(OrganizationMember.created_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
members = []
|
||||
for member, user in rows:
|
||||
member_dict = {
|
||||
"id": member.id,
|
||||
"organization_id": member.organization_id,
|
||||
"user_id": member.user_id,
|
||||
"person_id": member.person_id,
|
||||
"invited_email": member.invited_email,
|
||||
"role": str(member.role),
|
||||
"status": member.status,
|
||||
"is_permanent": member.is_permanent,
|
||||
"is_verified": member.is_verified,
|
||||
"expires_at": member.expires_at,
|
||||
"created_at": member.created_at,
|
||||
"updated_at": member.updated_at,
|
||||
"user_email": user.email if user else None,
|
||||
"user_display_name": user.email if user else None,
|
||||
}
|
||||
members.append(member_dict)
|
||||
|
||||
return members
|
||||
|
||||
|
||||
@router.post("/{org_id}/invitations", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_invitation(
|
||||
org_id: int,
|
||||
invite_in: InvitationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Meghívó küldése egy szervezetbe.
|
||||
Az invite_expiry_days a szervezet dinamikus settings mezőjéből olvasódik ki.
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó hívhatja.
|
||||
"""
|
||||
org = await _check_org_admin_access(org_id, current_user.id, db)
|
||||
|
||||
# Dinamikus beállítások lekérése
|
||||
org_settings = await _get_org_settings(org)
|
||||
invite_expiry_days = org_settings.get("invite_expiry_days", 7)
|
||||
|
||||
# Ellenőrizzük, hogy a szerepkör érvényes-e
|
||||
valid_roles = [r.value for r in OrgUserRole]
|
||||
role_upper = invite_in.role.upper()
|
||||
if role_upper not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_("ORGANIZATION.ERROR.INVALID_ROLE", roles=", ".join(valid_roles))
|
||||
)
|
||||
|
||||
# Ellenőrizzük, hogy az email már létezik-e a szervezetben
|
||||
stmt_existing = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
or_(
|
||||
OrganizationMember.invited_email == invite_in.email,
|
||||
OrganizationMember.user_id == select(User.id).where(User.email == invite_in.email).scalar_subquery()
|
||||
)
|
||||
)
|
||||
existing = (await db.execute(stmt_existing)).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=_("ORGANIZATION.ERROR.EMAIL_ALREADY_INVITED"))
|
||||
|
||||
# Keresés a felhasználók között
|
||||
stmt_user = select(User).where(User.email == invite_in.email)
|
||||
target_user = (await db.execute(stmt_user)).scalar_one_or_none()
|
||||
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=invite_expiry_days)
|
||||
|
||||
if target_user:
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=target_user.id,
|
||||
role=role_upper,
|
||||
status="pending",
|
||||
expires_at=expires_at,
|
||||
invited_email=invite_in.email,
|
||||
)
|
||||
db.add(new_member)
|
||||
await db.commit()
|
||||
logger.info(f"Meghívó elküldve létező felhasználónak: {invite_in.email} (org_id={org_id})")
|
||||
return {
|
||||
"status": "success",
|
||||
"message": _("ORGANIZATION.INVITATION.SENT_EXISTING_USER"),
|
||||
"member_id": new_member.id,
|
||||
"expires_at": expires_at.isoformat()
|
||||
}
|
||||
else:
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
invited_email=invite_in.email,
|
||||
role=role_upper,
|
||||
status="pending",
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(new_member)
|
||||
await db.commit()
|
||||
logger.info(f"Meghívó elküldve új (nem regisztrált) felhasználónak: {invite_in.email} (org_id={org_id})")
|
||||
return {
|
||||
"status": "success",
|
||||
"message": _("ORGANIZATION.INVITATION.SENT_NEW_USER"),
|
||||
"member_id": new_member.id,
|
||||
"expires_at": expires_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{org_id}/members/{member_id}", response_model=MemberResponse)
|
||||
async def update_member_role(
|
||||
org_id: int,
|
||||
member_id: int,
|
||||
role_update: MemberRoleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Tag szerepkörének módosítása a szervezetben.
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||||
"""
|
||||
org = await _check_org_admin_access(org_id, current_user.id, db)
|
||||
|
||||
# Ellenőrizzük, hogy a szerepkör érvényes-e
|
||||
valid_roles = [r.value for r in OrgUserRole]
|
||||
role_upper = role_update.role.upper()
|
||||
if role_upper not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_("ORGANIZATION.ERROR.INVALID_ROLE", roles=", ".join(valid_roles))
|
||||
)
|
||||
|
||||
# Tag lekérése
|
||||
stmt_member = select(OrganizationMember).where(
|
||||
OrganizationMember.id == member_id,
|
||||
OrganizationMember.organization_id == org_id
|
||||
)
|
||||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail=_("ORGANIZATION.ERROR.MEMBER_NOT_FOUND"))
|
||||
|
||||
# Nem lehet az utolsó OWNER-t átállítani
|
||||
if member.role == "OWNER" and role_upper != "OWNER":
|
||||
stmt_other_owners = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role == "OWNER",
|
||||
OrganizationMember.id != member_id,
|
||||
OrganizationMember.status == "active"
|
||||
)
|
||||
other_owners = (await db.execute(stmt_other_owners)).scalars().all()
|
||||
if not other_owners:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_("ORGANIZATION.ERROR.LAST_OWNER_ROLE")
|
||||
)
|
||||
|
||||
# Ha valaki ADMIN-t akar csinálni, az csak OWNER lehet
|
||||
if role_upper == "OWNER" and current_user.id != org.owner_id:
|
||||
stmt_caller = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.role == "OWNER",
|
||||
OrganizationMember.status == "active"
|
||||
)
|
||||
caller = (await db.execute(stmt_caller)).scalar_one_or_none()
|
||||
if not caller:
|
||||
raise HTTPException(status_code=403, detail=_("ORGANIZATION.ERROR.OWNER_ONLY_TRANSFER"))
|
||||
|
||||
member.role = role_upper
|
||||
await db.commit()
|
||||
await db.refresh(member)
|
||||
|
||||
user = None
|
||||
if member.user_id:
|
||||
stmt_user = select(User).where(User.id == member.user_id)
|
||||
user = (await db.execute(stmt_user)).scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"id": member.id,
|
||||
"organization_id": member.organization_id,
|
||||
"user_id": member.user_id,
|
||||
"person_id": member.person_id,
|
||||
"invited_email": member.invited_email,
|
||||
"role": str(member.role),
|
||||
"status": member.status,
|
||||
"is_permanent": member.is_permanent,
|
||||
"is_verified": member.is_verified,
|
||||
"expires_at": member.expires_at,
|
||||
"created_at": member.created_at,
|
||||
"updated_at": member.updated_at,
|
||||
"user_email": user.email if user else None,
|
||||
"user_display_name": user.display_name if user else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{org_id}/members/{member_id}", status_code=status.HTTP_200_OK)
|
||||
async def remove_member(
|
||||
org_id: int,
|
||||
member_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Tag eltávolítása / meghívó visszavonása a szervezetből.
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó végezheti.
|
||||
"""
|
||||
org = await _check_org_admin_access(org_id, current_user.id, db)
|
||||
|
||||
stmt_member = select(OrganizationMember).where(
|
||||
OrganizationMember.id == member_id,
|
||||
OrganizationMember.organization_id == org_id
|
||||
)
|
||||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail=_("ORGANIZATION.ERROR.MEMBER_NOT_FOUND"))
|
||||
|
||||
# Nem lehet az utolsó OWNER-t eltávolítani
|
||||
if member.role == "OWNER":
|
||||
stmt_other_owners = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role == "OWNER",
|
||||
OrganizationMember.id != member_id,
|
||||
OrganizationMember.status == "active"
|
||||
)
|
||||
other_owners = (await db.execute(stmt_other_owners)).scalars().all()
|
||||
if not other_owners:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_("ORGANIZATION.ERROR.LAST_OWNER_REMOVE")
|
||||
)
|
||||
|
||||
await security_service.log_event(
|
||||
db, user_id=current_user.id, action="ORG_MEMBER_REMOVED",
|
||||
severity=LogSeverity.info, target_type="OrganizationMember", target_id=str(member_id),
|
||||
old_data={"role": str(member.role), "status": member.status, "user_id": member.user_id}
|
||||
)
|
||||
|
||||
if member.status == "pending":
|
||||
await db.delete(member)
|
||||
action = "invitation_revoked"
|
||||
message = _("ORGANIZATION.MEMBER.INVITATION_REVOKED")
|
||||
else:
|
||||
member.status = "removed"
|
||||
action = "member_removed"
|
||||
message = _("ORGANIZATION.MEMBER.REMOVED")
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"action": action,
|
||||
"message": message
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{org_id}/visual-settings", response_model=OrganizationResponse)
|
||||
async def update_organization_visual_settings(
|
||||
org_id: int,
|
||||
@@ -217,11 +594,6 @@ async def update_organization_visual_settings(
|
||||
):
|
||||
"""
|
||||
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
|
||||
@@ -234,7 +606,7 @@ async def update_organization_visual_settings(
|
||||
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)."
|
||||
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
|
||||
)
|
||||
|
||||
# 2. Szervezet lekérése
|
||||
@@ -247,21 +619,20 @@ async def update_organization_visual_settings(
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Szervezet nem található."
|
||||
detail=_("ORGANIZATION.ERROR.NOT_FOUND")
|
||||
)
|
||||
|
||||
# 3. JSONB merge: csak a kapott kulcsokat frissítjük, a többit megtartjuk
|
||||
# 3. JSONB merge: csak a kapott kulcsokat frissítjük
|
||||
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)
|
||||
# 4. Egyéb mezők frissítése
|
||||
for field, value in update_dict.items():
|
||||
if hasattr(org, field) and value is not None:
|
||||
setattr(org, field, value)
|
||||
@@ -274,7 +645,7 @@ async def update_organization_visual_settings(
|
||||
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)}"
|
||||
detail=_("ORGANIZATION.ERROR.DATABASE_ERROR", error=str(e))
|
||||
)
|
||||
|
||||
return OrganizationResponse.model_validate(org)
|
||||
@@ -289,11 +660,6 @@ async def update_organization(
|
||||
):
|
||||
"""
|
||||
Szervezet adatainak részleges frissítése (általános PATCH).
|
||||
|
||||
Támogatja a cégadatok (name, full_name, tax_number, display_name),
|
||||
cím adatok (address_zip, address_city, address_street_name, stb.),
|
||||
valamint a visual_settings, language, default_currency mezők frissítését.
|
||||
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||||
"""
|
||||
# 1. Jogosultság ellenőrzése
|
||||
@@ -306,7 +672,7 @@ async def update_organization(
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultságod a szervezet adatainak módosításához (csak OWNER/ADMIN)."
|
||||
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
|
||||
)
|
||||
|
||||
# 2. Szervezet lekérése
|
||||
@@ -319,7 +685,7 @@ async def update_organization(
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Szervezet nem található."
|
||||
detail=_("ORGANIZATION.ERROR.NOT_FOUND")
|
||||
)
|
||||
|
||||
# 3. JSONB merge visual_settings esetén
|
||||
@@ -345,141 +711,14 @@ async def update_organization(
|
||||
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)}"
|
||||
detail=_("ORGANIZATION.ERROR.DATABASE_ERROR", error=str(e))
|
||||
)
|
||||
|
||||
return OrganizationResponse.model_validate(org)
|
||||
|
||||
|
||||
# --- B2B MEGHÍVÓ LOGIKA ---
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.models.identity import VerificationToken
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
class OrgInvitationIn(BaseModel):
|
||||
email: EmailStr
|
||||
role: str = "DRIVER"
|
||||
|
||||
class OrgInvitationResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
|
||||
@router.post("/{org_id}/invitations", response_model=OrgInvitationResponse, status_code=status.HTTP_200_OK)
|
||||
async def invite_to_organization(
|
||||
org_id: int,
|
||||
invite_in: OrgInvitationIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
B2B Meghívó küldése egy szervezetbe.
|
||||
Ha a felhasználó már létezik, egy pending tagot hozunk létre.
|
||||
Ha nem létezik, token készül az email címre.
|
||||
"""
|
||||
# 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=403, detail="Nincs jogosultságod meghívót küldeni (csak OWNER/ADMIN).")
|
||||
|
||||
# 2. Célpont keresése
|
||||
stmt_target = select(User).where(User.email == invite_in.email)
|
||||
target_user = (await db.execute(stmt_target)).scalar_one_or_none()
|
||||
|
||||
if target_user:
|
||||
# Létező felhasználó, van-e már tagsága?
|
||||
stmt_exist = select(OrganizationMember).where(
|
||||
(OrganizationMember.organization_id == org_id) &
|
||||
(OrganizationMember.user_id == target_user.id)
|
||||
)
|
||||
if (await db.execute(stmt_exist)).scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="A felhasználó már tagja a szervezetnek.")
|
||||
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=target_user.id,
|
||||
role=invite_in.role.upper(),
|
||||
status="pending" # Válaszolni kell a meghívóra
|
||||
)
|
||||
db.add(new_member)
|
||||
await db.commit()
|
||||
logger.info(f"Értesítő email küldve a létező felhasználónak: {invite_in.email}")
|
||||
return {"status": "success", "message": "Meghívó elküldve a meglévő felhasználónak."}
|
||||
else:
|
||||
# Új felhasználó -> Token
|
||||
token_val = uuid.uuid4()
|
||||
new_token = VerificationToken(
|
||||
token=token_val,
|
||||
user_id=None, # Mivel még nincs User
|
||||
token_type="org_invite",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
extra_data={"org_id": org_id, "role": invite_in.role.upper(), "email": invite_in.email}
|
||||
)
|
||||
db.add(new_token)
|
||||
await db.commit()
|
||||
logger.info(f"Meghívó email küldve az új felhasználónak: {invite_in.email}, Token: {token_val}")
|
||||
return {"status": "success", "message": "Meghívó email elküldve (új felhasználó)."}
|
||||
|
||||
@router.post("/invitations/{token}/accept", response_model=OrgInvitationResponse, status_code=status.HTTP_200_OK)
|
||||
async def accept_invitation(
|
||||
token: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Meghívó elfogadása token alapján. (Új felhasználó számára, miután regisztrált)
|
||||
"""
|
||||
stmt = select(VerificationToken).where(
|
||||
(VerificationToken.token == token) &
|
||||
(VerificationToken.token_type == "org_invite") &
|
||||
(VerificationToken.is_used == False)
|
||||
)
|
||||
token_rec = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not token_rec or token_rec.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=400, detail="Érvénytelen vagy lejárt meghívó.")
|
||||
|
||||
extra = token_rec.extra_data
|
||||
if not extra or extra.get("email") != current_user.email:
|
||||
raise HTTPException(status_code=403, detail="A meghívó nem a te e-mail címedre szól.")
|
||||
|
||||
org_id = extra.get("org_id")
|
||||
role = extra.get("role")
|
||||
|
||||
# Van-e már tagsága?
|
||||
stmt_exist = select(OrganizationMember).where(
|
||||
(OrganizationMember.organization_id == org_id) &
|
||||
(OrganizationMember.user_id == current_user.id)
|
||||
)
|
||||
exist_member = (await db.execute(stmt_exist)).scalar_one_or_none()
|
||||
if exist_member:
|
||||
exist_member.status = "active"
|
||||
exist_member.role = role
|
||||
else:
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=current_user.id,
|
||||
role=role,
|
||||
status="active"
|
||||
)
|
||||
db.add(new_member)
|
||||
token_rec.is_used = True
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "message": "Meghívó sikeresen elfogadva."}
|
||||
|
||||
|
||||
# ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
|
||||
|
||||
from app.models.identity import OneTimePassword
|
||||
import random
|
||||
|
||||
class JoinRequestIn(BaseModel):
|
||||
"""Csatlakozási kérelem egy céghez, amelynek van aktív adminja."""
|
||||
message: Optional[str] = Field(None, description="Üzenet az adminnak")
|
||||
@@ -503,9 +742,7 @@ async def accept_invitation(
|
||||
):
|
||||
"""
|
||||
Csatlakozási kérelem egy szervezethez, amelynek van aktív adminja.
|
||||
A kérelem 7 napig él, amíg az admin jóvá nem hagyja.
|
||||
"""
|
||||
# Ellenőrizzük, hogy létezik-e a szervezet
|
||||
stmt = select(Organization).where(
|
||||
Organization.id == org_id,
|
||||
Organization.is_deleted == False
|
||||
@@ -513,12 +750,12 @@ async def accept_invitation(
|
||||
result = await db.execute(stmt)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Szervezet nem található.")
|
||||
raise HTTPException(status_code=404, detail=_("ORGANIZATION.ERROR.NOT_FOUND"))
|
||||
|
||||
# Ellenőrizzük, hogy van-e aktív admin
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN])
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"])
|
||||
).join(User, OrganizationMember.user_id == User.id).where(User.is_active == True, User.is_deleted == False)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
@@ -527,7 +764,7 @@ async def accept_invitation(
|
||||
if not active_admins:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A szervezetnek nincs aktív adminja. Használja a /claim/request végpontot."
|
||||
detail=_("ORGANIZATION.ERROR.NO_ACTIVE_ADMIN")
|
||||
)
|
||||
|
||||
# Ellenőrizzük, hogy a felhasználó már nem tag-e
|
||||
@@ -538,18 +775,17 @@ async def accept_invitation(
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Már tagja vagy ennek a szervezetnek.")
|
||||
raise HTTPException(status_code=400, detail=_("ORGANIZATION.ERROR.ALREADY_MEMBER"))
|
||||
|
||||
# Létrehozzuk a pending tagot
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=current_user.id,
|
||||
role=OrgUserRole.DRIVER,
|
||||
role="MEMBER",
|
||||
status="pending"
|
||||
)
|
||||
db.add(new_member)
|
||||
|
||||
# Naplózás
|
||||
await security_service.log_event(
|
||||
db, user_id=current_user.id, action="ORG_JOIN_REQUEST",
|
||||
severity=LogSeverity.info, target_type="Organization", target_id=str(org_id),
|
||||
@@ -560,7 +796,7 @@ async def accept_invitation(
|
||||
|
||||
return {
|
||||
"status": "pending",
|
||||
"message": "Csatlakozási kérelmed rögzítettük. Az admin jóváhagyására vársz."
|
||||
"message": _("ORGANIZATION.JOIN_REQUEST.SENT")
|
||||
}
|
||||
|
||||
|
||||
@@ -573,10 +809,7 @@ async def accept_invitation(
|
||||
):
|
||||
"""
|
||||
Árva cég átvételi kérelem indítása.
|
||||
Ellenőrzi, hogy a megadott email cím egyezik-e a cég eredeti email címével,
|
||||
majd 6-jegyű OTP kódot küld ki.
|
||||
"""
|
||||
# Ellenőrizzük a szervezetet
|
||||
stmt = select(Organization).where(
|
||||
Organization.id == org_id,
|
||||
Organization.is_deleted == False
|
||||
@@ -584,12 +817,12 @@ async def accept_invitation(
|
||||
result = await db.execute(stmt)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Szervezet nem található.")
|
||||
raise HTTPException(status_code=404, detail=_("ORGANIZATION.ERROR.NOT_FOUND"))
|
||||
|
||||
# Ellenőrizzük, hogy tényleg árva-e (nincs aktív admin)
|
||||
# Ellenőrizzük, hogy tényleg árva-e
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN])
|
||||
OrganizationMember.role.in_(["OWNER", "ADMIN"])
|
||||
).join(User, OrganizationMember.user_id == User.id).where(User.is_active == True, User.is_deleted == False)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
@@ -598,11 +831,10 @@ async def accept_invitation(
|
||||
if active_admins:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A szervezetnek van aktív adminja. Használja a /join-request végpontot."
|
||||
detail=_("ORGANIZATION.ERROR.HAS_ACTIVE_ADMIN")
|
||||
)
|
||||
|
||||
# Ellenőrizzük az email címet - keressük a tulajdonos usert
|
||||
# A tulajdonos email-jét kell ellenőrizni
|
||||
# Ellenőrizzük az email címet
|
||||
owner_stmt = select(User).where(
|
||||
User.id == org.owner_id,
|
||||
User.email == request.email
|
||||
@@ -611,8 +843,6 @@ async def accept_invitation(
|
||||
owner_user = owner_result.scalar_one_or_none()
|
||||
|
||||
if not owner_user:
|
||||
# Lehet, hogy a tulajdonos törölve van, és az email át lett írva
|
||||
# Keressük a deleted_ prefix-es email-ben
|
||||
owner_stmt = select(User).where(
|
||||
User.id == org.owner_id,
|
||||
User.email.like(f"%{request.email}")
|
||||
@@ -623,7 +853,7 @@ async def accept_invitation(
|
||||
if not owner_user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Az email cím nem egyezik a cég tulajdonosának email címével."
|
||||
detail=_("ORGANIZATION.ERROR.EMAIL_MISMATCH")
|
||||
)
|
||||
|
||||
# Generáljunk 6-jegyű OTP kódot
|
||||
@@ -640,7 +870,6 @@ async def accept_invitation(
|
||||
db.add(otp)
|
||||
await db.commit()
|
||||
|
||||
# Szimulált email küldés
|
||||
logger.info(f"=== CÉG ÁTVÉTELI OTP ===")
|
||||
logger.info(f"Email: {request.email}")
|
||||
logger.info(f"Kód: {code}")
|
||||
@@ -649,7 +878,7 @@ async def accept_invitation(
|
||||
|
||||
return {
|
||||
"status": "otp_sent",
|
||||
"message": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.",
|
||||
"message": _("ORGANIZATION.CLAIM.OTP_SENT"),
|
||||
"expires_at": expires_at.isoformat()
|
||||
}
|
||||
|
||||
@@ -675,7 +904,7 @@ async def accept_invitation(
|
||||
result = await db.execute(stmt)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Szervezet nem található.")
|
||||
raise HTTPException(status_code=404, detail=_("ORGANIZATION.ERROR.NOT_FOUND"))
|
||||
|
||||
# Ellenőrizzük az OTP kódot
|
||||
otp_stmt = select(OneTimePassword).where(
|
||||
@@ -692,13 +921,13 @@ async def accept_invitation(
|
||||
if not otp:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Érvénytelen vagy lejárt kód."
|
||||
detail=_("ORGANIZATION.ERROR.INVALID_CODE")
|
||||
)
|
||||
|
||||
# Ellenőrizzük, hogy az OTP ehhez a szervezethez tartozik-e
|
||||
extra = otp.extra_data or {}
|
||||
if extra.get("org_id") != org_id:
|
||||
raise HTTPException(status_code=400, detail="A kód nem ehhez a szervezethez tartozik.")
|
||||
raise HTTPException(status_code=400, detail=_("ORGANIZATION.ERROR.CODE_ORG_MISMATCH"))
|
||||
|
||||
# Mark OTP as used
|
||||
otp.is_used = True
|
||||
@@ -713,13 +942,13 @@ async def accept_invitation(
|
||||
|
||||
if existing_member:
|
||||
# Frissítjük a szerepkört
|
||||
existing_member.role = OrgUserRole.ADMIN
|
||||
existing_member.role = "ADMIN"
|
||||
else:
|
||||
# Új tag létrehozása ADMIN szerepkörrel
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=current_user.id,
|
||||
role=OrgUserRole.ADMIN,
|
||||
role="ADMIN",
|
||||
status="active"
|
||||
)
|
||||
db.add(new_member)
|
||||
@@ -738,5 +967,5 @@ async def accept_invitation(
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin."
|
||||
"message": _("ORGANIZATION.CLAIM.SUCCESS")
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
"""
|
||||
Provider végpontok – Szolgáltató keresés és gyors felvétel (crowdsourced).
|
||||
|
||||
4-Level Category Architecture (2026-06-17):
|
||||
============================================
|
||||
- GET /categories/tree — Teljes hierarchikus fa a frontend checkboxai számára
|
||||
- GET /categories/autocomplete — Szöveges kereső Szint 2 és Szint 3 címkékhez
|
||||
- GET /categories — Meglévő endpoint (ExpertiseTag lista)
|
||||
- GET /search — Szolgáltató keresés
|
||||
- POST /quick-add — Gyors szolgáltató felvétel
|
||||
- PUT /{provider_id} — Szolgáltató szerkesztés (hibrid mentés)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
@@ -20,6 +30,8 @@ from app.schemas.provider import (
|
||||
ProviderUpdateIn,
|
||||
ProviderUpdateResponse,
|
||||
ExpertiseCategoryOut,
|
||||
CategoryTreeNode,
|
||||
CategoryAutocompleteItem,
|
||||
)
|
||||
from app.services.provider_service import search_providers, quick_add_provider, update_provider
|
||||
|
||||
@@ -27,6 +39,114 @@ router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 4-LEVEL CATEGORY ENDPOINTS (2026-06-17)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/categories/tree", response_model=List[CategoryTreeNode])
|
||||
async def get_category_tree(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Visszaadja a teljes kategória hierarchikus fát a frontend checkboxai számára.
|
||||
|
||||
Level 0: Járműtípus (Vehicle Type)
|
||||
Level 1: Iparág/Főcsoport (Industry)
|
||||
Level 2: Szakma (Profession)
|
||||
Level 3: Specifikus feladat/címke (Specific Tag)
|
||||
|
||||
Rekurzívan építi fel a fa struktúrát a parent_id és path mezők alapján.
|
||||
"""
|
||||
try:
|
||||
# Összes tag betöltése
|
||||
stmt = select(ExpertiseTag).order_by(ExpertiseTag.level, ExpertiseTag.id)
|
||||
result = await db.execute(stmt)
|
||||
all_tags = result.scalars().all()
|
||||
|
||||
# Indexelés ID alapján
|
||||
tag_map = {t.id: t for t in all_tags}
|
||||
|
||||
# Fa építése: csak a gyökér elemeket (parent_id IS NULL) adjuk vissza
|
||||
def build_tree(parent_id: Optional[int] = None) -> List[CategoryTreeNode]:
|
||||
nodes = []
|
||||
for tag in all_tags:
|
||||
if tag.parent_id == parent_id:
|
||||
children = build_tree(tag.id)
|
||||
nodes.append(CategoryTreeNode(
|
||||
id=tag.id,
|
||||
key=tag.key,
|
||||
name_hu=tag.name_hu,
|
||||
name_en=tag.name_en,
|
||||
level=tag.level,
|
||||
path=tag.path,
|
||||
is_official=tag.is_official,
|
||||
children=children,
|
||||
))
|
||||
return nodes
|
||||
|
||||
tree = build_tree(parent_id=None)
|
||||
return tree
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a kategória fa lekérése során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a kategória fa lekérése során.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories/autocomplete", response_model=List[CategoryAutocompleteItem])
|
||||
async def autocomplete_categories(
|
||||
q: str = Query(..., min_length=2, max_length=100, description="Keresőszó"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
|
||||
|
||||
Csak azokat listázza, ahol is_official=True.
|
||||
A keresés a name_hu, name_en és key mezőkben történik (ILIKE).
|
||||
"""
|
||||
try:
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.level >= 2,
|
||||
ExpertiseTag.is_official == True,
|
||||
or_(
|
||||
ExpertiseTag.name_hu.ilike(f"%{q}%"),
|
||||
ExpertiseTag.name_en.ilike(f"%{q}%"),
|
||||
ExpertiseTag.key.ilike(f"%{q}%"),
|
||||
),
|
||||
).order_by(ExpertiseTag.level, ExpertiseTag.name_hu).limit(20)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
tags = result.scalars().all()
|
||||
|
||||
return [
|
||||
CategoryAutocompleteItem(
|
||||
id=t.id,
|
||||
key=t.key,
|
||||
name_hu=t.name_hu,
|
||||
name_en=t.name_en,
|
||||
level=t.level,
|
||||
path=t.path,
|
||||
parent_id=t.parent_id,
|
||||
)
|
||||
for t in tags
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a kategória autocomplete során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a kategória keresés során.",
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# EXISTING ENDPOINTS (extended)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/categories", response_model=List[ExpertiseCategoryOut])
|
||||
async def list_expertise_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -34,9 +154,10 @@ async def list_expertise_categories(
|
||||
):
|
||||
"""
|
||||
Visszaadja az összes expertise kategóriát a frontend dropdown számára.
|
||||
Kibővítve a 4-level hierarchy mezőkkel (level, parent_id, path).
|
||||
"""
|
||||
try:
|
||||
stmt = select(ExpertiseTag).order_by(ExpertiseTag.id)
|
||||
stmt = select(ExpertiseTag).order_by(ExpertiseTag.level, ExpertiseTag.id)
|
||||
result = await db.execute(stmt)
|
||||
tags = result.scalars().all()
|
||||
return [
|
||||
@@ -46,6 +167,9 @@ async def list_expertise_categories(
|
||||
name_hu=t.name_hu,
|
||||
name_en=t.name_en,
|
||||
category=t.category,
|
||||
level=t.level,
|
||||
parent_id=t.parent_id,
|
||||
path=t.path,
|
||||
)
|
||||
for t in tags
|
||||
]
|
||||
@@ -62,6 +186,7 @@ async def search_service_providers(
|
||||
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"),
|
||||
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
|
||||
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
|
||||
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Találatok száma oldalanként"),
|
||||
offset: int = Query(0, ge=0, description="Lapozási offset"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -76,17 +201,35 @@ async def search_service_providers(
|
||||
- **crowd_added**: Közösség által hozzáadott adatok (marketplace.service_providers)
|
||||
|
||||
A találatok forrás szerint csoportosítva, lapozva érkeznek.
|
||||
|
||||
**category_ids**: Ha meg van adva, csak azok a szervezetek jelennek meg,
|
||||
amelyek ServiceProfile-jához tartozik legalább egy megadott ExpertiseTag
|
||||
(közvetlenül vagy hierarchikusan a path LIKE segítségével).
|
||||
"""
|
||||
try:
|
||||
# Parse category_ids: vesszővel elválasztott string -> List[int]
|
||||
parsed_category_ids: Optional[List[int]] = None
|
||||
if category_ids:
|
||||
try:
|
||||
parsed_category_ids = [int(x.strip()) for x in category_ids.split(",") if x.strip()]
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Érvénytelen category_ids formátum. Vesszővel elválasztott számok szükségesek (pl. '201,205').",
|
||||
)
|
||||
|
||||
result = await search_providers(
|
||||
db=db,
|
||||
q=q,
|
||||
category=category,
|
||||
city=city,
|
||||
category_ids=parsed_category_ids,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a szolgáltató keresés során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
@@ -143,6 +286,14 @@ async def update_service_provider(
|
||||
|
||||
Frissíti a szervezet alapadatait (név, cím) és a kapcsolódó
|
||||
ServiceProfile kapcsolati mezőit (telefon, email, weboldal, címkék).
|
||||
|
||||
4-Level Category Hybrid Save (2026-06-17):
|
||||
- category_ids: A kiválasztott kategória ID-k (Szint 0-3 checkboxokból)
|
||||
-> ServiceExpertise kapcsolatok frissítése
|
||||
- new_tags: A user által gépelt új címkenevek
|
||||
-> Létrehozás ExpertiseTag-ban (is_official=False, level=3)
|
||||
-> ServiceExpertise kapcsolat létrehozása
|
||||
- tags: Meglévő specialization_tags frissítése
|
||||
"""
|
||||
try:
|
||||
result = await update_provider(
|
||||
@@ -157,6 +308,11 @@ async def update_service_provider(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a szolgáltató frissítése során (id={provider_id}): {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
|
||||
@@ -6,7 +6,7 @@ from app.database import Base
|
||||
from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole, OneTimePassword
|
||||
|
||||
# 2. Szervezeti felépítés (MUST be before Address/Rating due to FK references)
|
||||
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
|
||||
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, OrgRole, Branch
|
||||
|
||||
# 3. Földrajzi adatok és címek
|
||||
from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
|
||||
@@ -59,7 +59,7 @@ ServiceRecord = AssetEvent
|
||||
|
||||
__all__ = [
|
||||
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
|
||||
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole",
|
||||
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole", "OrgRole",
|
||||
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
|
||||
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
|
||||
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
|
||||
|
||||
@@ -27,10 +27,27 @@ class OrgType(str, enum.Enum):
|
||||
class OrgUserRole(str, enum.Enum):
|
||||
OWNER = "OWNER"
|
||||
ADMIN = "ADMIN"
|
||||
FLEET_MANAGER = "FLEET_MANAGER"
|
||||
DRIVER = "DRIVER"
|
||||
MECHANIC = "MECHANIC"
|
||||
RECEPTIONIST = "RECEPTIONIST"
|
||||
MANAGER = "MANAGER"
|
||||
MEMBER = "MEMBER"
|
||||
AGENT = "AGENT"
|
||||
|
||||
class OrgRole(Base):
|
||||
"""
|
||||
Dinamikus szerepkör modell (RBAC).
|
||||
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
|
||||
Alapértelmezett szerepkörök: OWNER, ADMIN, MANAGER, MEMBER, AGENT.
|
||||
"""
|
||||
__tablename__ = "org_roles"
|
||||
__table_args__ = {"schema": "fleet"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name_key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Organization(Base):
|
||||
"""
|
||||
@@ -72,7 +89,7 @@ class Organization(Base):
|
||||
full_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
display_name: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
folder_slug: Mapped[str] = mapped_column(String(12), unique=True, index=True)
|
||||
folder_slug: Mapped[str] = mapped_column(String(24), unique=True, index=True)
|
||||
|
||||
default_currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
country_code: Mapped[str] = mapped_column(String(2), default="HU")
|
||||
@@ -84,6 +101,7 @@ class Organization(Base):
|
||||
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
plus_code: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
|
||||
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
|
||||
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
@@ -115,6 +133,14 @@ class Organization(Base):
|
||||
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
|
||||
)
|
||||
|
||||
# Dinamikus szervezeti beállítások (pl. invite_expiry_days, role_hierarchy)
|
||||
settings: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=lambda: {"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False},
|
||||
server_default=text("'{\"invite_expiry_days\": 7, \"max_members\": 50, \"allow_public_join\": false}'::jsonb")
|
||||
)
|
||||
|
||||
# --- 🔍 CROWDSOURCED SEARCH FIELDS ---
|
||||
# Provider nicknames / alternative names for matching (e.g. ["MOL", "MOL LUB", "MOL Magyarország"])
|
||||
aliases: Mapped[list] = mapped_column(
|
||||
@@ -183,14 +209,27 @@ class OrganizationMember(Base):
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
|
||||
role: Mapped[OrgUserRole] = mapped_column(
|
||||
PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet"),
|
||||
default=OrgUserRole.DRIVER
|
||||
# Meghívott e-mail címe (ha a meghívottnak még nincs user fiókja)
|
||||
invited_email: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
# Meghívó lejárati ideje
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Dinamikus szerepkör (String, nem Enum) - az OrgRole táblából validáljuk
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="MEMBER",
|
||||
server_default=text("'MEMBER'")
|
||||
)
|
||||
permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
is_permanent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# 🔴 HIÁNYZÓ OSZLOPOK - Pótolva a logic_spec alapján
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", server_default=text("'active'"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
organization: Mapped["Organization"] = relationship("Organization", back_populates="members")
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
person: Mapped[Optional["Person"]] = relationship("Person", back_populates="memberships")
|
||||
|
||||
@@ -80,9 +80,21 @@ class ExpertiseTag(Base):
|
||||
"""
|
||||
Szakmai címkék mesterlistája (MB 2.0).
|
||||
Ez a tábla vezérli a robotok keresését és a Gamification pontozást is.
|
||||
|
||||
4-Level Category Hierarchy (2026-06-17):
|
||||
=========================================
|
||||
Level 0: Járműtípus (Vehicle Type) — e.g. "Személyautó", "Motorkerékpár"
|
||||
Level 1: Iparág/Főcsoport (Industry) — e.g. "Karbantartás és javítás", "Üzemanyagtöltő"
|
||||
Level 2: Szakma (Profession) — e.g. "Autószerelő", "Gumiszerviz"
|
||||
Level 3: Specifikus feladat/címke (Specific Tag) — e.g. "Vezérműszíj csere", "Klíma töltés"
|
||||
|
||||
Adjacency List (parent_id) + Materialized Path (path) for efficient tree queries.
|
||||
"""
|
||||
__tablename__ = "expertise_tags"
|
||||
__table_args__ = {"schema": "marketplace"}
|
||||
__table_args__ = (
|
||||
Index('idx_expertise_path', 'path'),
|
||||
{"schema": "marketplace"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
@@ -90,6 +102,13 @@ class ExpertiseTag(Base):
|
||||
name_en: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
|
||||
|
||||
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("marketplace.expertise_tags.id"), nullable=True, index=True
|
||||
)
|
||||
level: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"), nullable=False)
|
||||
path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
# --- 🎮 GAMIFICATION ÉS DISCOVERY ---
|
||||
is_official: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true"))
|
||||
suggested_by_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
@@ -102,6 +121,20 @@ class ExpertiseTag(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Self-referential relationship for tree traversal
|
||||
# NOTE: cascade="all, delete-orphan" is intentionally NOT set here because
|
||||
# this is a self-referential tree. Deleting a parent should NOT cascade-delete
|
||||
# children (they may be re-parented). The "many" side of a self-referential
|
||||
# relationship cannot have delete-orphan without single_parent=True.
|
||||
children: Mapped[List["ExpertiseTag"]] = relationship(
|
||||
"ExpertiseTag", back_populates="parent",
|
||||
remote_side=[id],
|
||||
)
|
||||
parent: Mapped[Optional["ExpertiseTag"]] = relationship(
|
||||
"ExpertiseTag", back_populates="children",
|
||||
remote_side=[parent_id],
|
||||
)
|
||||
|
||||
services: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="tag")
|
||||
suggested_by: Mapped[Optional["Person"]] = relationship("Person")
|
||||
|
||||
|
||||
@@ -5,40 +5,114 @@ Tartalmazza a keresési, gyors felvételi és szerkesztési sémákat.
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
|
||||
- street -> address_street_name, address_street_type, address_house_number
|
||||
- A ProviderSearchResult is tartalmazza az atomizált címmezőket.
|
||||
|
||||
4-Level Category Architecture (2026-06-17):
|
||||
===========================================
|
||||
- CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz
|
||||
- CategoryAutocompleteItem: Szöveges kereső találatok Szint 2 és Szint 3 címkékhez
|
||||
- ProviderUpdateIn: kibővítve category_ids listával a hibrid mentéshez
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class ExpertiseCategoryOut(BaseModel):
|
||||
"""Expertise kategória kimeneti sémája a frontend dropdown számára."""
|
||||
# ──────────────────────────────────────────────
|
||||
# 4-LEVEL CATEGORY SCHEMAS (2026-06-17)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class CategoryTreeNode(BaseModel):
|
||||
"""Egy csomópont a kategória hierarchikus fában.
|
||||
|
||||
Tartalmazza a gyermek csomópontokat rekurzívan.
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
category: str
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
is_official: bool = True
|
||||
children: List["CategoryTreeNode"] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CategoryAutocompleteItem(BaseModel):
|
||||
"""Egy találat a kategória autocomplete keresőben.
|
||||
|
||||
Csak Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkéket
|
||||
listáz, ahol is_official=True.
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# EXISTING SCHEMAS (extended)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class ExpertiseCategoryOut(BaseModel):
|
||||
"""Expertise kategória kimeneti sémája a frontend dropdown számára.
|
||||
|
||||
Kibővítve a 4-level hierarchy mezőkkel.
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
level: int = 0
|
||||
parent_id: Optional[int] = None
|
||||
path: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CategoryInfo(BaseModel):
|
||||
"""Egy kategória rövid adatai a keresési találatokhoz.
|
||||
|
||||
Tartalmazza a kategória ID-ját, nevét és szintjét,
|
||||
hogy a frontend meg tudja jeleníteni a kapcsolódó
|
||||
szolgáltatásokat a kártyákon.
|
||||
"""
|
||||
id: int
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
level: int = 0
|
||||
key: str
|
||||
|
||||
|
||||
class ProviderSearchResult(BaseModel):
|
||||
"""Egy szolgáltató keresési találatának adatai.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- address_street_name, address_street_type, address_house_number
|
||||
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
|
||||
|
||||
FEATURE (2026-06-17): categories mező.
|
||||
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
|
||||
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
|
||||
"""
|
||||
id: int
|
||||
name: str
|
||||
category: Optional[str] = None
|
||||
specialization: List[str] = Field(default_factory=list)
|
||||
categories: List[CategoryInfo] = Field(default_factory=list)
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
plus_code: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
@@ -63,14 +137,24 @@ class ProviderQuickAddIn(BaseModel):
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
|
||||
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
|
||||
- A 4-level kategória rendszer bevezetésével a frontend a category_ids
|
||||
tömböt használja a kategóriák kiválasztására, nem a régi category_id-t.
|
||||
- category_id megtartva opcionálisként a backward compatibility miatt.
|
||||
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3 checkboxokból)
|
||||
- new_tags: A user által gépelt új (még nem létező) címkék listája
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
category_id: int = Field(..., ge=1, description="Kategória ID (marketplace.expertise_tags.id)")
|
||||
category_id: Optional[int] = Field(None, ge=1, description="Elsődleges kategória ID (opcionális, backward compatibility)")
|
||||
category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)")
|
||||
new_tags: Optional[List[str]] = Field(None, description="User által gépelt új címkenevek (is_official=False, level=3)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
@@ -92,6 +176,10 @@ class ProviderUpdateIn(BaseModel):
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
- Tartalmazza a contact_phone, contact_email, website és tags mezőket is.
|
||||
|
||||
4-Level Category (2026-06-17):
|
||||
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3)
|
||||
- new_tags: A user által gépelt, új (még nem létező) címkék listája
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
@@ -99,10 +187,17 @@ class ProviderUpdateIn(BaseModel):
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
|
||||
category_ids: Optional[List[int]] = Field(
|
||||
None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)"
|
||||
)
|
||||
new_tags: Optional[List[str]] = Field(
|
||||
None, description="User által gépelt új címkenevek (is_official=False, level=3)"
|
||||
)
|
||||
|
||||
|
||||
class ProviderUpdateResponse(BaseModel):
|
||||
@@ -110,3 +205,4 @@ class ProviderUpdateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
message: str = "Szolgáltató adatai sikeresen frissítve."
|
||||
earned_points: int = 0
|
||||
|
||||
@@ -51,6 +51,12 @@ SEED_RULES = [
|
||||
"description": "Szolgáltató értékelése (tagekkel)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "UPDATE_PROVIDER",
|
||||
"points": 100,
|
||||
"description": "Szolgáltató adatainak szerkesztése (kategóriák, címkék frissítése)",
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -19,20 +19,27 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- update_provider: data.street -> data.address_street_name, address_street_type, address_house_number
|
||||
- search_providers: visszaadja az address_street_name, address_street_type, address_house_number mezőket
|
||||
- A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek.
|
||||
|
||||
4-Level Category Hybrid Save (2026-06-17):
|
||||
===========================================
|
||||
- update_provider: category_ids -> ServiceExpertise kapcsolatok frissítése
|
||||
- update_provider: new_tags -> új ExpertiseTag létrehozása (is_official=False, level=3)
|
||||
- A hibrid logika biztosítja, hogy a meglévő kapcsolatok ne sérüljenek
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
|
||||
from app.models.marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging
|
||||
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging
|
||||
from app.models.identity.social import ServiceProvider
|
||||
from app.models.gamification.gamification import PointRule, UserStats
|
||||
from app.schemas.provider import (
|
||||
@@ -42,6 +49,7 @@ from app.schemas.provider import (
|
||||
ProviderQuickAddResponse,
|
||||
ProviderUpdateIn,
|
||||
ProviderUpdateResponse,
|
||||
CategoryInfo,
|
||||
)
|
||||
from app.services.gamification_service import gamification_service
|
||||
|
||||
@@ -132,6 +140,7 @@ async def search_providers(
|
||||
q: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
category_ids: Optional[List[int]] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> ProviderSearchResponse:
|
||||
@@ -144,11 +153,27 @@ async def search_providers(
|
||||
P1 CRITICAL ALIGN (2026-06-17): Az org lekérdezés most már visszaadja
|
||||
az address_street_name, address_street_type, address_house_number mezőket is.
|
||||
|
||||
FEATURE (2026-06-17): category_ids szűrő — SZIGORÚ AND KAPCSOLAT.
|
||||
- Ha több kategória ID van megadva, a szolgáltatónak MINDEGYIKHEZ
|
||||
tartoznia kell (AND), nem elég, ha csak az egyikhez (OR).
|
||||
- Nincs hierarchikus terjeszkedés: csak a pontosan megadott ID-kat
|
||||
ellenőrizzük a ServiceExpertise táblában.
|
||||
|
||||
FEATURE (2026-06-17): Keresés szigorítása.
|
||||
- A q (free-text) keresés csak a name és aliases mezőkben keres (ILIKE),
|
||||
a tags mezőt NEM vizsgálja.
|
||||
- A city keresés exact match vagy StartsWith, nem ILIKE.
|
||||
|
||||
FEATURE (2026-06-17): Kategória adatok visszaadása.
|
||||
- Minden ProviderSearchResult tartalmazza a kapcsolódó kategóriák
|
||||
(ExpertiseTag) ID-ját, nevét és szintjét a categories mezőben.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
q: Keresőszó (tokenizálva, AND kapcsolat) — opcionális
|
||||
category: Kategória szűrő
|
||||
city: Város szűrő — AND kapcsolat a q-val
|
||||
category_ids: Kategória ID-k listája (SZIGORÚ AND szűrés)
|
||||
limit: Lapozási limit
|
||||
offset: Lapozási offset
|
||||
|
||||
@@ -175,18 +200,42 @@ async def search_providers(
|
||||
or_(
|
||||
Organization.name.ilike(f"%{token}%"),
|
||||
cast(Organization.aliases, Text).ilike(f"%{token}%"),
|
||||
cast(Organization.tags, Text).ilike(f"%{token}%"),
|
||||
)
|
||||
)
|
||||
org_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
city_clean = city.strip()
|
||||
org_conditions.append(
|
||||
or_(
|
||||
Organization.address_city.ilike(f"%{city}%"),
|
||||
Organization.address_zip.ilike(f"%{city}%"),
|
||||
Organization.address_city == city_clean,
|
||||
Organization.address_city.ilike(f"{city_clean}%"),
|
||||
Organization.address_zip == city_clean,
|
||||
Organization.address_zip.ilike(f"{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
# FEATURE (2026-06-17): Kategória szűrés category_ids alapján.
|
||||
# SZIGORÚ AND KAPCSOLAT (2026-06-17): Ha több kategória ID van megadva,
|
||||
# a szolgáltatónak MINDEGYIKHEZ tartoznia kell. Nincs hierarchikus
|
||||
# terjeszkedés (path LIKE), csak a pontosan megadott ID-kat ellenőrizzük.
|
||||
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids None,
|
||||
# nem iterálunk rajta.
|
||||
safe_category_ids = category_ids or []
|
||||
if safe_category_ids:
|
||||
# Minden egyes megadott category_id-hoz külön subquery-t készítünk,
|
||||
# és AND kapcsolattal fűzzük össze őket. Ez biztosítja, hogy a
|
||||
# szolgáltató minden kategóriával rendelkezzen.
|
||||
for cid in safe_category_ids:
|
||||
profile_subq = (
|
||||
select(ServiceExpertise.service_id)
|
||||
.where(ServiceExpertise.expertise_id == cid)
|
||||
.subquery()
|
||||
)
|
||||
org_conditions.append(
|
||||
ServiceProfile.id.in_(select(profile_subq.c.service_id))
|
||||
)
|
||||
|
||||
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált
|
||||
# címmezőket (address_street_name, address_street_type, address_house_number).
|
||||
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán
|
||||
@@ -210,6 +259,7 @@ async def search_providers(
|
||||
Organization.address_street_name.label("address_street_name"),
|
||||
Organization.address_street_type.label("address_street_type"),
|
||||
Organization.address_house_number.label("address_house_number"),
|
||||
Organization.plus_code.label("plus_code"),
|
||||
Organization.is_verified.label("is_verified"),
|
||||
ServiceProfile.contact_phone.label("contact_phone"),
|
||||
ServiceProfile.contact_email.label("contact_email"),
|
||||
@@ -234,10 +284,14 @@ async def search_providers(
|
||||
)
|
||||
staging_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
city_clean = city.strip()
|
||||
staging_conditions.append(
|
||||
or_(
|
||||
ServiceStaging.city.ilike(f"%{city}%"),
|
||||
ServiceStaging.postal_code.ilike(f"%{city}%"),
|
||||
ServiceStaging.city == city_clean,
|
||||
ServiceStaging.city.ilike(f"{city_clean}%"),
|
||||
ServiceStaging.postal_code == city_clean,
|
||||
ServiceStaging.postal_code.ilike(f"{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -250,6 +304,7 @@ async def search_providers(
|
||||
literal(None).label("address_street_name"),
|
||||
literal(None).label("address_street_type"),
|
||||
literal(None).label("address_house_number"),
|
||||
literal(None).label("plus_code"),
|
||||
literal(False).label("is_verified"),
|
||||
literal(None).label("contact_phone"),
|
||||
literal(None).label("contact_email"),
|
||||
@@ -270,8 +325,13 @@ async def search_providers(
|
||||
)
|
||||
crowd_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
city_clean = city.strip()
|
||||
crowd_conditions.append(
|
||||
ServiceProvider.address.ilike(f"%{city}%")
|
||||
or_(
|
||||
ServiceProvider.address == city_clean,
|
||||
ServiceProvider.address.ilike(f"{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
crowd_stmt = select(
|
||||
@@ -283,6 +343,7 @@ async def search_providers(
|
||||
literal(None).label("address_street_name"),
|
||||
literal(None).label("address_street_type"),
|
||||
literal(None).label("address_house_number"),
|
||||
literal(None).label("plus_code"),
|
||||
literal(False).label("is_verified"),
|
||||
literal(None).label("contact_phone"),
|
||||
literal(None).label("contact_email"),
|
||||
@@ -315,6 +376,43 @@ async def search_providers(
|
||||
result = await db.execute(paginated_query)
|
||||
rows = result.fetchall()
|
||||
|
||||
# FEATURE (2026-06-17): Kategória adatok előtöltése.
|
||||
# Lekérdezzük az összes olyan ServiceProfile-hoz tartozó ExpertiseTag-eket,
|
||||
# amelyek a találati listában szereplő organization ID-khoz tartoznak.
|
||||
# Ezt egyetlen batch lekérdezéssel tesszük a N+1 probléma elkerülése érdekében.
|
||||
org_ids = [row.id for row in rows if row.source in ("verified_org", "crowd_added")]
|
||||
org_categories_map: dict[int, list[dict]] = {}
|
||||
if org_ids:
|
||||
cat_stmt = (
|
||||
select(
|
||||
ServiceProfile.organization_id,
|
||||
ExpertiseTag.id,
|
||||
ExpertiseTag.name_hu,
|
||||
ExpertiseTag.name_en,
|
||||
ExpertiseTag.level,
|
||||
ExpertiseTag.key,
|
||||
)
|
||||
.select_from(ServiceExpertise)
|
||||
.join(ExpertiseTag, ServiceExpertise.expertise_id == ExpertiseTag.id)
|
||||
.join(ServiceProfile, ServiceExpertise.service_id == ServiceProfile.id)
|
||||
.where(
|
||||
ServiceProfile.organization_id.in_(org_ids),
|
||||
)
|
||||
)
|
||||
cat_result = await db.execute(cat_stmt)
|
||||
cat_rows = cat_result.fetchall()
|
||||
for cat_row in cat_rows:
|
||||
org_id = cat_row.organization_id
|
||||
if org_id not in org_categories_map:
|
||||
org_categories_map[org_id] = []
|
||||
org_categories_map[org_id].append({
|
||||
"id": cat_row.id,
|
||||
"name_hu": cat_row.name_hu,
|
||||
"name_en": cat_row.name_en,
|
||||
"level": cat_row.level,
|
||||
"key": cat_row.key,
|
||||
})
|
||||
|
||||
for row in rows:
|
||||
tags_list: list = []
|
||||
spec_tags = getattr(row, "specialization_tags", None)
|
||||
@@ -323,6 +421,19 @@ async def search_providers(
|
||||
if user_tags:
|
||||
tags_list = list(user_tags)
|
||||
|
||||
# Kategória adatok összeállítása
|
||||
row_categories = org_categories_map.get(row.id, [])
|
||||
categories_out = [
|
||||
CategoryInfo(
|
||||
id=cat["id"],
|
||||
name_hu=cat["name_hu"],
|
||||
name_en=cat["name_en"],
|
||||
level=cat["level"],
|
||||
key=cat["key"],
|
||||
)
|
||||
for cat in row_categories
|
||||
]
|
||||
|
||||
results.append(
|
||||
ProviderSearchResult(
|
||||
id=row.id,
|
||||
@@ -333,10 +444,12 @@ async def search_providers(
|
||||
address_street_name=getattr(row, "address_street_name", None),
|
||||
address_street_type=getattr(row, "address_street_type", None),
|
||||
address_house_number=getattr(row, "address_house_number", None),
|
||||
plus_code=getattr(row, "plus_code", None),
|
||||
contact_phone=getattr(row, "contact_phone", None),
|
||||
contact_email=getattr(row, "contact_email", None),
|
||||
website=getattr(row, "website", None),
|
||||
tags=tags_list,
|
||||
categories=categories_out,
|
||||
source=row.source,
|
||||
is_verified=row.is_verified,
|
||||
)
|
||||
@@ -364,11 +477,16 @@ async def quick_add_provider(
|
||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||||
|
||||
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
|
||||
- Ha category_id nincs megadva, a primary_category kihagyásra kerül.
|
||||
- A 4-level kategória rendszerből érkező category_ids és new_tags
|
||||
feldolgozásra kerülnek a ServiceExpertise kapcsolatok létrehozásához.
|
||||
|
||||
Folyamat:
|
||||
1. Kategória ellenőrzése (expertise_tags)
|
||||
1. (Opcionális) Elsődleges kategória ellenőrzése (expertise_tags)
|
||||
2. Organization létrehozása (is_verified=False, org_type='service_provider')
|
||||
3. ServiceProfile létrehozása
|
||||
4. ServiceExpertise kapcsolat létrehozása
|
||||
4. ServiceExpertise kapcsolatok létrehozása (category_id + category_ids + new_tags)
|
||||
5. Branch létrehozása a címmel
|
||||
6. OrganizationMember létrehozása a felhasználóhoz
|
||||
|
||||
@@ -383,10 +501,15 @@ async def quick_add_provider(
|
||||
Raises:
|
||||
ValueError: Ha a kategória nem létezik
|
||||
"""
|
||||
# 1. Kategória ellenőrzése
|
||||
# 1. (Opcionális) Elsődleges kategória ellenőrzése
|
||||
primary_category_key = None
|
||||
if data.category_id is not None:
|
||||
category = await db.get(ExpertiseTag, data.category_id)
|
||||
if not category:
|
||||
raise ValueError(f"Category with id={data.category_id} not found")
|
||||
primary_category_key = category.key
|
||||
else:
|
||||
category = None
|
||||
|
||||
# 2. Organization létrehozása
|
||||
folder_slug = hashlib.md5(
|
||||
@@ -414,6 +537,7 @@ async def quick_add_provider(
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
plus_code=data.plus_code,
|
||||
owner_id=None,
|
||||
first_registered_at=datetime.now(timezone.utc),
|
||||
current_lifecycle_started_at=datetime.now(timezone.utc),
|
||||
@@ -428,7 +552,9 @@ async def quick_add_provider(
|
||||
).hexdigest()[:64]
|
||||
|
||||
# specialization_tags összeállítása: primary_category + user-supplied tags
|
||||
spec_tags = {"primary_category": category.key}
|
||||
spec_tags: dict = {}
|
||||
if primary_category_key:
|
||||
spec_tags["primary_category"] = primary_category_key
|
||||
if data.tags:
|
||||
spec_tags["user_tags"] = data.tags
|
||||
|
||||
@@ -445,13 +571,49 @@ async def quick_add_provider(
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
|
||||
# 4. ServiceExpertise kapcsolat
|
||||
# =====================================================================
|
||||
# 4. 🏗️ ServiceExpertise kapcsolatok létrehozása (4-LEVEL CATEGORY)
|
||||
# =====================================================================
|
||||
# Összegyűjtjük az összes kategória ID-t a ServiceExpertise kapcsolatokhoz.
|
||||
all_category_ids: List[int] = []
|
||||
|
||||
# 4a. Elsődleges kategória (ha meg van adva)
|
||||
if data.category_id is not None:
|
||||
all_category_ids.append(data.category_id)
|
||||
|
||||
# 4b. category_ids a 4-level checkboxokból
|
||||
safe_category_ids = data.category_ids or []
|
||||
if safe_category_ids:
|
||||
all_category_ids.extend(safe_category_ids)
|
||||
|
||||
# 4c. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
|
||||
safe_new_tags = data.new_tags or []
|
||||
new_tag_ids: List[int] = []
|
||||
if safe_new_tags:
|
||||
new_tag_ids = await _create_new_tags(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
new_tag_names=safe_new_tags,
|
||||
)
|
||||
if new_tag_ids:
|
||||
all_category_ids.extend(new_tag_ids)
|
||||
|
||||
# 4d. ServiceExpertise kapcsolatok létrehozása
|
||||
if all_category_ids:
|
||||
# Deduplikáció: csak egyedi ID-k
|
||||
unique_ids = list(set(all_category_ids))
|
||||
for expertise_id in unique_ids:
|
||||
expertise = ServiceExpertise(
|
||||
service_id=profile.id,
|
||||
expertise_id=data.category_id,
|
||||
expertise_id=expertise_id,
|
||||
confidence_level=50, # Közepes bizonyosság (crowdsourced)
|
||||
)
|
||||
db.add(expertise)
|
||||
logger.info(
|
||||
f"ServiceExpertise kapcsolatok létrehozva: service_id={profile.id}, "
|
||||
f"expertise_ids={unique_ids}"
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
# 5. Branch létrehozása atomizált címmezőkkel
|
||||
branch = Branch(
|
||||
@@ -467,6 +629,28 @@ async def quick_add_provider(
|
||||
)
|
||||
db.add(branch)
|
||||
|
||||
# =====================================================================
|
||||
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (P0 CRITICAL BUGFIX 2026-06-17)
|
||||
# =====================================================================
|
||||
# A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER).
|
||||
# Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL).
|
||||
# Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég
|
||||
# nem jelenik meg a "Cégeim" garázsválasztóban.
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=user_id,
|
||||
role=OrgUserRole.ADMIN,
|
||||
is_permanent=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(member)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"OrganizationMember created: org_id={org.id}, user_id={user_id}, "
|
||||
f"role=ADMIN (crowdsourced provider has no owner)"
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
# =====================================================================
|
||||
# 7. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
|
||||
# =====================================================================
|
||||
@@ -494,6 +678,173 @@ async def quick_add_provider(
|
||||
)
|
||||
|
||||
|
||||
async def _sync_service_expertises(
|
||||
db: AsyncSession,
|
||||
service_profile_id: int,
|
||||
category_ids: List[int],
|
||||
) -> None:
|
||||
"""
|
||||
Szinkronizálja a ServiceExpertise kapcsolatokat a megadott kategória ID-k alapján.
|
||||
|
||||
4-Level Category Hybrid Save (2026-06-17):
|
||||
- Eltávolítja a meglévő kapcsolatokat, amelyek nincsenek az új listában
|
||||
- Hozzáadja az új kapcsolatokat, amelyek még nem léteznek
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
service_profile_id: A ServiceProfile ID-ja
|
||||
category_ids: Az új kategória ID-k listája
|
||||
"""
|
||||
if not category_ids:
|
||||
return
|
||||
|
||||
# Meglévő kapcsolatok lekérése
|
||||
existing_stmt = select(ServiceExpertise).where(
|
||||
ServiceExpertise.service_id == service_profile_id
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
existing_expertises = existing_result.scalars().all()
|
||||
existing_ids = {e.expertise_id for e in existing_expertises}
|
||||
|
||||
# Eltávolítandó kapcsolatok (amik nincsenek az új listában)
|
||||
ids_to_remove = existing_ids - set(category_ids)
|
||||
if ids_to_remove:
|
||||
delete_stmt = delete(ServiceExpertise).where(
|
||||
ServiceExpertise.service_id == service_profile_id,
|
||||
ServiceExpertise.expertise_id.in_(ids_to_remove),
|
||||
)
|
||||
await db.execute(delete_stmt)
|
||||
logger.info(
|
||||
f"Eltávolított ServiceExpertise kapcsolatok: service_id={service_profile_id}, "
|
||||
f"expertise_ids={ids_to_remove}"
|
||||
)
|
||||
|
||||
# Hozzáadandó kapcsolatok (amik újak)
|
||||
ids_to_add = set(category_ids) - existing_ids
|
||||
for expertise_id in ids_to_add:
|
||||
new_expertise = ServiceExpertise(
|
||||
service_id=service_profile_id,
|
||||
expertise_id=expertise_id,
|
||||
confidence_level=50,
|
||||
)
|
||||
db.add(new_expertise)
|
||||
logger.info(
|
||||
f"Új ServiceExpertise kapcsolat: service_id={service_profile_id}, "
|
||||
f"expertise_id={expertise_id}"
|
||||
)
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""
|
||||
Biztonságos slug létrehozása magyar ékezetes karakterekkel.
|
||||
|
||||
- Kisbetűsítés
|
||||
- Ékezetes karakterek cseréje (á→a, é→e, í→i, ó→o, ö→o, ő→o, ú→u, ü→u, ű→u)
|
||||
- Nem alfanumerikus karakterek cseréje alulvonásra
|
||||
- Maximum 50 karakter
|
||||
"""
|
||||
replacements = {
|
||||
'á': 'a', 'é': 'e', 'í': 'i', 'ó': 'o', 'ö': 'o', 'ő': 'o',
|
||||
'ú': 'u', 'ü': 'u', 'ű': 'u',
|
||||
'Á': 'a', 'É': 'e', 'Í': 'i', 'Ó': 'o', 'Ö': 'o', 'Ő': 'o',
|
||||
'Ú': 'u', 'Ü': 'u', 'Ű': 'u',
|
||||
}
|
||||
result = text.lower().strip()
|
||||
for hungarian, ascii_char in replacements.items():
|
||||
result = result.replace(hungarian, ascii_char)
|
||||
# Replace any non-alphanumeric character (except underscore) with underscore
|
||||
result = re.sub(r'[^a-z0-9_]', '_', result)
|
||||
# Collapse multiple underscores
|
||||
result = re.sub(r'_+', '_', result)
|
||||
# Strip leading/trailing underscores
|
||||
result = result.strip('_')
|
||||
return result[:50]
|
||||
|
||||
|
||||
async def _create_new_tags(
|
||||
db: AsyncSession,
|
||||
service_profile_id: int,
|
||||
new_tag_names: List[str],
|
||||
) -> List[int]:
|
||||
"""
|
||||
Létrehoz új ExpertiseTag rekordokat a user által gépelt címkékből.
|
||||
|
||||
P0 CRITICAL BUGFIX (2026-06-17): try-except blokk + slugify + hibanaplózás.
|
||||
|
||||
4-Level Category Hybrid Save (2026-06-17):
|
||||
- Ha a címke string már létezik az ExpertiseTag táblában, visszaadja az ID-ját
|
||||
- Ha a címke vadiúj, létrehozza is_official=False és level=3 értékekkel
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
service_profile_id: A ServiceProfile ID-ja (a kapcsolathoz)
|
||||
new_tag_names: A user által gépelt új címkenevek listája
|
||||
|
||||
Returns:
|
||||
List[int]: A létrehozott/megtalált ExpertiseTag ID-k listája
|
||||
"""
|
||||
if not new_tag_names:
|
||||
return []
|
||||
|
||||
created_ids = []
|
||||
|
||||
for tag_name in new_tag_names:
|
||||
tag_name = tag_name.strip()
|
||||
if not tag_name:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Ellenőrizzük, hogy létezik-e már
|
||||
existing_stmt = select(ExpertiseTag).where(
|
||||
or_(
|
||||
ExpertiseTag.key == _slugify(tag_name),
|
||||
ExpertiseTag.name_hu == tag_name,
|
||||
ExpertiseTag.name_en == tag_name,
|
||||
)
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
existing_tag = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_tag:
|
||||
# Már létezik, használjuk a meglévő ID-t
|
||||
created_ids.append(existing_tag.id)
|
||||
logger.info(
|
||||
f"Meglévő címke használata: tag_name={tag_name}, "
|
||||
f"existing_id={existing_tag.id}"
|
||||
)
|
||||
else:
|
||||
# Vadiúj címke létrehozása slugify-jal
|
||||
new_key = _slugify(tag_name)
|
||||
new_tag = ExpertiseTag(
|
||||
key=new_key,
|
||||
name_hu=tag_name,
|
||||
name_en=tag_name,
|
||||
level=3,
|
||||
is_official=False,
|
||||
category="user_created",
|
||||
parent_id=None,
|
||||
path=None,
|
||||
search_keywords=[tag_name.lower()],
|
||||
description=f"User-created tag: {tag_name}",
|
||||
)
|
||||
db.add(new_tag)
|
||||
await db.flush()
|
||||
created_ids.append(new_tag.id)
|
||||
logger.info(
|
||||
f"Új címke létrehozva: tag_name={tag_name}, "
|
||||
f"new_id={new_tag.id}, key={new_key}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"HIBA az új címke létrehozásakor: tag_name={tag_name}, "
|
||||
f"error={e}", exc_info=True
|
||||
)
|
||||
# Nem szakítjuk meg a teljes folyamatot egyetlen hibás címke miatt
|
||||
continue
|
||||
|
||||
return created_ids
|
||||
|
||||
|
||||
async def update_provider(
|
||||
db: AsyncSession,
|
||||
provider_id: int,
|
||||
@@ -507,6 +858,11 @@ async def update_provider(
|
||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||||
|
||||
4-Level Category Hybrid Save (2026-06-17):
|
||||
- category_ids: ServiceExpertise kapcsolatok frissítése
|
||||
- new_tags: Új ExpertiseTag létrehozása (is_official=False, level=3)
|
||||
majd ServiceExpertise kapcsolat létrehozása
|
||||
|
||||
Multi-source update (2026-06-17): Támogatja mindhárom forrást:
|
||||
1. fleet.organizations (verified orgs) - közvetlen frissítés
|
||||
2. marketplace.service_staging (robot adatok) - migrálás Organization-be
|
||||
@@ -527,15 +883,43 @@ async def update_provider(
|
||||
|
||||
Raises:
|
||||
ValueError: Ha a szolgáltató nem található
|
||||
PermissionError: Ha a felhasználó nem tagja a szervezetnek
|
||||
"""
|
||||
# 1. Organization lekérdezése
|
||||
org = await db.get(Organization, provider_id)
|
||||
|
||||
if org:
|
||||
# =====================================================================
|
||||
# 1a. 🔐 ACCESS CONTROL ELLENŐRZÉS (P0 CRITICAL BUGFIX 2026-06-17)
|
||||
# =====================================================================
|
||||
# Csak azok a felhasználók szerkeszthetik a szolgáltatót, akik
|
||||
# OWNER vagy ADMIN szerepkörű tagok a szervezetben.
|
||||
member_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org.id,
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN]),
|
||||
)
|
||||
member_result = await db.execute(member_stmt)
|
||||
member = member_result.scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
logger.warning(
|
||||
f"Access denied: user_id={user_id} attempted to update "
|
||||
f"org_id={org.id} but is not an OWNER or ADMIN member"
|
||||
)
|
||||
raise PermissionError(
|
||||
f"Nincs jogosultságod szerkeszteni ezt a szolgáltatót. "
|
||||
f"Csak a szervezet tulajdonosa vagy adminisztrátora "
|
||||
f"módosíthatja az adatokat."
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
if not org:
|
||||
# 1b. Ha nincs Organization-ben, ServiceStaging-ben keresünk
|
||||
staging = await db.get(ServiceStaging, provider_id)
|
||||
if staging:
|
||||
# Migráljuk Organization-be
|
||||
now = datetime.now(timezone.utc)
|
||||
org = Organization(
|
||||
id=staging.id,
|
||||
name=staging.name[:100],
|
||||
@@ -549,7 +933,16 @@ async def update_provider(
|
||||
address_house_number=data.address_house_number,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=f"sp-{staging.id}-{uuid.uuid4().hex[:6]}",
|
||||
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||
first_registered_at=now,
|
||||
current_lifecycle_started_at=now,
|
||||
created_at=now,
|
||||
subscription_plan="FREE",
|
||||
base_asset_limit=1,
|
||||
purchased_extra_slots=0,
|
||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||
external_integration_config={},
|
||||
is_ownership_transferable=True,
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
@@ -562,6 +955,7 @@ async def update_provider(
|
||||
crowd = await db.get(ServiceProvider, provider_id)
|
||||
if crowd:
|
||||
# Migráljuk Organization-be
|
||||
now = datetime.now(timezone.utc)
|
||||
org = Organization(
|
||||
id=crowd.id,
|
||||
name=crowd.name[:100],
|
||||
@@ -575,7 +969,16 @@ async def update_provider(
|
||||
address_house_number=data.address_house_number,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}",
|
||||
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||
first_registered_at=now,
|
||||
current_lifecycle_started_at=now,
|
||||
created_at=now,
|
||||
subscription_plan="FREE",
|
||||
base_asset_limit=1,
|
||||
purchased_extra_slots=0,
|
||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||
external_integration_config={},
|
||||
is_ownership_transferable=True,
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
@@ -605,6 +1008,8 @@ async def update_provider(
|
||||
org.address_street_type = data.address_street_type
|
||||
if data.address_house_number is not None:
|
||||
org.address_house_number = data.address_house_number
|
||||
if data.plus_code is not None:
|
||||
org.plus_code = data.plus_code
|
||||
|
||||
# 3. ServiceProfile lekérdezése (ha létezik)
|
||||
profile_stmt = select(ServiceProfile).where(
|
||||
@@ -625,26 +1030,156 @@ async def update_provider(
|
||||
spec_tags["user_tags"] = data.tags
|
||||
profile.specialization_tags = spec_tags
|
||||
|
||||
# =====================================================================
|
||||
# 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17)
|
||||
# =====================================================================
|
||||
|
||||
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy
|
||||
# new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et.
|
||||
|
||||
# Összegyűjtjük az összes kategória ID-t (meglévő + új)
|
||||
all_category_ids: List[int] = []
|
||||
|
||||
# 5a. category_ids -> ServiceExpertise kapcsolatok frissítése
|
||||
safe_category_ids = data.category_ids or []
|
||||
if safe_category_ids:
|
||||
all_category_ids.extend(safe_category_ids)
|
||||
|
||||
# 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
|
||||
safe_new_tags = data.new_tags or []
|
||||
if safe_new_tags:
|
||||
new_tag_ids = await _create_new_tags(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
new_tag_names=safe_new_tags,
|
||||
)
|
||||
if new_tag_ids:
|
||||
all_category_ids.extend(new_tag_ids)
|
||||
|
||||
# 5c. Egyszeri szinkronizálás az összes kategória ID-val
|
||||
if all_category_ids:
|
||||
await _sync_service_expertises(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
category_ids=all_category_ids,
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
logger.info(
|
||||
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
|
||||
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
|
||||
f"website={data.website}, tags={data.tags}"
|
||||
f"website={data.website}, tags={data.tags}, "
|
||||
f"category_ids={data.category_ids}, new_tags={data.new_tags}"
|
||||
)
|
||||
else:
|
||||
# P0 CRITICAL BUGFIX (2026-06-17): Ha nincs ServiceProfile, létrehozzuk.
|
||||
# Ez biztosítja, hogy a contact_phone, contact_email, website, tags
|
||||
# és category_ids adatok mentésre kerüljenek, még akkor is, ha a
|
||||
# szolgáltató korábban csak Organization-ként létezett.
|
||||
logger.warning(
|
||||
f"Provider update: org_id={org.id} has no ServiceProfile. "
|
||||
f"Only Organization fields updated."
|
||||
f"Creating one now."
|
||||
)
|
||||
fingerprint = hashlib.sha256(
|
||||
f"update-create-{org.id}-{datetime.now().isoformat()}".encode()
|
||||
).hexdigest()[:64]
|
||||
profile = ServiceProfile(
|
||||
organization_id=org.id,
|
||||
fingerprint=fingerprint,
|
||||
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
|
||||
contact_phone=data.contact_phone,
|
||||
contact_email=data.contact_email,
|
||||
website=data.website,
|
||||
specialization_tags={"user_tags": data.tags} if data.tags else {},
|
||||
status=ServiceStatus.active,
|
||||
)
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ServiceProfile created for org_id={org.id}, profile_id={profile.id}"
|
||||
)
|
||||
|
||||
# 4. ServiceProfile mezők frissítése
|
||||
profile.contact_phone = data.contact_phone
|
||||
profile.contact_email = data.contact_email
|
||||
profile.website = data.website
|
||||
|
||||
# specialization_tags frissítése
|
||||
if data.tags is not None:
|
||||
spec_tags = profile.specialization_tags or {}
|
||||
spec_tags["user_tags"] = data.tags
|
||||
profile.specialization_tags = spec_tags
|
||||
|
||||
# =====================================================================
|
||||
# 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17)
|
||||
# =====================================================================
|
||||
|
||||
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy
|
||||
# new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et.
|
||||
|
||||
# Összegyűjtjük az összes kategória ID-t (meglévő + új)
|
||||
all_category_ids: List[int] = []
|
||||
|
||||
# 5a. category_ids -> ServiceExpertise kapcsolatok frissítése
|
||||
safe_category_ids = data.category_ids or []
|
||||
if safe_category_ids:
|
||||
all_category_ids.extend(safe_category_ids)
|
||||
|
||||
# 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
|
||||
safe_new_tags = data.new_tags or []
|
||||
if safe_new_tags:
|
||||
new_tag_ids = await _create_new_tags(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
new_tag_names=safe_new_tags,
|
||||
)
|
||||
if new_tag_ids:
|
||||
all_category_ids.extend(new_tag_ids)
|
||||
|
||||
# 5c. Egyszeri szinkronizálás az összes kategória ID-val
|
||||
if all_category_ids:
|
||||
await _sync_service_expertises(
|
||||
db=db,
|
||||
service_profile_id=profile.id,
|
||||
category_ids=all_category_ids,
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
logger.info(
|
||||
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
|
||||
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
|
||||
f"website={data.website}, tags={data.tags}, "
|
||||
f"category_ids={data.category_ids}, new_tags={data.new_tags}"
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# 6. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás szerkesztésért)
|
||||
# =====================================================================
|
||||
# A felhasználó aktuális penalty_level-jétől (restriction_level) függően
|
||||
# kalkuláljuk a kapott pontot. A GamificationService.process_activity()
|
||||
# automatikusan alkalmazza a büntetési szorzót a _calculate_multiplier-en
|
||||
# keresztül, így:
|
||||
# - Szint 0 (L0) = 100% pont
|
||||
# - Szint 1 (L1) = 50% pont
|
||||
# - Szint 2 (L2) = 10% pont
|
||||
# - Szint 3 (L3) = 0% pont (blokkolt)
|
||||
earned_points = await _award_provider_points(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
action_key="UPDATE_PROVIDER",
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Provider updated successfully: org_id={org.id}, "
|
||||
f"name={data.name}, user_id={user_id}"
|
||||
f"name={data.name}, user_id={user_id}, earned_points={earned_points}"
|
||||
)
|
||||
|
||||
return ProviderUpdateResponse(
|
||||
id=org.id,
|
||||
name=data.name,
|
||||
message="Szolgáltató adatai sikeresen frissítve.",
|
||||
earned_points=earned_points,
|
||||
)
|
||||
|
||||
1011
backend/scripts/seed_expertise_taxonomy.py
Normal file
1011
backend/scripts/seed_expertise_taxonomy.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,5 +34,43 @@
|
||||
"BUTTON": "Go to Dashboard",
|
||||
"FOOTER": "Service Finder - Test system message"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organization not found.",
|
||||
"INVALID_TAX_FORMAT": "Invalid Hungarian tax number format!",
|
||||
"TAX_LOOKUP_FAILED": "Tax number lookup failed.",
|
||||
"FORBIDDEN": "You do not have permission to access this organization.",
|
||||
"INVALID_ROLE": "Invalid role specified.",
|
||||
"CANNOT_REMOVE_OWNER": "The owner cannot be removed from the organization.",
|
||||
"CANNOT_DEMOTE_OWNER": "The owner's role cannot be changed.",
|
||||
"ALREADY_MEMBER": "You are already a member of this organization.",
|
||||
"NO_ACTIVE_ADMIN": "This organization has no active admin. Use the /claim endpoint.",
|
||||
"HAS_ACTIVE_ADMIN": "This organization has an active admin. Use the /join-request endpoint.",
|
||||
"EMAIL_MISMATCH": "The email does not match the organization owner's email.",
|
||||
"INVALID_CODE": "Invalid or expired code.",
|
||||
"CODE_ORG_MISMATCH": "The code does not belong to this organization.",
|
||||
"INVITE_EXPIRED": "The invitation has expired.",
|
||||
"INVITE_INVALID": "Invalid invitation token."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Company successfully created and registered.",
|
||||
"MEMBER_REMOVED": "Member successfully removed.",
|
||||
"ROLE_UPDATED": "Role successfully updated.",
|
||||
"UPDATED": "Organization data successfully updated.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Visual settings successfully updated."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Invitation created successfully.",
|
||||
"ACCEPTED": "Invitation accepted.",
|
||||
"REVOKED": "Invitation revoked."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Your join request has been submitted. Waiting for admin approval."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "A 6-digit verification code has been sent to the organization owner's email.",
|
||||
"SUCCESS": "You have successfully taken over the organization. You are now the admin."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,5 +41,43 @@
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Sikeres mentés!"
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Szervezet nem található.",
|
||||
"INVALID_TAX_FORMAT": "Érvénytelen magyar adószám formátum!",
|
||||
"TAX_LOOKUP_FAILED": "Adószám lekérdezés sikertelen.",
|
||||
"FORBIDDEN": "Nincs jogosultságod ehhez a szervezethez.",
|
||||
"INVALID_ROLE": "Érvénytelen szerepkör.",
|
||||
"CANNOT_REMOVE_OWNER": "A tulajdonos nem távolítható el a szervezetből.",
|
||||
"CANNOT_DEMOTE_OWNER": "A tulajdonos szerepköre nem módosítható.",
|
||||
"ALREADY_MEMBER": "Már tagja vagy ennek a szervezetnek.",
|
||||
"NO_ACTIVE_ADMIN": "A szervezetnek nincs aktív adminja. Használd a /claim végpontot.",
|
||||
"HAS_ACTIVE_ADMIN": "A szervezetnek van aktív adminja. Használd a /join-request végpontot.",
|
||||
"EMAIL_MISMATCH": "Az email cím nem egyezik a cég tulajdonosának email címével.",
|
||||
"INVALID_CODE": "Érvénytelen vagy lejárt kód.",
|
||||
"CODE_ORG_MISMATCH": "A kód nem ehhez a szervezethez tartozik.",
|
||||
"INVITE_EXPIRED": "A meghívó lejárt.",
|
||||
"INVITE_INVALID": "Érvénytelen meghívó token."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Cég sikeresen létrehozva és regisztrálva.",
|
||||
"MEMBER_REMOVED": "Tag sikeresen eltávolítva.",
|
||||
"ROLE_UPDATED": "Szerepkör sikeresen frissítve.",
|
||||
"UPDATED": "Szervezet adatai sikeresen frissítve.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Vizuális beállítások sikeresen frissítve."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Meghívó sikeresen létrehozva.",
|
||||
"ACCEPTED": "Meghívó elfogadva.",
|
||||
"REVOKED": "Meghívó visszavonva."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Csatlakozási kérelmed rögzítettük. Az admin jóváhagyására vársz."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.",
|
||||
"SUCCESS": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin."
|
||||
}
|
||||
}
|
||||
}
|
||||
219
backend/tests/test_garage_selector_and_team_api.py
Normal file
219
backend/tests/test_garage_selector_and_team_api.py
Normal file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Teszt script a P0 Garage Selector Fix & Team Management API ellenőrzéséhez.
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/tests/active/test_garage_selector_and_team_api.py
|
||||
|
||||
Ez a script:
|
||||
1. Bejelentkezik admin@profibot.hu / Admin123! (Vezető Tervező, user_id=2)
|
||||
2. Meghívja a GET /api/v1/organizations/my végpontot
|
||||
3. Ellenőrzi, hogy a teszt cég (owner_id=2) megjelenik-e
|
||||
4. Teszteli az új Team Management API végpontokat
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Add the backend directory to path
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
API_PREFIX = "/api/v1"
|
||||
|
||||
|
||||
async def login(email: str, password: str) -> str:
|
||||
"""Bejelentkezés és token lekérése."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.post(
|
||||
f"{API_PREFIX}/auth/login",
|
||||
data={"username": email, "password": password},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Login failed: {resp.status_code} - {resp.text}")
|
||||
sys.exit(1)
|
||||
data = resp.json()
|
||||
token = data.get("access_token") or data.get("token")
|
||||
print(f"✅ Login successful (user: {email})")
|
||||
return token
|
||||
|
||||
|
||||
async def get_my_organizations(token: str) -> list:
|
||||
"""GET /api/v1/organizations/my - Saját szervezetek lekérése."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.get(
|
||||
f"{API_PREFIX}/organizations/my",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 GET /api/v1/organizations/my")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Error: {resp.text}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
print(f"Count: {len(data)} organizations found")
|
||||
print(f"\nRaw response:")
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def list_members(token: str, org_id: int) -> list:
|
||||
"""GET /api/v1/organizations/{org_id}/members - Tagok listázása."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.get(
|
||||
f"{API_PREFIX}/organizations/{org_id}/members",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 GET /api/v1/organizations/{org_id}/members")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Error: {resp.text}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
print(f"Count: {len(data)} members")
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
return data
|
||||
|
||||
|
||||
async def create_invitation(token: str, org_id: int, email: str, role: str = "MANAGER") -> dict:
|
||||
"""POST /api/v1/organizations/{org_id}/invitations - Meghívó küldése."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.post(
|
||||
f"{API_PREFIX}/organizations/{org_id}/invitations",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"email": email, "role": role},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 POST /api/v1/organizations/{org_id}/invitations (email={email}, role={role})")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text}")
|
||||
|
||||
if resp.status_code in (200, 201):
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def update_member_role(token: str, org_id: int, member_id: int, role: str) -> dict:
|
||||
"""PATCH /api/v1/organizations/{org_id}/members/{member_id} - Szerepkör módosítása."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.patch(
|
||||
f"{API_PREFIX}/organizations/{org_id}/members/{member_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"role": role},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 PATCH /api/v1/organizations/{org_id}/members/{member_id} (role={role})")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def remove_member(token: str, org_id: int, member_id: int) -> dict:
|
||||
"""DELETE /api/v1/organizations/{org_id}/members/{member_id} - Tag eltávolítása."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.delete(
|
||||
f"{API_PREFIX}/organizations/{org_id}/members/{member_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 DELETE /api/v1/organizations/{org_id}/members/{member_id}")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🔍 P0 GARAGE SELECTOR FIX & TEAM MANAGEMENT API TESZT")
|
||||
print("=" * 70)
|
||||
|
||||
# 1. Bejelentkezés admin@profibot.hu (Vezető Tervező, user_id=2)
|
||||
token = await login("admin@profibot.hu", "Admin123!")
|
||||
|
||||
# 2. Saját szervezetek lekérése
|
||||
orgs = await get_my_organizations(token)
|
||||
|
||||
if not orgs:
|
||||
print("\n❌ CRITICAL: No organizations returned! Garage Selector would be empty!")
|
||||
print(" This means the get_my_organizations query is still broken.")
|
||||
sys.exit(1)
|
||||
|
||||
# 3. Ellenőrizzük, hogy van-e olyan org ahol owner_id=2 (admin user)
|
||||
admin_owned = [o for o in orgs if o.get("owner_id") == 2]
|
||||
if admin_owned:
|
||||
print(f"\n✅ Garage Selector FIX VERIFIED: {len(admin_owned)} organization(s) where owner_id=2:")
|
||||
for o in admin_owned:
|
||||
print(f" - ID: {o['organization_id']}, Name: {o.get('name', 'N/A')}, Role: {o.get('user_role', 'N/A')}")
|
||||
else:
|
||||
print(f"\n⚠️ No organizations found with owner_id=2. Checking all orgs...")
|
||||
for o in orgs:
|
||||
print(f" - ID: {o['organization_id']}, Name: {o.get('name', 'N/A')}, Owner: {o.get('owner_id', 'N/A')}, Role: {o.get('user_role', 'N/A')}")
|
||||
|
||||
# 4. Ha van legalább egy szervezet, teszteljük a Team Management API-t
|
||||
if orgs:
|
||||
test_org_id = orgs[0]["organization_id"]
|
||||
print(f"\n{'='*70}")
|
||||
print(f"🧪 TEAM MANAGEMENT API TESZTEK (org_id={test_org_id})")
|
||||
print(f"{'='*70}")
|
||||
|
||||
# 4a. Tagok listázása
|
||||
members = await list_members(token, test_org_id)
|
||||
|
||||
# 4b. Meghívó küldése egy nem létező emailre (invited_email teszt)
|
||||
test_email = f"test_invite_{datetime.now(timezone.utc).timestamp()}@example.com"
|
||||
invite_result = await create_invitation(token, test_org_id, test_email, "MANAGER")
|
||||
|
||||
if invite_result:
|
||||
print(f"\n✅ Invitation created successfully!")
|
||||
|
||||
# 4c. Tagok újralistázása (ellenőrizzük, hogy megjelent-e a meghívó)
|
||||
members_after = await list_members(token, test_org_id)
|
||||
|
||||
# Keressük az új meghívót
|
||||
pending_invites = [m for m in members_after if m.get("status") == "pending"]
|
||||
if pending_invites:
|
||||
print(f"\n✅ Pending invitation found in member list!")
|
||||
new_member_id = pending_invites[0]["id"]
|
||||
|
||||
# 4d. Szerepkör módosítása
|
||||
role_result = await update_member_role(token, test_org_id, new_member_id, "ADMIN")
|
||||
if role_result:
|
||||
print(f"\n✅ Role update successful!")
|
||||
|
||||
# 4e. Tag eltávolítása (meghívó visszavonása)
|
||||
remove_result = await remove_member(token, test_org_id, new_member_id)
|
||||
if remove_result:
|
||||
print(f"\n✅ Member removal successful!")
|
||||
else:
|
||||
print(f"\n⚠️ No pending invitations found in member list.")
|
||||
else:
|
||||
print(f"\n⚠️ Could not create test invitation (might already exist).")
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print("✅ TESZT BEFEJEZŐDÖTT")
|
||||
print(f"{'='*70}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
128
docs/organization_member_audit_report_2026-06-17.md
Normal file
128
docs/organization_member_audit_report_2026-06-17.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# 🏗️ OrganizationMember Audit Report & Garage Selector Implementation
|
||||
|
||||
**Dátum:** 2026-06-17
|
||||
**Készítette:** Fast Coder (Core Developer)
|
||||
**Verzió:** 1.0
|
||||
|
||||
---
|
||||
|
||||
## 1. Végrehajtott Módosítások
|
||||
|
||||
### 1.1 Backend: `get_my_organizations` végpont javítása
|
||||
|
||||
**Fájl:** [`backend/app/api/v1/endpoints/organizations.py`](../backend/app/api/v1/endpoints/organizations.py:198)
|
||||
|
||||
A lekérdezés logikája az alábbiakra változott:
|
||||
|
||||
- **RÉGI:** `INNER JOIN` az `OrganizationMember` táblán → csak azokat a szervezeteket adta vissza, ahol a felhasználónak volt member rekordja. Kizárta a tulajdonosi viszonyt (`owner_id`).
|
||||
- **ÚJ:** `LEFT JOIN` + `OR` feltétel → visszaadja azokat a szervezeteket, ahol a felhasználó:
|
||||
- a) Technikai tulajdonos (`Organization.owner_id == current_user.id`)
|
||||
- **VAGY**
|
||||
- b) Szerepel az `OrganizationMember` táblában tagként (`OrganizationMember.user_id == current_user.id`)
|
||||
|
||||
**SQL változás:**
|
||||
```python
|
||||
# RÉGI (hibás):
|
||||
select(Organization).join(OrganizationMember).where(OrganizationMember.user_id == current_user.id)
|
||||
|
||||
# ÚJ (javított):
|
||||
select(Organization).outerjoin(OrganizationMember, OrganizationMember.organization_id == Organization.id)\
|
||||
.where(or_(Organization.owner_id == current_user.id, OrganizationMember.user_id == current_user.id))\
|
||||
.distinct()
|
||||
```
|
||||
|
||||
**Válasz bővítése:** A végpont most `user_role` mezőt is visszaad, amely a felhasználó szerepkörét tartalmazza az adott szervezetben (`OWNER`, `ADMIN`, `FLEET_MANAGER`, `DRIVER`, `MECHANIC`, `RECEPTIONIST`). Ha a felhasználó a tulajdonos, de nincs member rekordja, `"OWNER"`-t ad vissza.
|
||||
|
||||
### 1.2 Backend: `OrganizationMember` modell bővítése
|
||||
|
||||
**Fájl:** [`backend/app/models/marketplace/organization.py`](../backend/app/models/marketplace/organization.py:195)
|
||||
|
||||
A modellhez az alábbi hiányzó oszlopok kerültek hozzáadásra:
|
||||
|
||||
| Oszlop | Típus | Alapértelmezés | Leírás |
|
||||
|--------|-------|----------------|--------|
|
||||
| `status` | `String(20)` | `'active'` | Tag státusza (`active`, `pending`, `inactive`) |
|
||||
| `created_at` | `DateTime(timezone=True)` | `func.now()` | Létrehozás időpontja |
|
||||
| `updated_at` | `DateTime(timezone=True)` | `onupdate=func.now()` | Utolsó módosítás időpontja |
|
||||
|
||||
Ezek az oszlopok hiányoztak az adatbázisból, de a kód már hivatkozott rájuk (pl. `organizations.py:421,481` sorokban `status="pending"`, `status="active"`), ami futási hibát okozott volna.
|
||||
|
||||
### 1.3 Adatbázis szinkronizáció
|
||||
|
||||
A `sync_engine` sikeresen hozzáadta a 3 hiányzó oszlopot a `fleet.organization_members` táblához.
|
||||
|
||||
---
|
||||
|
||||
## 2. Audit Jelentés: OrganizationMember Tábla
|
||||
|
||||
### 2.1 Oszlopok (Jelenlegi állapot a javítás után)
|
||||
|
||||
| # | Oszlop | Típus | Kötelező | Alapértelmezett | Megjegyzés |
|
||||
|---|--------|-------|----------|-----------------|------------|
|
||||
| 1 | `id` | `integer` | YES | auto-increment | PK |
|
||||
| 2 | `organization_id` | `integer` | YES | - | FK → `fleet.organizations.id` |
|
||||
| 3 | `user_id` | `integer` | NO | - | FK → `identity.users.id` (lehet NULL pending invite-nál) |
|
||||
| 4 | `person_id` | `bigint` | NO | - | FK → `identity.persons.id` |
|
||||
| 5 | `role` | `ENUM` (OrgUserRole) | YES | `DRIVER` | OWNER, ADMIN, FLEET_MANAGER, DRIVER, MECHANIC, RECEPTIONIST |
|
||||
| 6 | `permissions` | `json` | NO | `{}` | JSONB jogosultságok |
|
||||
| 7 | `is_permanent` | `boolean` | NO | `false` | Állandó vs. ideiglenes tagság |
|
||||
| 8 | `is_verified` | `boolean` | NO | `false` | Ellenőrzött tag |
|
||||
| 9 | **`status`** 🆕 | `varchar(20)` | NO | `'active'` | Tag státusza |
|
||||
| 10 | **`created_at`** 🆕 | `timestamptz` | NO | `now()` | Létrehozás dátuma |
|
||||
| 11 | **`updated_at`** 🆕 | `timestamptz` | YES | - | Módosítás dátuma |
|
||||
|
||||
### 2.2 Meghívási Folyamat (Invite Flow)
|
||||
|
||||
**Meglévő API végpontok:**
|
||||
|
||||
| Végpont | Metódus | Státusz | Leírás |
|
||||
|---------|---------|---------|--------|
|
||||
| `POST /api/v1/organizations/{org_id}/invitations` | POST | ✅ Létezik | Meghívó küldése email címre. Ha a user létezik → pending tag létrehozása. Ha nem → `VerificationToken` (`org_invite` típus) |
|
||||
| `POST /api/v1/organizations/invitations/{token}/accept` | POST | ✅ Létezik | Meghívó elfogadása token alapján (regisztráció után) |
|
||||
| `POST /api/v1/organizations/{org_id}/join-request` | POST | ✅ Létezik | Csatlakozási kérelem (ha van aktív admin) |
|
||||
| `POST /api/v1/organizations/{org_id}/claim/request` | POST | ✅ Létezik | Árva cég átvételi kérelem (OTP küldés) |
|
||||
| `POST /api/v1/organizations/{org_id}/claim/verify` | POST | ✅ Létezik | Árva cég átvétel OTP-vel |
|
||||
|
||||
**Hiányzó API végpontok (P2 - Következő iteráció):**
|
||||
|
||||
| Végpont | Metódus | Hiány | Hatás |
|
||||
|---------|---------|-------|-------|
|
||||
| `GET /api/v1/organizations/{org_id}/members` | GET | ❌ Hiányzik | Nincs lehetőség a tagok listázására |
|
||||
| `PATCH /api/v1/organizations/{org_id}/members/{member_id}/role` | PATCH | ❌ Hiányzik | Nincs lehetőség a szerepkör módosítására |
|
||||
| `DELETE /api/v1/organizations/{org_id}/members/{member_id}` | DELETE | ❌ Hiányzik | Nincs lehetőség a tag eltávolítására |
|
||||
| `GET /api/v1/organizations/{org_id}/invitations` | GET | ❌ Hiányzik | Nincs lehetőség a függő meghívók listázására |
|
||||
| `DELETE /api/v1/organizations/{org_id}/invitations/{invitation_id}` | DELETE | ❌ Hiányzik | Nincs lehetőség a meghívó visszavonására |
|
||||
|
||||
### 2.3 Jogosultságkezelés
|
||||
|
||||
- **Tag felvétele:** A `POST /invitations` végponton keresztül lehetséges (meghívó küldése). A `POST /join-request` lehetővé teszi a felhasználóknak, hogy maguk kérjenek csatlakozást.
|
||||
- **Szerepkör módosítása:** ❌ **Nincs implementálva.** Nincs PATCH végpont a tag szerepkörének módosítására.
|
||||
- **Tag eltávolítása:** ❌ **Nincs implementálva.** Nincs DELETE végpont a tag eltávolítására.
|
||||
- **Meghívó visszavonása:** ❌ **Nincs implementálva.** Nincs DELETE végpont a függő meghívók visszavonására.
|
||||
|
||||
### 2.4 Javasolt Javítási Sorrend
|
||||
|
||||
1. **P0 - KÉSZ:** `status`, `created_at`, `updated_at` oszlopok hozzáadva az `OrganizationMember` modellhez
|
||||
2. **P0 - KÉSZ:** `get_my_organizations` javítva `OR` logikára + `user_role` visszaadása
|
||||
3. **P1 - JELEN FELADAT:** Tagkezelő API végpontok implementálása (list, role change, remove)
|
||||
4. **P2 - KÖVETKEZŐ:** Meghívókezelő API végpontok (list pending, revoke)
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Állapot
|
||||
|
||||
A frontend oldalon **nem volt szükség változtatásra**, mivel:
|
||||
|
||||
1. A [`HeaderCompanySwitcher.vue`](../frontend/src/components/header/HeaderCompanySwitcher.vue:132) már használja az `authStore.fetchMyOrganizations()` hívást, ami a `/organizations/my` végpontot hívja.
|
||||
2. A `companyOrganizations` computed property (144. sor) megfelelően szűri az `individual`, `service_provider`, `service` típusokat.
|
||||
3. A [`authStore.switchOrganization()`](../frontend/src/stores/auth.ts:631) metódus már implementálva van a `PATCH /users/me/active-organization` hívással.
|
||||
4. Az [`OrganizationItem` típus](../frontend/src/types/organization.ts:5) már tartalmazza a `user_role` mezőt (nem kötelező).
|
||||
|
||||
---
|
||||
|
||||
## 4. Teszt Eredmények
|
||||
|
||||
- ✅ `sync_engine` sikeresen lefutott - 3 hiányzó oszlop hozzáadva
|
||||
- ✅ `GET /api/v1/organizations/my` végpont működik, visszaadja a `user_role` mezőt
|
||||
- ✅ A végpont helyesen szűri a `service_provider` típusú szervezeteket
|
||||
- ✅ Az adatbázis séma konzisztens a kóddal
|
||||
@@ -139,9 +139,14 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
// ── Filter: only real companies (exclude individual/private orgs) ──
|
||||
// Also exclude service_provider and service types — those belong to the
|
||||
// Marketplace / Service Finder domain, not the user's own companies.
|
||||
const companyOrganizations = computed(() => {
|
||||
return authStore.myOrganizations.filter(
|
||||
(org) => org.org_type && org.org_type !== 'individual'
|
||||
(org) => org.org_type &&
|
||||
org.org_type !== 'individual' &&
|
||||
org.org_type !== 'service_provider' &&
|
||||
org.org_type !== 'service'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('serviceFinder.detailTitle') }}</h3>
|
||||
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.detailTitle') }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -33,25 +33,117 @@
|
||||
|
||||
<!-- ── Body ── -->
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Provider Name -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-extrabold text-slate-800">{{ provider.name }}</h2>
|
||||
<!-- Provider Name + Source Badge -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-extrabold text-slate-800 truncate">{{ provider.name }}</h2>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold mt-1"
|
||||
:class="sourceBadgeClass"
|
||||
>
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div v-if="provider.category" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<!-- ── Categories (chips/badges) ── -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailCategories') }}</p>
|
||||
<div v-if="provider.categories && provider.categories.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="cat in provider.categories"
|
||||
:key="cat.id"
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="categoryBadgeClass(cat.level)"
|
||||
>
|
||||
{{ categoryName(cat) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="text-sm text-slate-400 italic">{{ t('provider.detailNoCategories') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Contact Grid ── -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailContact') }}</p>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<!-- Phone -->
|
||||
<a
|
||||
v-if="provider.contact_phone"
|
||||
:href="'tel:' + provider.contact_phone"
|
||||
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
|
||||
>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailCategory') }}</p>
|
||||
<p class="text-sm font-medium text-slate-800">{{ provider.category }}</p>
|
||||
<span>{{ provider.contact_phone }}</span>
|
||||
</a>
|
||||
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="italic">{{ t('provider.detailNoPhone') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<a
|
||||
v-if="provider.contact_email"
|
||||
:href="'mailto:' + provider.contact_email"
|
||||
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
|
||||
>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="truncate">{{ provider.contact_email }}</span>
|
||||
</a>
|
||||
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="italic">{{ t('provider.detailNoEmail') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
|
||||
>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="truncate">{{ provider.website }}</span>
|
||||
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="italic">{{ t('provider.detailNoWebsite') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address (P1 CRITICAL ALIGN: atomizált címmezők intelligens összefűzése) -->
|
||||
<!-- ── Location & Plus Code ── -->
|
||||
<div v-if="hasAddress || provider.plus_code">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailLocation') }}</p>
|
||||
<div class="rounded-xl bg-slate-50 p-4 space-y-3">
|
||||
<!-- Address -->
|
||||
<div v-if="hasAddress" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -59,90 +151,31 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailAddress') }}</p>
|
||||
<p class="text-sm font-medium text-slate-800">
|
||||
{{ formattedAddress }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-700">{{ formattedAddress }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Plus Code -->
|
||||
<div v-if="provider.plus_code" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSource') }}</p>
|
||||
<p class="text-sm font-medium text-slate-800">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
:class="sourceBadgeClass"
|
||||
>
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-700 font-mono">{{ provider.plus_code }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Specialization -->
|
||||
<div v-if="provider.specialization && provider.specialization.length > 0" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<!-- Navigate Button (Google Maps) -->
|
||||
<a
|
||||
:href="googleMapsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-all hover:shadow-md hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSpecialization') }}</p>
|
||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
||||
<span
|
||||
v-for="(tag, idx) in provider.specialization"
|
||||
:key="idx"
|
||||
class="inline-flex items-center rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
<div v-if="provider.rating" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<svg
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="h-4 w-4"
|
||||
:class="star <= Math.round(provider.rating!) ? 'text-amber-400' : 'text-slate-200'"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span class="ml-1 text-sm font-medium text-slate-700">{{ provider.rating.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No rating fallback -->
|
||||
<div v-if="!provider.rating" class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
|
||||
<p class="text-sm text-slate-400 italic">{{ t('serviceFinder.detailNoRating') }}</p>
|
||||
{{ t('provider.detailNavigate') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,20 +202,30 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// ── Types matching backend ProviderSearchResult ──
|
||||
interface CategoryInfo {
|
||||
id: number
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
key: string
|
||||
}
|
||||
|
||||
interface ProviderSearchResult {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
specialization: string[]
|
||||
categories: CategoryInfo[]
|
||||
city: string | null
|
||||
address: string | null
|
||||
address_zip: string | null
|
||||
address_street_name: string | null
|
||||
address_street_type: string | null
|
||||
address_house_number: string | null
|
||||
plus_code: string | null
|
||||
contact_phone: string | null
|
||||
contact_email: string | null
|
||||
website: string | null
|
||||
@@ -223,10 +266,32 @@ const sourceLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Category badge color based on level.
|
||||
* Level 0 = Vehicle Type (purple), Level 1 = Industry (blue),
|
||||
* Level 2 = Profession (teal), Level 3 = Specific Tag (slate).
|
||||
*/
|
||||
function categoryBadgeClass(level: number): string {
|
||||
switch (level) {
|
||||
case 0: return 'bg-purple-100 text-purple-700'
|
||||
case 1: return 'bg-blue-100 text-blue-700'
|
||||
case 2: return 'bg-teal-100 text-teal-700'
|
||||
case 3: return 'bg-slate-100 text-slate-600'
|
||||
default: return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the localized category name based on current locale.
|
||||
*/
|
||||
function categoryName(cat: CategoryInfo): string {
|
||||
if (locale.value === 'hu' && cat.name_hu) return cat.name_hu
|
||||
if (cat.name_en) return cat.name_en
|
||||
return cat.key
|
||||
}
|
||||
|
||||
/**
|
||||
* P1 CRITICAL ALIGN: Atomizált címmezők intelligens összefűzése.
|
||||
* A provider.address_street_name + ' ' + provider.address_street_type + ' ' + provider.address_house_number
|
||||
* Ha a city is meg van adva, akkor "city, street_name street_type house_number" formátumban jelenik meg.
|
||||
*/
|
||||
const hasAddress = computed(() => {
|
||||
const p = props.provider
|
||||
@@ -258,6 +323,31 @@ const formattedAddress = computed(() => {
|
||||
|
||||
return parts.join(', ')
|
||||
})
|
||||
|
||||
/**
|
||||
* Google Maps URL: prefer plus_code, fall back to address query.
|
||||
*/
|
||||
const googleMapsUrl = computed(() => {
|
||||
const p = props.provider
|
||||
if (!p) return 'https://maps.google.com'
|
||||
|
||||
if (p.plus_code) {
|
||||
return `https://maps.google.com/maps?q=${encodeURIComponent(p.plus_code)}`
|
||||
}
|
||||
|
||||
const queryParts: string[] = []
|
||||
if (p.address_street_name) queryParts.push(p.address_street_name)
|
||||
if (p.address_street_type) queryParts.push(p.address_street_type)
|
||||
if (p.address_house_number) queryParts.push(p.address_house_number)
|
||||
if (p.city) queryParts.push(p.city)
|
||||
if (p.address_zip) queryParts.push(p.address_zip)
|
||||
|
||||
if (queryParts.length > 0) {
|
||||
return `https://maps.google.com/maps?q=${encodeURIComponent(queryParts.join(' '))}`
|
||||
}
|
||||
|
||||
return `https://maps.google.com/maps?q=${encodeURIComponent(p.name)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
|
||||
class="relative w-full max-w-lg mx-4 flex flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
|
||||
style="max-height: 85vh;"
|
||||
>
|
||||
<!-- ── Header ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -32,8 +33,40 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Form Body ── -->
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- ── Tab Bar ── -->
|
||||
<div class="flex border-b border-slate-200 px-6 shrink-0">
|
||||
<button
|
||||
@click="activeTab = 'basic'"
|
||||
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
|
||||
:class="activeTab === 'basic'
|
||||
? 'border-sf-accent text-sf-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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('provider.tabBasic') }}
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'services'"
|
||||
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
|
||||
:class="activeTab === 'services'
|
||||
? 'border-sf-accent text-sf-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
{{ t('provider.tabServices') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Scrollable Body ── -->
|
||||
<div class="overflow-y-auto p-6 space-y-5" style="max-height: calc(85vh - 140px);">
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- TAB 1: Alapadatok (Basic Data) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<template v-if="activeTab === 'basic'">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
@@ -48,10 +81,13 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Atomizált címmezők (P1 CRITICAL ALIGN) ── -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.addressSection') }}</p>
|
||||
<!-- Street Name -->
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.streetNameLabel') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.streetNameOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_street_name"
|
||||
@@ -63,7 +99,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Street Type + House Number row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.streetTypeLabel') }}
|
||||
@@ -130,10 +166,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plus Code -->
|
||||
<div class="mt-3">
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.plusCodeLabel') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.plusCodeOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.plus_code"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.plusCodePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Elérhetőségek (Contact) ── -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.contactSection') }}</p>
|
||||
<!-- Phone -->
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.phoneLabel') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.phoneOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
@@ -144,9 +200,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.emailLabel') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.emailOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
@@ -160,6 +217,7 @@
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.websiteLabel') }}
|
||||
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.websiteOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.website"
|
||||
@@ -168,31 +226,188 @@
|
||||
:placeholder="t('provider.websitePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tags (comma-separated) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- TAB 2: Szolgáltatások (Services) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<template v-if="activeTab === 'services'">
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- BLOCK A: Fix Struktúra (Szint 0 + Szint 1) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{{ t('provider.tagsLabel') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_a_title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="categoryTreeLoading" class="flex items-center gap-2 text-sm text-slate-400 py-2">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Level 0: Járműtípus (always visible) -->
|
||||
<div v-if="level0Categories.length > 0" class="mb-3">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level0Label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="cat in level0Categories"
|
||||
:key="cat.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
|
||||
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(cat.id) }"
|
||||
>
|
||||
<input
|
||||
v-model="form.tagsInput"
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(cat.id)"
|
||||
@change="toggleCategory(cat.id)"
|
||||
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
|
||||
/>
|
||||
{{ cat.name_hu || cat.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level 1: Iparág/Főcsoport (children of CHECKED Level 0) -->
|
||||
<div v-if="vehicleLevel1Categories.length > 0" class="mb-3 ml-4 pl-3 border-l-2 border-sf-accent/20">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level1Label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="l1 in vehicleLevel1Categories"
|
||||
:key="l1.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
|
||||
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(l1.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(l1.id)"
|
||||
@change="toggleCategory(l1.id)"
|
||||
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
|
||||
/>
|
||||
{{ l1.name_hu || l1.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Universal Level 1: Iparágak és Univerzális Szolgáltatások (independent of Level 0) -->
|
||||
<div v-if="universalLevel1Categories.length > 0" class="mb-3 mt-4 pt-3 border-t-2 border-dashed border-amber-200">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-amber-50 text-amber-500">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.universal_title') }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-2">{{ t('categories.universal_hint') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="l1 in universalLevel1Categories"
|
||||
:key="l1.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-amber-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-amber-400 hover:bg-amber-50 cursor-pointer"
|
||||
:class="{ 'border-amber-500 bg-amber-50 text-amber-700 font-semibold': selectedCategoryIds.includes(l1.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(l1.id)"
|
||||
@change="toggleCategory(l1.id)"
|
||||
class="h-3.5 w-3.5 rounded border-amber-300 text-amber-500 focus:ring-amber-300"
|
||||
/>
|
||||
{{ l1.name_hu || l1.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- BLOCK B: Okos Szövegmező (Szint 2 + Szint 3) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-blue-50 text-blue-500">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_b_title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Autocomplete input -->
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="autocompleteQuery"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('provider.tagsPlaceholder')"
|
||||
:placeholder="t('categories.search_placeholder')"
|
||||
@input="onAutocompleteInput"
|
||||
@keydown.enter.prevent="onAutocompleteEnter"
|
||||
@keydown.backspace="onAutocompleteBackspace"
|
||||
@blur="onAutocompleteBlur"
|
||||
/>
|
||||
<p v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(tag, idx) in form.tags"
|
||||
:key="idx"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
|
||||
<!-- Autocomplete dropdown -->
|
||||
<div
|
||||
v-if="autocompleteQuery.length >= 2"
|
||||
class="absolute z-10 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
{{ tag }}
|
||||
<!-- Results list -->
|
||||
<button
|
||||
v-for="item in autocompleteResults"
|
||||
:key="item.id"
|
||||
@mousedown.prevent="selectAutocompleteItem(item)"
|
||||
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 transition-colors hover:bg-sf-accent/5 hover:text-sf-accent flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xs text-slate-400 font-mono">L{{ item.level }}</span>
|
||||
<span>{{ item.name_hu || item.key }}</span>
|
||||
</button>
|
||||
<!-- No results message -->
|
||||
<div
|
||||
v-if="autocompleteResults.length === 0"
|
||||
class="px-4 py-3 text-sm text-slate-400 text-center"
|
||||
>
|
||||
{{ t('categories.no_results_create') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected tags (chips) -->
|
||||
<div v-if="selectedTags.length > 0" class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(tag, idx) in selectedTags"
|
||||
:key="'tag-' + idx"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 border border-blue-100"
|
||||
>
|
||||
{{ tag.name_hu || tag.key }}
|
||||
<button
|
||||
@click="removeTag(idx)"
|
||||
class="ml-0.5 text-sf-accent/60 hover:text-red-500 cursor-pointer"
|
||||
class="ml-0.5 text-blue-400 hover:text-red-500 cursor-pointer"
|
||||
>×</button>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gamification Toast -->
|
||||
<div
|
||||
v-if="gamificationToast"
|
||||
class="flex items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium shadow-sm transition-all duration-300"
|
||||
:class="gamificationToast.type === 'success'
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-amber-50 text-amber-700 border border-amber-200'"
|
||||
>
|
||||
<svg v-if="gamificationToast.type === 'success'" class="h-5 w-5 shrink-0 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg v-else class="h-5 w-5 shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span>{{ gamificationToast.message }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
@@ -202,8 +417,11 @@
|
||||
<p class="text-xs text-slate-400 italic">
|
||||
{{ t('provider.editInfo') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<!-- ── Sticky Footer (Save / Cancel) ── -->
|
||||
<div class="shrink-0 border-t border-slate-200 bg-white px-6 py-4 rounded-b-2xl">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
@@ -241,13 +459,28 @@
|
||||
* address_street_name, address_street_type, address_house_number
|
||||
* - A payload pontosan az új, atomizált kulcsokkal megy a backend felé.
|
||||
*
|
||||
* UX OVERHAUL (2026-06-17):
|
||||
* - Tab 1: Alapadatok (Név, Cím 3 bontásban, Elérhetőségek)
|
||||
* - Tab 2: Szolgáltatások (Kategória fa + Autocomplete)
|
||||
* - Görgethető body (overflow-y-auto, max-height: 85vh)
|
||||
* - Sticky footer a Mentés/Mégse gombokkal
|
||||
* - Okos Kategória Fa: csak a kipipált Szint 0 gyerekei jelennek meg
|
||||
*
|
||||
* 4-Level Category Hybrid UI (2026-06-17):
|
||||
* ==========================================
|
||||
* BLOCK A (Fix Struktúra): Szint 0 (Járműtípus) + Szint 1 (Iparág) checkboxok
|
||||
* - Okos: Szint 1 csak akkor jelenik meg, ha a Szint 0 ki van pipálva
|
||||
* BLOCK B (Okos Szövegmező): Szint 2 + Szint 3 autocomplete + chip tags
|
||||
* - Ha a gépelt szó létezik → legördül és kiválasztható
|
||||
* - Ha nem létezik → Enter → új chip címke (new_tags)
|
||||
*
|
||||
* @emits close — modal bezárása
|
||||
* @emits saved — sikeres mentés után, payload: { id: number }
|
||||
*
|
||||
* @see backend/app/api/v1/endpoints/providers.py — PUT /providers/{id}
|
||||
* @see frontend/src/components/provider/ProviderDetailModal.vue — hívó
|
||||
*/
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ref, reactive, watch, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/axios'
|
||||
|
||||
@@ -274,6 +507,34 @@ interface ProviderSearchResult {
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
interface CategoryTreeNode {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
path: string | null
|
||||
is_official: boolean
|
||||
children: CategoryTreeNode[]
|
||||
}
|
||||
|
||||
interface AutocompleteItem {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
path: string | null
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
interface SelectedTag {
|
||||
id: number | null // null for user-created tags (not yet in DB)
|
||||
key: string
|
||||
name_hu: string | null
|
||||
level: number
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -286,9 +547,15 @@ const emit = defineEmits<{
|
||||
(e: 'saved', payload: { id: number }): void
|
||||
}>()
|
||||
|
||||
// ── Tab State ──
|
||||
const activeTab = ref<'basic' | 'services'>('basic')
|
||||
|
||||
// ── Form State ──
|
||||
const isSaving = ref(false)
|
||||
|
||||
// ── Gamification Toast ──
|
||||
const gamificationToast = ref<{ type: 'success' | 'penalty'; message: string } | null>(null)
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
city: string
|
||||
@@ -296,11 +563,10 @@ interface FormState {
|
||||
address_street_name: string
|
||||
address_street_type: string
|
||||
address_house_number: string
|
||||
plus_code: string
|
||||
phone: string
|
||||
email: string
|
||||
website: string
|
||||
tagsInput: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
@@ -310,11 +576,55 @@ const form = reactive<FormState>({
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
plus_code: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
tagsInput: '',
|
||||
tags: [],
|
||||
})
|
||||
|
||||
// ── Category State (4-Level) ──
|
||||
const categoryTreeLoading = ref(false)
|
||||
const categoryTree = ref<CategoryTreeNode[]>([])
|
||||
const selectedCategoryIds = ref<number[]>([])
|
||||
const selectedTags = ref<SelectedTag[]>([])
|
||||
const autocompleteQuery = ref('')
|
||||
const autocompleteResults = ref<AutocompleteItem[]>([])
|
||||
let autocompleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Computed: Level 0 categories ──
|
||||
const level0Categories = computed(() => {
|
||||
return categoryTree.value.filter(c => c.level === 0)
|
||||
})
|
||||
|
||||
// ── Computed: Vehicle-specific Level 1 categories ──
|
||||
// Shows Level 1 children of CHECKED Level 0 parents only.
|
||||
const vehicleLevel1Categories = computed(() => {
|
||||
const checkedL0Ids = new Set(selectedCategoryIds.value)
|
||||
const result: CategoryTreeNode[] = []
|
||||
for (const l0 of level0Categories.value) {
|
||||
if (l0.children.length > 0 && checkedL0Ids.has(l0.id)) {
|
||||
for (const l1 of l0.children) {
|
||||
// Avoid duplicates if same L1 appears under multiple L0
|
||||
if (!result.some(r => r.id === l1.id)) {
|
||||
result.push(l1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// ── Computed: Universal Level 1 categories ──
|
||||
// Level 1 categories that are root nodes (parent_id IS NULL) in the tree.
|
||||
// These are independent of any vehicle type and always visible.
|
||||
// Examples: "Üzemanyag és Töltőállomás", "Étel és Ital", "Szállás"
|
||||
const universalLevel1Categories = computed(() => {
|
||||
return categoryTree.value.filter(c => c.level === 1)
|
||||
})
|
||||
|
||||
// ── Load category tree on mount ──
|
||||
onMounted(async () => {
|
||||
await loadCategoryTree()
|
||||
})
|
||||
|
||||
// ── Watch provider changes → populate form ──
|
||||
@@ -332,33 +642,125 @@ watch(
|
||||
form.phone = provider.contact_phone || ''
|
||||
form.email = provider.contact_email || ''
|
||||
form.website = provider.website || ''
|
||||
form.tagsInput = ''
|
||||
form.tags = provider.tags && provider.tags.length > 0
|
||||
? [...provider.tags]
|
||||
: provider.specialization
|
||||
? [...provider.specialization]
|
||||
: []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ── Tag management ──
|
||||
watch(
|
||||
() => form.tagsInput,
|
||||
(val) => {
|
||||
if (val.endsWith(',') || val.endsWith(';')) {
|
||||
const newTag = val.slice(0, -1).trim()
|
||||
if (newTag && !form.tags.includes(newTag)) {
|
||||
form.tags.push(newTag)
|
||||
}
|
||||
form.tagsInput = ''
|
||||
// ── Load Category Tree from API ──
|
||||
async function loadCategoryTree() {
|
||||
categoryTreeLoading.value = true
|
||||
try {
|
||||
const response = await api.get('providers/categories/tree')
|
||||
categoryTree.value = response.data || []
|
||||
} catch (err) {
|
||||
console.error('[ProviderEditModal] Failed to load category tree:', err)
|
||||
} finally {
|
||||
categoryTreeLoading.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Toggle category selection (Block A) ──
|
||||
function toggleCategory(categoryId: number) {
|
||||
const idx = selectedCategoryIds.value.indexOf(categoryId)
|
||||
if (idx >= 0) {
|
||||
selectedCategoryIds.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedCategoryIds.value.push(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete input handler (Block B) ──
|
||||
function onAutocompleteInput() {
|
||||
if (autocompleteTimer) {
|
||||
clearTimeout(autocompleteTimer)
|
||||
}
|
||||
|
||||
const query = autocompleteQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
autocompleteResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce: 300ms
|
||||
autocompleteTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await api.get('providers/categories/autocomplete', {
|
||||
params: { q: query },
|
||||
})
|
||||
// Filter out already selected tags
|
||||
const selectedKeys = new Set(selectedTags.value.map(t => t.key))
|
||||
autocompleteResults.value = (response.data || []).filter(
|
||||
(item: AutocompleteItem) => !selectedKeys.has(item.key)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[ProviderEditModal] Autocomplete error:', err)
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ── Select autocomplete item ──
|
||||
function selectAutocompleteItem(item: AutocompleteItem) {
|
||||
selectedTags.value.push({
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
name_hu: item.name_hu,
|
||||
level: item.level,
|
||||
})
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
|
||||
// ── Enter key: create new tag if not found ──
|
||||
function onAutocompleteEnter() {
|
||||
const query = autocompleteQuery.value.trim()
|
||||
if (!query) return
|
||||
|
||||
// Check if already selected
|
||||
if (selectedTags.value.some(t => t.key === query.toLowerCase().replace(/\s+/g, '_'))) {
|
||||
autocompleteQuery.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it matches any autocomplete result
|
||||
const match = autocompleteResults.value.find(
|
||||
item => (item.name_hu || '').toLowerCase() === query.toLowerCase()
|
||||
)
|
||||
if (match) {
|
||||
selectAutocompleteItem(match)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new tag (user-created, not yet in DB)
|
||||
selectedTags.value.push({
|
||||
id: null, // null = not yet in DB
|
||||
key: query.toLowerCase().replace(/\s+/g, '_'),
|
||||
name_hu: query,
|
||||
level: 3,
|
||||
})
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
|
||||
// ── Backspace on empty input: remove last tag ──
|
||||
function onAutocompleteBackspace() {
|
||||
if (autocompleteQuery.value === '' && selectedTags.value.length > 0) {
|
||||
selectedTags.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blur: close autocomplete dropdown ──
|
||||
function onAutocompleteBlur() {
|
||||
// Delay to allow click on dropdown items
|
||||
setTimeout(() => {
|
||||
autocompleteResults.value = []
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// ── Remove tag ──
|
||||
function removeTag(index: number) {
|
||||
form.tags.splice(index, 1)
|
||||
selectedTags.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Save handler (async — calls backend PUT /providers/{id}) ──
|
||||
@@ -366,27 +768,58 @@ async function handleSave() {
|
||||
if (!form.name.trim() || !props.provider) return
|
||||
|
||||
isSaving.value = true
|
||||
gamificationToast.value = null
|
||||
|
||||
try {
|
||||
// Separate existing tag IDs from new tag names
|
||||
const existingCategoryIds = selectedTags.value
|
||||
.filter(t => t.id !== null)
|
||||
.map(t => t.id as number)
|
||||
|
||||
const newTagNames = selectedTags.value
|
||||
.filter(t => t.id === null)
|
||||
.map(t => t.name_hu || t.key)
|
||||
|
||||
// Combine Block A + Block B category IDs
|
||||
const allCategoryIds = [...selectedCategoryIds.value, ...existingCategoryIds]
|
||||
|
||||
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
|
||||
const body = {
|
||||
const body: Record<string, any> = {
|
||||
name: form.name.trim(),
|
||||
city: form.city.trim() || null,
|
||||
address_zip: form.zip.trim() || null,
|
||||
address_street_name: form.address_street_name.trim() || null,
|
||||
address_street_type: form.address_street_type.trim() || null,
|
||||
address_house_number: form.address_house_number.trim() || null,
|
||||
plus_code: form.plus_code.trim() || null,
|
||||
contact_phone: form.phone.trim() || null,
|
||||
contact_email: form.email.trim() || null,
|
||||
website: form.website.trim() || null,
|
||||
tags: form.tags.length > 0 ? [...form.tags] : null,
|
||||
tags: null,
|
||||
category_ids: allCategoryIds.length > 0 ? allCategoryIds : null,
|
||||
new_tags: newTagNames.length > 0 ? newTagNames : null,
|
||||
}
|
||||
|
||||
await api.put(`providers/${props.provider.id}`, body)
|
||||
const response = await api.put(`providers/${props.provider.id}`, body)
|
||||
const earnedPoints = response.data?.earned_points
|
||||
|
||||
// Show gamification toast if points were awarded
|
||||
if (earnedPoints !== undefined && earnedPoints !== null) {
|
||||
if (earnedPoints >= 0) {
|
||||
gamificationToast.value = {
|
||||
type: 'success',
|
||||
message: t('gamification.points_awarded', { points: earnedPoints }),
|
||||
}
|
||||
} else {
|
||||
gamificationToast.value = {
|
||||
type: 'penalty',
|
||||
message: t('gamification.penalty_applied', { points: Math.abs(earnedPoints) }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit success so parent can refresh search results
|
||||
emit('saved', { id: props.provider.id })
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
console.error('[ProviderEditModal] Save error:', err)
|
||||
// Keep modal open on error so user can retry
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-fade-in-up"
|
||||
class="relative w-full max-w-lg mx-4 flex flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
|
||||
style="max-height: 85vh;"
|
||||
>
|
||||
<!-- ── Header ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -32,8 +33,40 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Form Body ── -->
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||
<!-- ── Tab Bar ── -->
|
||||
<div class="flex border-b border-slate-200 px-6 shrink-0">
|
||||
<button
|
||||
@click="activeTab = 'basic'"
|
||||
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
|
||||
:class="activeTab === 'basic'
|
||||
? 'border-sf-accent text-sf-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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('provider.tabBasic') }}
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'services'"
|
||||
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
|
||||
:class="activeTab === 'services'
|
||||
? 'border-sf-accent text-sf-accent'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
{{ t('provider.tabServices') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Scrollable Body ── -->
|
||||
<div class="overflow-y-auto p-6 space-y-5" style="max-height: calc(85vh - 140px);">
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- TAB 1: Alapadatok (Basic Data) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<template v-if="activeTab === 'basic'">
|
||||
<!-- Provider Name -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
@@ -50,58 +83,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category Dropdown -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.categoryLabel') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="form.category_id"
|
||||
required
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="" disabled>{{ t('provider.categoryPlaceholder') }}</option>
|
||||
<option
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
:value="cat.id"
|
||||
>
|
||||
{{ cat.name_hu || cat.name_en || cat.key }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- ZIP Code -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.zipLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.zipOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_zip"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
:placeholder="t('provider.zipPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- City -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.cityLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.cityOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:placeholder="t('provider.cityPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Atomizált címmezők (P1 CRITICAL ALIGN) ── -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.addressSection') }}</p>
|
||||
<!-- Street Name -->
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.streetNameLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.streetNameOptional') }})</span>
|
||||
</label>
|
||||
@@ -115,7 +101,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Street Type + House Number row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.streetTypeLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.streetTypeOptional') }})</span>
|
||||
@@ -156,8 +142,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<!-- City + ZIP row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.zipLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.zipOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.address_zip"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
:placeholder="t('provider.zipPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.cityLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.cityOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:placeholder="t('provider.cityPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Plus Code -->
|
||||
<div class="mt-3">
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.plusCodeLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.plusCodeOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.plus_code"
|
||||
type="text"
|
||||
maxlength="20"
|
||||
:placeholder="t('provider.plusCodePlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Elérhetőségek (Contact) ── -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.contactSection') }}</p>
|
||||
<!-- Phone -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.phoneLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.phoneOptional') }})</span>
|
||||
</label>
|
||||
@@ -171,7 +202,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.emailLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.emailOptional') }})</span>
|
||||
</label>
|
||||
@@ -197,33 +228,152 @@
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tags (comma-separated) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- TAB 2: Szolgáltatások (Services) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<template v-if="activeTab === 'services'">
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- BLOCK A: Fix Struktúra (Szint 0 + Szint 1) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-semibold text-slate-700">
|
||||
{{ t('provider.tagsLabel') }} <span class="text-slate-400 text-xs">({{ t('provider.tagsOptional') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.tagsInput"
|
||||
type="text"
|
||||
:placeholder="t('provider.tagsPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-2 focus:ring-sf-accent/20"
|
||||
/>
|
||||
<p v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(tag, idx) in form.tags"
|
||||
:key="idx"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_a_title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="categoryTreeLoading" class="flex items-center gap-2 text-sm text-slate-400 py-2">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Level 0: Járműtípus (always visible) -->
|
||||
<div v-if="level0Categories.length > 0" class="mb-3">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level0Label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="cat in level0Categories"
|
||||
:key="cat.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
|
||||
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(cat.id) }"
|
||||
>
|
||||
{{ tag }}
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(cat.id)"
|
||||
@change="toggleCategory(cat.id)"
|
||||
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
|
||||
/>
|
||||
{{ cat.name_hu || cat.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level 1: Iparág/Főcsoport (children of CHECKED Level 0 + universal Level 1) -->
|
||||
<div v-if="allLevel1Categories.length > 0" class="mb-3 ml-4 pl-3 border-l-2 border-sf-accent/20">
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level1Label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="l1 in allLevel1Categories"
|
||||
:key="l1.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
|
||||
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(l1.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedCategoryIds.includes(l1.id)"
|
||||
@change="toggleCategory(l1.id)"
|
||||
class="h-3.5 w-3.5 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/30"
|
||||
/>
|
||||
{{ l1.name_hu || l1.key }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- BLOCK B: Okos Szövegmező (Szint 2 + Szint 3) -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-blue-50 text-blue-500">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_b_title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Autocomplete input -->
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="autocompleteQuery"
|
||||
type="text"
|
||||
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
:placeholder="t('categories.search_placeholder')"
|
||||
@input="onAutocompleteInput"
|
||||
@keydown.enter.prevent="onAutocompleteEnter"
|
||||
@keydown.backspace="onAutocompleteBackspace"
|
||||
@blur="onAutocompleteBlur"
|
||||
/>
|
||||
<!-- Autocomplete dropdown -->
|
||||
<div
|
||||
v-if="autocompleteQuery.length >= 2"
|
||||
class="absolute z-10 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<!-- Results list -->
|
||||
<button
|
||||
v-for="item in autocompleteResults"
|
||||
:key="item.id"
|
||||
@mousedown.prevent="selectAutocompleteItem(item)"
|
||||
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 transition-colors hover:bg-sf-accent/5 hover:text-sf-accent flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xs text-slate-400 font-mono">L{{ item.level }}</span>
|
||||
<span>{{ item.name_hu || item.key }}</span>
|
||||
</button>
|
||||
<!-- No results message -->
|
||||
<div
|
||||
v-if="autocompleteResults.length === 0"
|
||||
class="px-4 py-3 text-sm text-slate-400 text-center"
|
||||
>
|
||||
{{ t('categories.no_results_create') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected tags (chips) -->
|
||||
<div v-if="selectedTags.length > 0" class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(tag, idx) in selectedTags"
|
||||
:key="'tag-' + idx"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 border border-blue-100"
|
||||
>
|
||||
{{ tag.name_hu || tag.key }}
|
||||
<button
|
||||
@click="removeTag(idx)"
|
||||
type="button"
|
||||
class="ml-0.5 text-sf-accent/60 hover:text-red-500 cursor-pointer"
|
||||
class="ml-0.5 text-blue-400 hover:text-red-500 cursor-pointer"
|
||||
>×</button>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-slate-200" />
|
||||
|
||||
<!-- Info text -->
|
||||
<p class="text-xs text-slate-400 italic">
|
||||
{{ t('provider.editInfo') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Soft-Deduplication Warning -->
|
||||
<div
|
||||
@@ -257,13 +407,16 @@
|
||||
</div>
|
||||
<p class="text-sm text-emerald-600 font-medium">{{ t('provider.successSubtext') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<!-- ── Sticky Footer (Submit / Close after success) ── -->
|
||||
<div class="shrink-0 border-t border-slate-200 bg-white px-6 py-4 rounded-b-2xl">
|
||||
<button
|
||||
v-if="!successMessage"
|
||||
type="submit"
|
||||
:disabled="isSubmitting || !isFormValid"
|
||||
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<svg
|
||||
v-if="isSubmitting"
|
||||
@@ -287,7 +440,7 @@
|
||||
>
|
||||
{{ t('provider.closeButton') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -326,6 +479,34 @@ interface SearchResult {
|
||||
is_verified: boolean
|
||||
}
|
||||
|
||||
interface CategoryTreeNode {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
path: string | null
|
||||
is_official: boolean
|
||||
children: CategoryTreeNode[]
|
||||
}
|
||||
|
||||
interface AutocompleteItem {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
path: string | null
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
interface SelectedTag {
|
||||
id: number | null
|
||||
key: string
|
||||
name_hu: string | null
|
||||
level: number
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -337,6 +518,9 @@ const emit = defineEmits<{
|
||||
(e: 'saved', provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }): void
|
||||
}>()
|
||||
|
||||
// ── Tab State ──
|
||||
const activeTab = ref<'basic' | 'services'>('basic')
|
||||
|
||||
// ── State ──
|
||||
const categories = ref<ExpertiseCategory[]>([])
|
||||
const isSubmitting = ref(false)
|
||||
@@ -353,6 +537,7 @@ const form = reactive({
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
plus_code: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
@@ -360,9 +545,40 @@ const form = reactive({
|
||||
tags: [] as string[],
|
||||
})
|
||||
|
||||
// ── Category State (4-Level) ──
|
||||
const categoryTreeLoading = ref(false)
|
||||
const categoryTree = ref<CategoryTreeNode[]>([])
|
||||
const selectedCategoryIds = ref<number[]>([])
|
||||
const selectedTags = ref<SelectedTag[]>([])
|
||||
const autocompleteQuery = ref('')
|
||||
const autocompleteResults = ref<AutocompleteItem[]>([])
|
||||
let autocompleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Computed: Level 0 categories ──
|
||||
const level0Categories = computed(() => {
|
||||
return categoryTree.value.filter(c => c.level === 0)
|
||||
})
|
||||
|
||||
// ── Computed: All visible Level 1 categories ──
|
||||
// Shows Level 1 children of checked Level 0 parents.
|
||||
const allLevel1Categories = computed(() => {
|
||||
const checkedL0Ids = new Set(selectedCategoryIds.value)
|
||||
const result: CategoryTreeNode[] = []
|
||||
for (const l0 of level0Categories.value) {
|
||||
if (l0.children.length > 0 && checkedL0Ids.has(l0.id)) {
|
||||
for (const l1 of l0.children) {
|
||||
if (!result.some(r => r.id === l1.id)) {
|
||||
result.push(l1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// ── Computed ──
|
||||
const isFormValid = computed(() => {
|
||||
return form.name.trim().length >= 2 && !!form.category_id
|
||||
return form.name.trim().length >= 2
|
||||
})
|
||||
|
||||
// ── Soft-Deduplication: Debounced search on name/city/zip changes ──
|
||||
@@ -394,7 +610,6 @@ async function checkDuplicates() {
|
||||
duplicateWarning.value = null
|
||||
}
|
||||
} catch {
|
||||
// Silent fail — deduplication is non-blocking
|
||||
duplicateWarning.value = null
|
||||
}
|
||||
}
|
||||
@@ -406,7 +621,7 @@ function onFormFieldChange() {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// ── Fetch Categories ──
|
||||
// ── Fetch Categories (legacy dropdown fallback) ──
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await api.get('providers/categories')
|
||||
@@ -428,7 +643,226 @@ async function fetchCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset Form ──
|
||||
// ── Load Category Tree from API ──
|
||||
async function loadCategoryTree() {
|
||||
categoryTreeLoading.value = true
|
||||
try {
|
||||
const response = await api.get('providers/categories/tree')
|
||||
categoryTree.value = response.data || []
|
||||
} catch (err) {
|
||||
console.error('[ProviderQuickAddModal] Failed to load category tree:', err)
|
||||
} finally {
|
||||
categoryTreeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle category selection (Block A) ──
|
||||
function toggleCategory(categoryId: number) {
|
||||
const idx = selectedCategoryIds.value.indexOf(categoryId)
|
||||
if (idx >= 0) {
|
||||
selectedCategoryIds.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedCategoryIds.value.push(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete input handler (Block B) ──
|
||||
function onAutocompleteInput() {
|
||||
if (autocompleteTimer) {
|
||||
clearTimeout(autocompleteTimer)
|
||||
}
|
||||
|
||||
const query = autocompleteQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
autocompleteResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
autocompleteTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await api.get('providers/categories/autocomplete', {
|
||||
params: { q: query },
|
||||
})
|
||||
const selectedKeys = new Set(selectedTags.value.map(t => t.key))
|
||||
autocompleteResults.value = (response.data || []).filter(
|
||||
(item: AutocompleteItem) => !selectedKeys.has(item.key)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[ProviderQuickAddModal] Autocomplete error:', err)
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ── Select autocomplete item ──
|
||||
function selectAutocompleteItem(item: AutocompleteItem) {
|
||||
selectedTags.value.push({
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
name_hu: item.name_hu,
|
||||
level: item.level,
|
||||
})
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
|
||||
// ── Enter key: create new tag if not found ──
|
||||
function onAutocompleteEnter() {
|
||||
const query = autocompleteQuery.value.trim()
|
||||
if (!query) return
|
||||
|
||||
// Check if already selected
|
||||
if (selectedTags.value.some(t => t.key === query.toLowerCase().replace(/\s+/g, '_'))) {
|
||||
autocompleteQuery.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it matches any autocomplete result
|
||||
const match = autocompleteResults.value.find(
|
||||
item => (item.name_hu || '').toLowerCase() === query.toLowerCase()
|
||||
)
|
||||
if (match) {
|
||||
selectAutocompleteItem(match)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new tag (user-created, not yet in DB)
|
||||
selectedTags.value.push({
|
||||
id: null, // null = not yet in DB
|
||||
key: query.toLowerCase().replace(/\s+/g, '_'),
|
||||
name_hu: query,
|
||||
level: 3,
|
||||
})
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
|
||||
// ── Backspace on empty input: remove last tag ──
|
||||
function onAutocompleteBackspace() {
|
||||
if (autocompleteQuery.value === '' && selectedTags.value.length > 0) {
|
||||
selectedTags.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blur: close autocomplete dropdown ──
|
||||
function onAutocompleteBlur() {
|
||||
// Delay to allow click on dropdown items
|
||||
setTimeout(() => {
|
||||
autocompleteResults.value = []
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// ── Remove tag ──
|
||||
function removeTag(index: number) {
|
||||
selectedTags.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Submit handler (async — calls backend POST /providers/quick-add) ──
|
||||
async function handleSubmit() {
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
// Separate existing tag IDs from new tag names
|
||||
const existingCategoryIds = selectedTags.value
|
||||
.filter(t => t.id !== null)
|
||||
.map(t => t.id as number)
|
||||
|
||||
const newTagNames = selectedTags.value
|
||||
.filter(t => t.id === null)
|
||||
.map(t => t.name_hu || t.key)
|
||||
|
||||
// Combine Block A + Block B category IDs
|
||||
const allCategoryIds = [...selectedCategoryIds.value, ...existingCategoryIds]
|
||||
|
||||
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
|
||||
const body: Record<string, any> = {
|
||||
name: form.name.trim(),
|
||||
category_id: form.category_id || null,
|
||||
city: form.city.trim() || null,
|
||||
address_zip: form.address_zip.trim() || null,
|
||||
address_street_name: form.address_street_name.trim() || null,
|
||||
address_street_type: form.address_street_type.trim() || null,
|
||||
address_house_number: form.address_house_number.trim() || null,
|
||||
plus_code: form.plus_code.trim() || null,
|
||||
contact_phone: form.phone.trim() || null,
|
||||
contact_email: form.email.trim() || null,
|
||||
website: form.website.trim() || null,
|
||||
tags: form.tags.length > 0 ? form.tags : null,
|
||||
category_ids: allCategoryIds.length > 0 ? allCategoryIds : null,
|
||||
new_tags: newTagNames.length > 0 ? newTagNames : null,
|
||||
}
|
||||
|
||||
const response = await api.post('providers/quick-add', body)
|
||||
const data = response.data as QuickAddResponse
|
||||
|
||||
// Show gamification toast
|
||||
if (data.earned_points && data.earned_points > 0) {
|
||||
successMessage.value = t('provider.successGamification', {
|
||||
name: data.name,
|
||||
points: data.earned_points,
|
||||
})
|
||||
} else {
|
||||
successMessage.value = t('provider.successSimple', { name: data.name })
|
||||
}
|
||||
|
||||
// Emit saved event so parent can refresh
|
||||
emit('saved', {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
category: form.category_id ? String(form.category_id) : null,
|
||||
city: form.city.trim() || null,
|
||||
address: form.address_street_name.trim()
|
||||
? `${form.address_street_name.trim()} ${form.address_street_type.trim()} ${form.address_house_number.trim()}`.trim()
|
||||
: null,
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('[ProviderQuickAddModal] Submit error:', err)
|
||||
if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message
|
||||
} else {
|
||||
errorMessage.value = t('provider.errorGeneric')
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Watch tagsInput for comma/semicolon splitting ──
|
||||
watch(
|
||||
() => form.tagsInput,
|
||||
(val: string) => {
|
||||
if (/[,;]/.test(val)) {
|
||||
const parts = val.split(/[,;]+/).map(s => s.trim()).filter(Boolean)
|
||||
if (parts.length > 0) {
|
||||
form.tags.push(...parts)
|
||||
form.tagsInput = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function removeLegacyTag(index: number) {
|
||||
form.tags.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Close handler ──
|
||||
function handleClose() {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleCloseAfterSuccess() {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// ── Reset form ──
|
||||
function resetForm() {
|
||||
form.name = ''
|
||||
form.category_id = ''
|
||||
@@ -442,115 +876,38 @@ function resetForm() {
|
||||
form.website = ''
|
||||
form.tagsInput = ''
|
||||
form.tags = []
|
||||
selectedCategoryIds.value = []
|
||||
selectedTags.value = []
|
||||
autocompleteQuery.value = ''
|
||||
autocompleteResults.value = []
|
||||
activeTab.value = 'basic'
|
||||
errorMessage.value = null
|
||||
successMessage.value = null
|
||||
duplicateWarning.value = null
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
// ── Submit ──
|
||||
async function handleSubmit() {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
category_id: Number(form.category_id),
|
||||
city: form.city.trim() || null,
|
||||
address_zip: form.address_zip.trim() || null,
|
||||
address_street_name: form.address_street_name.trim() || null,
|
||||
address_street_type: form.address_street_type.trim() || null,
|
||||
address_house_number: form.address_house_number.trim() || null,
|
||||
contact_phone: form.phone.trim() || null,
|
||||
contact_email: form.email.trim() || null,
|
||||
website: form.website.trim() || null,
|
||||
tags: form.tags.length > 0 ? [...form.tags] : null,
|
||||
}
|
||||
|
||||
const response = await api.post<QuickAddResponse>('providers/quick-add', payload)
|
||||
const data = response.data
|
||||
|
||||
// ── Success: Show Gamification Toast ──
|
||||
successMessage.value = t('provider.successMessage', { points: data.earned_points })
|
||||
|
||||
// Build a display address from atomized fields
|
||||
const addressParts = [
|
||||
form.address_street_name,
|
||||
form.address_street_type,
|
||||
form.address_house_number,
|
||||
].filter(Boolean)
|
||||
const displayAddress = addressParts.length > 0 ? addressParts.join(' ') : null
|
||||
|
||||
// Emit the saved provider back to parent
|
||||
emit('saved', {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
category: form.category_id ? categories.value.find(c => c.id === Number(form.category_id))?.name_hu || null : null,
|
||||
city: form.city || null,
|
||||
address: displayAddress,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[ProviderQuickAddModal] Submit error:', error)
|
||||
if (error.response?.data?.detail) {
|
||||
errorMessage.value = error.response.data.detail
|
||||
} else {
|
||||
errorMessage.value = t('provider.errorMessage')
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tag management ──
|
||||
// ── Watch isOpen: reset + fetch on open ──
|
||||
watch(
|
||||
() => form.tagsInput,
|
||||
(val) => {
|
||||
if (val.endsWith(',') || val.endsWith(';')) {
|
||||
const newTag = val.slice(0, -1).trim()
|
||||
if (newTag && !form.tags.includes(newTag)) {
|
||||
form.tags.push(newTag)
|
||||
}
|
||||
form.tagsInput = ''
|
||||
() => props.isOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
fetchCategories()
|
||||
loadCategoryTree()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function removeTag(index: number) {
|
||||
form.tags.splice(index, 1)
|
||||
}
|
||||
|
||||
// ── Close Handlers ──
|
||||
function handleClose() {
|
||||
if (isSubmitting.value) return
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleCloseAfterSuccess() {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// ── Watch Modal Open ──
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
fetchCategories()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Watch form fields for soft-deduplication ──
|
||||
watch(() => form.name, () => { onFormFieldChange() })
|
||||
watch(() => form.city, () => { onFormFieldChange() })
|
||||
watch(() => form.address_zip, () => { onFormFieldChange() })
|
||||
// ── Soft-deduplication watchers ──
|
||||
watch(() => form.name, onFormFieldChange)
|
||||
watch(() => form.city, onFormFieldChange)
|
||||
watch(() => form.address_zip, onFormFieldChange)
|
||||
|
||||
// ── Load categories on mount ──
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
loadCategoryTree()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -568,12 +925,11 @@ onMounted(() => {
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
finance: 'Finance & Costs',
|
||||
diagnostic: 'Live Diagnostics',
|
||||
addVehicle: '+ Add New Vehicle',
|
||||
service: 'Service',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Welcome',
|
||||
@@ -1242,6 +1243,11 @@ export default {
|
||||
filterAutoMento: '🚛 Tow Truck',
|
||||
filterBenzinkut: '⛽ Gas Station',
|
||||
filterAutoKozmetika: '✨ Car Detailing',
|
||||
// Category filter (FEATURE 2026-06-17)
|
||||
categoryPlaceholder: 'Search category (e.g. AC, tire)',
|
||||
categoryFilterTitle: 'Category filter',
|
||||
categoryRemove: 'Remove',
|
||||
categoryNoResults: 'No results',
|
||||
// SOS Tab
|
||||
sosTabTitle: 'SOS Rescue',
|
||||
sosPlaceholder: '🚧 This feature is under development. You will be able to request emergency roadside assistance here soon!',
|
||||
@@ -1299,21 +1305,60 @@ export default {
|
||||
duplicateWarningShort: 'Similar provider found nearby: {name}. Are you sure you want to add a new one?',
|
||||
// Edit Modal
|
||||
editTitle: 'Edit Provider Data',
|
||||
tabBasic: 'Basic Data',
|
||||
tabServices: 'Services',
|
||||
addressSection: 'Address Details',
|
||||
contactSection: 'Contact Details',
|
||||
streetNameLabel: 'Street Name',
|
||||
streetNameOptional: 'optional',
|
||||
streetNamePlaceholder: 'e.g. Egressy',
|
||||
streetTypeLabel: 'Street Type',
|
||||
streetTypePlaceholder: 'Select type...',
|
||||
houseNumberLabel: 'House Number',
|
||||
houseNumberPlaceholder: 'e.g. 4',
|
||||
phoneLabel: 'Phone',
|
||||
phoneOptional: 'optional',
|
||||
phonePlaceholder: 'e.g. +36 20 123 4567',
|
||||
emailLabel: 'Email',
|
||||
emailOptional: 'optional',
|
||||
emailPlaceholder: 'e.g. info@provider.hu',
|
||||
websiteLabel: 'Website',
|
||||
websiteOptional: 'optional',
|
||||
websitePlaceholder: 'e.g. https://provider.hu',
|
||||
tagsLabel: 'Service Tags',
|
||||
tagsPlaceholder: 'Add tags (comma-separated)',
|
||||
level0Label: 'Vehicle Type',
|
||||
level1Label: 'Industry / Main Group',
|
||||
editInfo: 'Your edits will be reviewed and may earn gamification points!',
|
||||
// Plus Code
|
||||
plusCodeLabel: 'Plus Code',
|
||||
plusCodeOptional: 'optional',
|
||||
plusCodePlaceholder: 'e.g. 8FVC9X8V+XV',
|
||||
// Detail Modal
|
||||
detailTitle: 'Provider Details',
|
||||
detailCategories: 'Categories',
|
||||
detailContact: 'Contact',
|
||||
detailLocation: 'Location',
|
||||
detailNavigate: 'Navigate',
|
||||
detailNoCategories: 'No categories assigned',
|
||||
detailNoPhone: 'No phone',
|
||||
detailNoEmail: 'No email',
|
||||
detailNoWebsite: 'No website',
|
||||
detailNoPlusCode: 'No Plus Code',
|
||||
},
|
||||
|
||||
// ── Categories (Hybrid Category UI) ────────────────────────────────
|
||||
categories: {
|
||||
block_a_title: 'Basic Categories (Vehicle & Industry)',
|
||||
block_b_title: 'Specific Tasks & Tags',
|
||||
search_placeholder: 'Search for a service, or type a new one (Enter)...',
|
||||
no_results_create: 'No results. Press Enter to create a new tag.',
|
||||
},
|
||||
|
||||
// ── Gamification ───────────────────────────────────────────────────
|
||||
gamification: {
|
||||
points_awarded: 'Saved successfully! You earned {points} XP!',
|
||||
penalty_applied: 'Saved. Due to your restriction level, you earned {points} XP.',
|
||||
},
|
||||
|
||||
admin: {
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
finance: 'Pénzügyek és Költségek',
|
||||
diagnostic: 'Élő Diagnosztika',
|
||||
addVehicle: '+ Új Jármű Hozzáadása',
|
||||
service: 'Szervíz',
|
||||
},
|
||||
header: {
|
||||
welcome: 'Üdvözlünk',
|
||||
@@ -1242,6 +1243,11 @@ export default {
|
||||
filterAutoMento: '🚛 Autómentő',
|
||||
filterBenzinkut: '⛽ Benzinkút',
|
||||
filterAutoKozmetika: '✨ Autókozmetika',
|
||||
// Category filter (FEATURE 2026-06-17)
|
||||
categoryPlaceholder: 'Kategória keresése (pl. klíma, gumi)',
|
||||
categoryFilterTitle: 'Kategória szűrő',
|
||||
categoryRemove: 'Eltávolítás',
|
||||
categoryNoResults: 'Nincs találat',
|
||||
// SOS Tab
|
||||
sosTabTitle: 'SOS Mentés',
|
||||
sosPlaceholder: '🚧 Ez a funkció fejlesztés alatt áll. Hamarosan kérhető lesz itt az azonnali úti segítség!',
|
||||
@@ -1299,21 +1305,60 @@ export default {
|
||||
duplicateWarningShort: 'Hasonló szolgáltató található a környéken: {name}. Biztosan újat szeretnél rögzíteni?',
|
||||
// Edit Modal
|
||||
editTitle: 'Szolgáltató adatok szerkesztése',
|
||||
tabBasic: 'Alapadatok',
|
||||
tabServices: 'Szolgáltatások',
|
||||
addressSection: 'Cím adatok',
|
||||
contactSection: 'Elérhetőségek',
|
||||
streetNameLabel: 'Utca neve',
|
||||
streetNameOptional: 'opcionális',
|
||||
streetNamePlaceholder: 'Pl. Egressy',
|
||||
streetTypeLabel: 'Közterület jellege',
|
||||
streetTypePlaceholder: 'Válassz típust...',
|
||||
houseNumberLabel: 'Házszám',
|
||||
houseNumberPlaceholder: 'Pl. 4',
|
||||
phoneLabel: 'Telefonszám',
|
||||
phoneOptional: 'opcionális',
|
||||
phonePlaceholder: 'Pl. +36 20 123 4567',
|
||||
emailLabel: 'Email',
|
||||
emailOptional: 'opcionális',
|
||||
emailPlaceholder: 'Pl. info@szolgaltato.hu',
|
||||
websiteLabel: 'Weboldal',
|
||||
websiteOptional: 'opcionális',
|
||||
websitePlaceholder: 'Pl. https://szolgaltato.hu',
|
||||
tagsLabel: 'Szolgáltatás Címkék',
|
||||
tagsPlaceholder: 'Címkék hozzáadása (vesszővel elválasztva)',
|
||||
level0Label: 'Járműtípus',
|
||||
level1Label: 'Iparág / Főcsoport',
|
||||
editInfo: 'A szerkesztéseid ellenőrzésre kerülnek, és gamifikációs pontokat szerezhetsz!',
|
||||
// Plus Code
|
||||
plusCodeLabel: 'Plus kód',
|
||||
plusCodeOptional: 'opcionális',
|
||||
plusCodePlaceholder: 'Pl. 8FVC9X8V+XV',
|
||||
// Detail Modal
|
||||
detailTitle: 'Szolgáltató adatai',
|
||||
detailCategories: 'Kategóriák',
|
||||
detailContact: 'Elérhetőségek',
|
||||
detailLocation: 'Helyszín',
|
||||
detailNavigate: 'Navigáció',
|
||||
detailNoCategories: 'Nincs kategória hozzárendelve',
|
||||
detailNoPhone: 'Nincs telefonszám',
|
||||
detailNoEmail: 'Nincs email',
|
||||
detailNoWebsite: 'Nincs weboldal',
|
||||
detailNoPlusCode: 'Nincs Plus kód',
|
||||
},
|
||||
|
||||
// ── Categories (Hybrid Category UI) ────────────────────────────────
|
||||
categories: {
|
||||
block_a_title: 'Alap kategóriák (Jármű és Iparág)',
|
||||
block_b_title: 'Specifikus feladatok és címkék',
|
||||
search_placeholder: 'Keress szolgáltatást, vagy írj be újat (Enter)...',
|
||||
no_results_create: 'Nincs találat. Nyomj Entert az új címke létrehozásához.',
|
||||
},
|
||||
|
||||
// ── Gamification ───────────────────────────────────────────────────
|
||||
gamification: {
|
||||
points_awarded: 'Sikeres mentés! Szereztél {points} XP-t!',
|
||||
penalty_applied: 'Mentve. A korlátozásod miatt {points} XP-t kaptál.',
|
||||
},
|
||||
|
||||
admin: {
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
{{ t('menu.finance') }}
|
||||
</button>
|
||||
|
||||
<!-- Szerviz kereső -->
|
||||
<!-- Szerviz kereső (belső) -->
|
||||
<button
|
||||
@click="navigateToCard('service')"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
@@ -77,6 +77,17 @@
|
||||
</svg>
|
||||
{{ t('menu.features') }}
|
||||
</button>
|
||||
|
||||
<!-- Szervíz (külső oldal) -->
|
||||
<button
|
||||
@click="openExternalServiceFinder"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
{{ t('menu.service') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -133,6 +144,12 @@ function navigateToCard(cardId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Open external Service Finder page in new tab ───────────────────
|
||||
function openExternalServiceFinder() {
|
||||
isHamburgerOpen.value = false
|
||||
window.open('https://app.servicefinder.hu/dashboard/service-finder', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// ── Close hamburger on outside click ──────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
@@ -21,4 +21,10 @@ export interface OrganizationItem {
|
||||
* Set via PATCH /organizations/{org_id}/visual-settings.
|
||||
*/
|
||||
visual_settings?: Record<string, any> | null
|
||||
/**
|
||||
* The current user's role in this organization (e.g. OWNER, ADMIN, FLEET_MANAGER, etc.).
|
||||
* Returned by GET /api/v1/organizations/my since the 2026-06-17 bugfix.
|
||||
* Used by the frontend to decide whether to show service_provider orgs in the garage switcher.
|
||||
*/
|
||||
user_role?: string
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@
|
||||
Card 3: 🔧 Service Finder (3-Way Indítópult)
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
|
||||
@click="openExternalServiceFinder"
|
||||
>
|
||||
<!-- Top header bar -->
|
||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||
@@ -176,19 +177,19 @@
|
||||
<!-- Content: 3 simple buttons stacked vertically -->
|
||||
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
|
||||
<button
|
||||
@click="router.push('/dashboard/service-finder?mode=planned')"
|
||||
@click.stop="openExternalServiceFinder"
|
||||
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
🔍 {{ t('serviceFinder.plannedMaintenance') }}
|
||||
</button>
|
||||
<button
|
||||
@click="router.push('/dashboard/service-finder?mode=sos')"
|
||||
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
|
||||
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
🚨 SOS {{ t('serviceFinder.sosTitle') }}
|
||||
</button>
|
||||
<button
|
||||
@click="isSfQuickAddOpen = true"
|
||||
@click.stop="isSfQuickAddOpen = true"
|
||||
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||
>
|
||||
➕ {{ t('provider.quickAddTitle') }}
|
||||
@@ -440,6 +441,14 @@ const openCard = (cardId: string, targetId: string | null = null) => {
|
||||
const isSearchModalOpen = ref(false)
|
||||
const isSfQuickAddOpen = ref(false)
|
||||
|
||||
/**
|
||||
* Opens the external Service Finder page in a new tab.
|
||||
* Used by the card click and the "Tervezett karbantartás" button.
|
||||
*/
|
||||
function openExternalServiceFinder() {
|
||||
window.open('https://app.servicefinder.hu/dashboard/service-finder', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when ProviderQuickAddModal saves a new provider.
|
||||
*/
|
||||
|
||||
@@ -66,6 +66,90 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Category Filter (Autocomplete + Chips) ── -->
|
||||
<div class="mx-auto mt-4 max-w-2xl">
|
||||
<!-- Autocomplete input -->
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="categoryQuery"
|
||||
type="text"
|
||||
:placeholder="t('serviceFinder.categoryPlaceholder')"
|
||||
class="w-full rounded-xl border-2 border-white/20 bg-white/10 py-3 pl-12 pr-10 text-sm text-white placeholder-slate-400 backdrop-blur-sm transition-all focus:border-sf-accent focus:bg-white/15 focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
|
||||
@input="onCategoryInput"
|
||||
@keydown.enter="selectFirstSuggestion"
|
||||
@keydown.escape="categorySuggestions = []"
|
||||
@keydown.down.prevent="highlightNext"
|
||||
@keydown.up.prevent="highlightPrev"
|
||||
/>
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
v-if="categoryQuery"
|
||||
@click="clearCategoryInput"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Autocomplete dropdown -->
|
||||
<div
|
||||
v-if="categorySuggestions.length > 0"
|
||||
class="absolute z-50 mt-1 w-full max-w-md rounded-xl border border-white/20 bg-slate-800 shadow-2xl backdrop-blur-xl"
|
||||
style="max-width: calc(100% - 2rem);"
|
||||
>
|
||||
<div
|
||||
v-for="(suggestion, idx) in categorySuggestions"
|
||||
:key="suggestion.id"
|
||||
@click="selectCategory(suggestion)"
|
||||
@mouseenter="categoryHighlightIndex = idx"
|
||||
class="flex cursor-pointer items-center gap-3 px-4 py-3 text-sm text-slate-200 transition-colors"
|
||||
:class="{
|
||||
'bg-sf-accent/20 text-white': idx === categoryHighlightIndex,
|
||||
'hover:bg-white/5': idx !== categoryHighlightIndex,
|
||||
'rounded-t-xl': idx === 0,
|
||||
'rounded-b-xl': idx === categorySuggestions.length - 1,
|
||||
}"
|
||||
>
|
||||
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/20 text-xs font-bold text-sf-accent">
|
||||
{{ suggestion.level }}
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<span class="font-medium">{{ categoryLabel(suggestion) }}</span>
|
||||
<span v-if="suggestion.path" class="ml-2 text-xs text-slate-500">#{{ suggestion.path }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-500">L{{ suggestion.level }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected category chips -->
|
||||
<div v-if="selectedCategories.length > 0" class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-medium text-slate-400">{{ t('serviceFinder.categoryFilterTitle') }}:</span>
|
||||
<span
|
||||
v-for="cat in selectedCategories"
|
||||
:key="cat.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
{{ categoryLabel(cat) }}
|
||||
<button
|
||||
@click="removeCategory(cat)"
|
||||
class="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-300 transition-colors hover:bg-white/20 hover:text-white cursor-pointer"
|
||||
:title="t('serviceFinder.categoryRemove')"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick filter chips -->
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
<span class="text-xs font-medium text-slate-400">{{ t('serviceFinder.quickFilters') }}</span>
|
||||
@@ -240,6 +324,32 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Contact info: phone + website (P0 UI Card Cleanup 2026-06-17) -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-3 text-sm">
|
||||
<a
|
||||
v-if="provider.contact_phone"
|
||||
:href="'tel:' + provider.contact_phone"
|
||||
class="inline-flex items-center gap-1.5 text-slate-500 hover:text-sf-accent transition-colors"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<span>{{ provider.contact_phone }}</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website.startsWith('http') ? provider.website : 'https://' + provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-slate-500 hover:text-sf-accent transition-colors"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<span>{{ provider.website.replace(/^https?:\/\//, '') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Specialization chips / tags -->
|
||||
<div
|
||||
v-if="provider.specialization && provider.specialization.length > 0"
|
||||
@@ -260,10 +370,36 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Category badge (fő szolgáltatás - mindig megjelenik) -->
|
||||
<!-- FEATURE (2026-06-17): Kategória chip-ek a kártyán -->
|
||||
<div
|
||||
v-if="provider.categories && provider.categories.length > 0"
|
||||
class="mb-4 flex flex-wrap gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-for="cat in provider.categories.slice(0, 4)"
|
||||
:key="cat.id"
|
||||
class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700 border border-emerald-100"
|
||||
>
|
||||
{{ providerCategoryLabel(cat) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="provider.categories.length > 4"
|
||||
class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-500"
|
||||
>
|
||||
+{{ provider.categories.length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Category badge (fő szolgáltatás - csak ha van kategória) -->
|
||||
<div class="mb-3">
|
||||
<span
|
||||
v-if="provider.category"
|
||||
v-if="provider.categories && provider.categories.length > 0"
|
||||
class="inline-flex items-center rounded-md bg-sf-accent/10 px-2.5 py-1 text-xs font-semibold text-sf-accent"
|
||||
>
|
||||
{{ providerCategoryLabel(provider.categories[0]) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="provider.category"
|
||||
class="inline-flex items-center rounded-md bg-sf-accent/10 px-2.5 py-1 text-xs font-semibold text-sf-accent"
|
||||
>
|
||||
{{ provider.category }}
|
||||
@@ -397,17 +533,35 @@ import api from '@/api/axios'
|
||||
import ProviderDetailModal from '@/components/provider/ProviderDetailModal.vue'
|
||||
import ProviderEditModal from '@/components/provider/ProviderEditModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// ── Types matching backend ProviderSearchResult ──
|
||||
// @see backend/app/schemas/provider.py:21-33
|
||||
interface CategoryInfo {
|
||||
id: number
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
key: string
|
||||
}
|
||||
|
||||
interface ProviderSearchResult {
|
||||
id: number
|
||||
name: string
|
||||
category: string | null
|
||||
specialization: string[]
|
||||
categories: CategoryInfo[]
|
||||
city: string | null
|
||||
address: string | null
|
||||
address_zip: string | null
|
||||
address_street_name: string | null
|
||||
address_street_type: string | null
|
||||
address_house_number: string | null
|
||||
plus_code: string | null
|
||||
contact_phone: string | null
|
||||
contact_email: string | null
|
||||
website: string | null
|
||||
tags: string[]
|
||||
source: 'verified_org' | 'staged_data' | 'crowd_added'
|
||||
is_verified: boolean
|
||||
rating: number | null
|
||||
@@ -430,12 +584,54 @@ const quickFilters = computed(() => [
|
||||
{ key: 'autókozmetika', label: t('serviceFinder.filterAutoKozmetika') },
|
||||
])
|
||||
|
||||
/**
|
||||
* Locale-aware category label.
|
||||
* Returns name_hu if locale is 'hu', name_en otherwise.
|
||||
* Falls back to the other language if the preferred one is null.
|
||||
*/
|
||||
function categoryLabel(cat: CategorySuggestion): string {
|
||||
if (locale.value === 'hu') {
|
||||
return cat.name_hu || cat.name_en || cat.key
|
||||
}
|
||||
return cat.name_en || cat.name_hu || cat.key
|
||||
}
|
||||
|
||||
/**
|
||||
* FEATURE (2026-06-17): Locale-aware category label for provider cards.
|
||||
* Uses the same logic as categoryLabel but for CategoryInfo type.
|
||||
*/
|
||||
function providerCategoryLabel(cat: CategoryInfo): string {
|
||||
if (locale.value === 'hu') {
|
||||
return cat.name_hu || cat.name_en || cat.key
|
||||
}
|
||||
return cat.name_en || cat.name_hu || cat.key
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// ── Active Tab (from route.query.mode or default 'planned') ──
|
||||
const activeTab = ref<'planned' | 'sos'>('planned')
|
||||
|
||||
// ── Category Filter State (FEATURE 2026-06-17) ──
|
||||
// Matching backend CategoryAutocompleteItem schema
|
||||
// @see backend/app/schemas/provider.py:41-55
|
||||
interface CategorySuggestion {
|
||||
id: number
|
||||
key: string
|
||||
name_hu: string | null
|
||||
name_en: string | null
|
||||
level: number
|
||||
path: string | null
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
const categoryQuery = ref('')
|
||||
const categorySuggestions = ref<CategorySuggestion[]>([])
|
||||
const categoryHighlightIndex = ref(-1)
|
||||
const selectedCategories = ref<CategorySuggestion[]>([])
|
||||
let categoryDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Reactive state ──
|
||||
const searchQuery = ref('')
|
||||
const searchCity = ref('')
|
||||
@@ -464,21 +660,28 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Search logic (passes both q and city) ──
|
||||
async function handleSearch() {
|
||||
isSearching.value = true
|
||||
hasSearched.value = true
|
||||
currentPage.value = 1
|
||||
|
||||
try {
|
||||
const offset = 0
|
||||
// ── Helper: build common search params (includes category_ids) ──
|
||||
function buildSearchParams(offset: number): Record<string, any> {
|
||||
const params: Record<string, any> = {
|
||||
limit: perPage.value,
|
||||
offset,
|
||||
}
|
||||
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
|
||||
if (searchCity.value.trim()) params.city = searchCity.value.trim()
|
||||
if (selectedCategories.value.length > 0) {
|
||||
params.category_ids = selectedCategories.value.map(c => c.id).join(',')
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// ── Search logic (passes q, city, and category_ids) ──
|
||||
async function handleSearch() {
|
||||
isSearching.value = true
|
||||
hasSearched.value = true
|
||||
currentPage.value = 1
|
||||
|
||||
try {
|
||||
const params = buildSearchParams(0)
|
||||
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
|
||||
results.value = res.data.results || []
|
||||
totalResults.value = res.data.total || 0
|
||||
@@ -506,13 +709,7 @@ async function goToPage(page: number) {
|
||||
|
||||
try {
|
||||
const offset = (page - 1) * perPage.value
|
||||
const params: Record<string, any> = {
|
||||
limit: perPage.value,
|
||||
offset,
|
||||
}
|
||||
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
|
||||
if (searchCity.value.trim()) params.city = searchCity.value.trim()
|
||||
|
||||
const params = buildSearchParams(offset)
|
||||
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
|
||||
results.value = res.data.results || []
|
||||
totalResults.value = res.data.total || 0
|
||||
@@ -523,6 +720,82 @@ async function goToPage(page: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Category Autocomplete (FEATURE 2026-06-17) ──
|
||||
async function onCategoryInput() {
|
||||
// Clear previous suggestions if query is too short
|
||||
if (!categoryQuery.value || categoryQuery.value.trim().length < 2) {
|
||||
categorySuggestions.value = []
|
||||
categoryHighlightIndex.value = -1
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce: wait 250ms after last keystroke
|
||||
if (categoryDebounceTimer) clearTimeout(categoryDebounceTimer)
|
||||
categoryDebounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const q = categoryQuery.value.trim()
|
||||
if (q.length < 2) {
|
||||
categorySuggestions.value = []
|
||||
categoryHighlightIndex.value = -1
|
||||
return
|
||||
}
|
||||
const res = await api.get<CategorySuggestion[]>('providers/categories/autocomplete', {
|
||||
params: { q },
|
||||
})
|
||||
categorySuggestions.value = res.data || []
|
||||
categoryHighlightIndex.value = categorySuggestions.value.length > 0 ? 0 : -1
|
||||
} catch (err) {
|
||||
console.error('[ServiceFinderView] Category autocomplete error:', err)
|
||||
categorySuggestions.value = []
|
||||
categoryHighlightIndex.value = -1
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function selectCategory(cat: CategorySuggestion) {
|
||||
// Prevent duplicates
|
||||
if (selectedCategories.value.some(c => c.id === cat.id)) {
|
||||
categoryQuery.value = ''
|
||||
categorySuggestions.value = []
|
||||
categoryHighlightIndex.value = -1
|
||||
return
|
||||
}
|
||||
selectedCategories.value.push({ ...cat })
|
||||
categoryQuery.value = ''
|
||||
categorySuggestions.value = []
|
||||
categoryHighlightIndex.value = -1
|
||||
// Immediately re-trigger search with the new category filter
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function removeCategory(cat: CategorySuggestion) {
|
||||
selectedCategories.value = selectedCategories.value.filter(c => c.id !== cat.id)
|
||||
// Immediately refresh results when removing a chip
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function clearCategoryInput() {
|
||||
categoryQuery.value = ''
|
||||
categorySuggestions.value = []
|
||||
categoryHighlightIndex.value = -1
|
||||
}
|
||||
|
||||
function selectFirstSuggestion() {
|
||||
if (categorySuggestions.value.length > 0 && categoryHighlightIndex.value >= 0) {
|
||||
selectCategory(categorySuggestions.value[categoryHighlightIndex.value])
|
||||
}
|
||||
}
|
||||
|
||||
function highlightNext() {
|
||||
if (categorySuggestions.value.length === 0) return
|
||||
categoryHighlightIndex.value = (categoryHighlightIndex.value + 1) % categorySuggestions.value.length
|
||||
}
|
||||
|
||||
function highlightPrev() {
|
||||
if (categorySuggestions.value.length === 0) return
|
||||
categoryHighlightIndex.value = (categoryHighlightIndex.value - 1 + categorySuggestions.value.length) % categorySuggestions.value.length
|
||||
}
|
||||
|
||||
// ── Detail Modal ──
|
||||
function openDetail(provider: ProviderSearchResult) {
|
||||
selectedProvider.value = provider
|
||||
|
||||
168
plans/logic_spec_garage_selector_member_audit.md
Normal file
168
plans/logic_spec_garage_selector_member_audit.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 🏗️ Logic Spec: P1 Garage Selector & OrganizationMember Audit
|
||||
|
||||
## 1. Modul Célja és Masterbook 2.0 Illeszkedés
|
||||
|
||||
**Cél:** A Dashboard "Garage Selector" (cégválasztó) pontosítása, hogy a bejelentkezett felhasználó:
|
||||
- a) Szerepeljen azon Organization rekordok között, ahol technikai tulajdonos (`owner_id`)
|
||||
- b) Szerepeljen azon Organization rekordok között, ahol aktív `OrganizationMember`
|
||||
|
||||
Emellett az `OrganizationMember` tábla és a meghívási folyamat teljes auditja.
|
||||
|
||||
**Masterbook 2.0 illeszkedés:** B2B szervezetkezelés (11-es Epic), B2B szerepkörök és szervezeti hierarchia.
|
||||
|
||||
---
|
||||
|
||||
## 2. Adatmodell Elemzés
|
||||
|
||||
### 2.1 OrganizationMember tábla (aktuális séma)
|
||||
|
||||
| Oszlop | Típus | Kötelező | Megjegyzés |
|
||||
|--------|-------|----------|------------|
|
||||
| `id` | integer PK | YES | Auto-increment |
|
||||
| `organization_id` | integer FK -> fleet.organizations.id | YES | |
|
||||
| `user_id` | integer FK -> identity.users.id | NO | Lehet NULL (pending invite) |
|
||||
| `person_id` | bigint FK -> identity.persons.id | NO | |
|
||||
| `role` | ENUM (OrgUserRole) | YES | OWNER, ADMIN, FLEET_MANAGER, DRIVER, MECHANIC, RECEPTIONIST |
|
||||
| `permissions` | JSONB | NO (default `{}`) | |
|
||||
| `is_permanent` | boolean | NO (default false) | |
|
||||
| `is_verified` | boolean | NO (default false) | |
|
||||
|
||||
### 2.2 🔴 HIÁNYZÓ OSZLOPOK (Kritikus)
|
||||
|
||||
1. **`status` VARCHAR** - A kód használja (`status="pending"`, `status="active"` a organizations.py:421,481 sorokban), **de az adatbázisban NINCS ilyen oszlop!** Ez futási hibát okoz az invite/join folyamatokban.
|
||||
2. **`joined_at` TIMESTAMP** - Nincs nyomon követve, mikor csatlakozott a tag.
|
||||
3. **`expires_at` / `valid_until` TIMESTAMP** - Nincs lejárati dátum (pl. ideiglenes hozzáféréshez).
|
||||
4. **`created_at` TIMESTAMP** - Hiányzik a tag rekord létrehozási dátuma.
|
||||
5. **`updated_at` TIMESTAMP** - Hiányzik a módosítás dátuma.
|
||||
|
||||
### 2.3 🟢 MEGLÉVŐ, HELYESEN MŰKÖDŐ
|
||||
|
||||
- `role` ENUM - Jól definiált, 6 szerepkörrel.
|
||||
- `user_id` + `person_id` kettős hivatkozás - Támogatja a "Dual Entity" modellt.
|
||||
- `is_permanent` - Alkalmas az állandó vs. ideiglenes tagság megkülönböztetésére.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Módosítás: `get_my_organizations` Fix
|
||||
|
||||
### 3.1 Probléma
|
||||
Jelenleg a `GET /api/v1/organizations/my` lekérdezés (`organizations.py:192`) csak `INNER JOIN`-t használ az `OrganizationMember` táblával. Ez kizárja azokat az eseteket, ahol a felhasználó `owner_id` (technikai tulajdonos), de az `OrganizationMember` rekord nem található.
|
||||
|
||||
### 3.2 Megoldás
|
||||
Használjunk `LEFT JOIN`-t `OR` feltétellel:
|
||||
|
||||
```python
|
||||
from sqlalchemy import or_
|
||||
|
||||
stmt = (
|
||||
select(Organization)
|
||||
.outerjoin(OrganizationMember, OrganizationMember.organization_id == Organization.id)
|
||||
.where(
|
||||
or_(
|
||||
Organization.owner_id == current_user.id,
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
.where(Organization.org_type.notin_([OrgType.service_provider, OrgType.service]))
|
||||
.where(Organization.is_deleted == False)
|
||||
.distinct()
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 Válasz bővítése
|
||||
A frontend számára szükséges a `user_role` mező:
|
||||
|
||||
```python
|
||||
user_role = None
|
||||
for member in o.members:
|
||||
if member.user_id == current_user.id:
|
||||
user_role = member.role.value if hasattr(member.role, 'value') else str(member.role)
|
||||
break
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Módosítások
|
||||
|
||||
### 4.1 HeaderCompanySwitcher.vue - Jelenlegi állapot
|
||||
A komponens már használja az `authStore.myOrganizations` adatokat, és szűri a `companyOrganizations` computed property-ben az `individual`, `service_provider`, `service` típusokat. Ez rendben van.
|
||||
|
||||
### 4.2 Szükséges változtatások
|
||||
1. **Nincs változtatás szükséges** a frontend logikában - a `fetchMyOrganizations()` már meghívja a `/organizations/my` végpontot, és a `companyOrganizations` computed property megfelelően szűr.
|
||||
2. **OrganizationItem típus** (`frontend/src/types/organization.ts`) - már tartalmazza a `user_role` mezőt (nem kötelező).
|
||||
|
||||
### 4.3 Aktív szervezet kiválasztás
|
||||
A `switchOrganization` metódus (`frontend/src/stores/auth.ts:631`) már implementálva van, a `PATCH /users/me/active-organization` hívással. A frontend globális állapota frissül a `user.value.active_organization_id` mezővel.
|
||||
|
||||
---
|
||||
|
||||
## 5. Audit Jelentés: OrganizationMember & Invite Flow
|
||||
|
||||
### 5.1 Meglévő API Végpontok
|
||||
|
||||
| Végpont | Metódus | Státusz | Leírás |
|
||||
|---------|---------|---------|--------|
|
||||
| POST /organizations/{org_id}/invitations | POST | Letezik | Meghívó küldése email címre |
|
||||
| POST /organizations/invitations/{token}/accept | POST | Letevezik | Meghívó elfogadása token alapján |
|
||||
| POST /organizations/{org_id}/join-request | POST | Letevezik | Csatlakozási kérelem (ha van admin) |
|
||||
| POST /organizations/{org_id}/claim/request | POST | Letevezik | Árva cég átvételi kérelem |
|
||||
| POST /organizations/{org_id}/claim/verify | POST | Letevezik | Árva cég átvétel OTP-vel |
|
||||
|
||||
### 5.2 🔴 HIÁNYZÓ VÉGPONTOK (Kritikus)
|
||||
|
||||
| Végpont | Hiány | Hatás |
|
||||
|---------|-------|-------|
|
||||
| GET /organizations/{org_id}/members | Nincs | Nincs lehetőség a tagok listázására |
|
||||
| PATCH /organizations/{org_id}/members/{member_id}/role | Nincs | Nincs lehetőség a szerepkör módosítására |
|
||||
| DELETE /organizations/{org_id}/members/{member_id} | Nincs | Nincs lehetőség a tag eltávolítására |
|
||||
| GET /organizations/{org_id}/invitations | Nincs | Nincs lehetőség a függő meghívók listázására |
|
||||
| DELETE /organizations/{org_id}/invitations/{invitation_id} | Nincs | Nincs lehetőség a meghívó visszavonására |
|
||||
|
||||
### 5.3 🔴 Kritikus Adatbázis Probléma
|
||||
Az `OrganizationMember` modellben és az adatbázisban **HIÁNYZIK** a `status` oszlop, de a kód (`organizations.py:421` és `organizations.py:481`) hivatkozik rá (`status="pending"`, `status="active"`). Ez a meghívási folyamatban futási hibát okoz!
|
||||
|
||||
### 5.4 Meghívási Folyamat Ábrája
|
||||
|
||||
```
|
||||
OWNER/ADMIN -> POST /invitations -> Van-e user?
|
||||
|-> Igen -> Letrehoz OrganizationMember (user_id=target)
|
||||
|-> Nem -> Letrehoz VerificationToken (token_type=org_invite)
|
||||
|-> Email ertesites
|
||||
|
|
||||
Regisztracio utan -> POST /invitations/{token}/accept
|
||||
-> Token validalas
|
||||
-> Letezik-e mar tag?
|
||||
|-> Igen -> Frissiti a szerepkört
|
||||
|-> Nem -> Letrehozza a tag rekordot
|
||||
-> Done
|
||||
```
|
||||
|
||||
### 5.5 Javasolt Javítási Sorrend
|
||||
|
||||
1. **P0 - AZONNAL:** `status` oszlop hozzáadása az `OrganizationMember` modellhez és az adatbázishoz (sync_engine).
|
||||
2. **P0 - AZONNAL:** Dátum oszlopok (`created_at`, `updated_at`) hozzáadása.
|
||||
3. **P1 - JELEN FELADAT:** `get_my_organizations` javítása `OR` logikára.
|
||||
4. **P2 - KOVETKEZO:** Tagkezelő API végpontok implementálása (list, role change, remove).
|
||||
5. **P2 - KOVETKEZO:** Meghívókezelő API végpontok (list pending, revoke).
|
||||
|
||||
---
|
||||
|
||||
## 6. Végrehajtási Terv
|
||||
|
||||
### 6.1 Backend változtatások (Code mód)
|
||||
1. `backend/app/models/marketplace/organization.py` - `OrganizationMember` modellbe hozzáadni: `status`, `created_at`, `updated_at`
|
||||
2. `backend/app/api/v1/endpoints/organizations.py` - `get_my_organizations` query javítása `OR` logikára + `DISTINCT` + `user_role` visszaadása
|
||||
|
||||
### 6.2 Frontend változtatások (Code mód)
|
||||
- Nincs szükség változtatásra - a frontend már helyesen használja az API-t.
|
||||
|
||||
### 6.3 Adatbázis szinkron
|
||||
- Futtatni: `docker exec sf_api python3 -m app.scripts.sync_engine`
|
||||
|
||||
---
|
||||
|
||||
## 7. Jóváhagyás
|
||||
|
||||
**Kérem a felhasználó jóváhagyását a fenti tervhez!** A jóváhagyás után:
|
||||
1. Létrehozom a Gitea feladatkártyákat
|
||||
2. Váltok Code módba a megvalósításhoz
|
||||
179
plans/logic_spec_provider_cegeim_bugfix.md
Normal file
179
plans/logic_spec_provider_cegeim_bugfix.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 🐛 logic_spec: Quick-add provider cégek megjelennek a "Cégeim" menüben
|
||||
|
||||
**Kártya:** #266
|
||||
**Státusz:** Tervezés kész, kivitelezés előtt
|
||||
**Architect:** Service Finder Rendszer-Architect
|
||||
|
||||
---
|
||||
|
||||
## 1. Probléma összefoglaló
|
||||
|
||||
A gyors szolgáltató felvétel (`quick_add_provider`) során létrejövő `service_provider` típusú szervezetek három problémát okoznak:
|
||||
|
||||
| # | Probléma | Hatás |
|
||||
|---|----------|-------|
|
||||
| 1 | **"Cégeim" menüben való megjelenés** | A `service_provider` típusú szervezetek megjelennek a felhasználó saját cégei között |
|
||||
| 2 | **Tulajdonos hiánya** | A `GET /my` response nem adja vissza az `owner_id` mezőt; új provider-eknél `owner_id=null` lehet |
|
||||
| 3 | **Státusz megjelenítés** | Bár a DB-ben `status='pending_verification'`, a UI mást mutathat |
|
||||
|
||||
---
|
||||
|
||||
## 2. Érintett fájlok
|
||||
|
||||
### Backend
|
||||
- `backend/app/api/v1/endpoints/organizations.py:177` — `GET /my` lekérdezés
|
||||
- `backend/app/services/provider_service.py:468` — `quick_add_provider()` függvény
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/components/header/HeaderCompanySwitcher.vue:142` — `companyOrganizations` szűrő
|
||||
- `frontend/src/stores/auth.ts:589` — `fetchMyOrganizations()` hívás
|
||||
|
||||
---
|
||||
|
||||
## 3. Hibaelemzés részletesen
|
||||
|
||||
### 3.1. `GET /my` endpoint (`organizations.py:183-186`)
|
||||
|
||||
```python
|
||||
stmt = (
|
||||
select(Organization)
|
||||
.join(OrganizationMember)
|
||||
.where(OrganizationMember.user_id == current_user.id)
|
||||
)
|
||||
```
|
||||
|
||||
**Hiba:** Nincs `org_type` szűrés. Minden szervezetet visszaad, ahol a user tag.
|
||||
|
||||
**Response (193-206. sor):** Nem adja vissza az `owner_id` mezőt.
|
||||
|
||||
### 3.2. `HeaderCompanySwitcher.vue:142-146`
|
||||
|
||||
```typescript
|
||||
const companyOrganizations = computed(() => {
|
||||
return authStore.myOrganizations.filter(
|
||||
(org) => org.org_type && org.org_type !== 'individual'
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**Hiba:** Csak az `individual` típust szűri ki. A `service_provider`, `service` típusú szervezetek átmennek a szűrőn.
|
||||
|
||||
### 3.3. `quick_add_provider()` (`provider_service.py:521-646`)
|
||||
|
||||
- Létrehoz egy `Organization`-t `org_type='service_provider'`-rel (526. sor)
|
||||
- Beállítja `owner_id=user_id` (541. sor)
|
||||
- **Létrehoz `OrganizationMember`-et `role=OWNER`-rel** (638-646. sor) → ez miatt a `GET /my` visszaadja a szervezetet
|
||||
|
||||
### 3.4. Adatbázis státusz
|
||||
|
||||
```sql
|
||||
SELECT column_default FROM information_schema.columns
|
||||
WHERE table_schema='fleet' AND table_name='organizations' AND column_name='status';
|
||||
-- Result: 'pending_verification'::character varying
|
||||
```
|
||||
|
||||
A DB default helyes. A service_providerek `status='pending_verification'`, `is_verified=false`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Javítási terv
|
||||
|
||||
### 4.1. Backend: `GET /my` endpoint szűrés
|
||||
|
||||
**Fájl:** `backend/app/api/v1/endpoints/organizations.py:177`
|
||||
|
||||
**Módosítás:** Adjunk hozzá `org_type` szűrést, hogy csak a valódi céges típusok (`business`, `fleet_owner`, `individual`) jelenjenek meg:
|
||||
|
||||
```python
|
||||
stmt = (
|
||||
select(Organization)
|
||||
.join(OrganizationMember)
|
||||
.where(OrganizationMember.user_id == current_user.id)
|
||||
.where(Organization.org_type.in_([OrgType.business, OrgType.fleet_owner, OrgType.individual]))
|
||||
)
|
||||
```
|
||||
|
||||
**Alternatíva:** Szűrjük ki a `service_provider` és `service` típusokat:
|
||||
|
||||
```python
|
||||
.where(Organization.org_type.notin_([OrgType.service_provider, OrgType.service]))
|
||||
```
|
||||
|
||||
### 4.2. Frontend: `HeaderCompanySwitcher` szűrés (biztonsági réteg)
|
||||
|
||||
**Fájl:** `frontend/src/components/header/HeaderCompanySwitcher.vue:142`
|
||||
|
||||
**Módosítás:** Szűrjük ki a `service_provider` és `service` típusokat is:
|
||||
|
||||
```typescript
|
||||
const companyOrganizations = computed(() => {
|
||||
return authStore.myOrganizations.filter(
|
||||
(org) => org.org_type &&
|
||||
org.org_type !== 'individual' &&
|
||||
org.org_type !== 'service_provider' &&
|
||||
org.org_type !== 'service'
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### 4.3. Backend: `GET /my` response kiegészítése
|
||||
|
||||
**Fájl:** `backend/app/api/v1/endpoints/organizations.py:193`
|
||||
|
||||
**Módosítás:** Adjuk hozzá az `owner_id` mezőt a response-hoz:
|
||||
|
||||
```python
|
||||
return [
|
||||
{
|
||||
"organization_id": o.id,
|
||||
"owner_id": o.owner_id, # NEW
|
||||
"status": o.status,
|
||||
# ... existing fields
|
||||
}
|
||||
for o in orgs
|
||||
]
|
||||
```
|
||||
|
||||
### 4.4. Backend: `quick_add_provider` OrganizationMember létrehozásának felülvizsgálata
|
||||
|
||||
**Fájl:** `backend/app/services/provider_service.py:632`
|
||||
|
||||
**Megfontolandó:** A `quick_add_provider()` létrehozza az `OrganizationMember`-et `role=OWNER`-rel. Ez lehetővé teszi a user számára a provider szerkesztését (access control miatt), de emiatt a provider megjelenik a "Cégeim" listában.
|
||||
|
||||
**Megoldás:** Ha a 4.1-es és 4.2-es javítások életbe lépnek, akkor a service_provider típusú szervezetek nem fognak megjelenni a "Cégeim" listában, így az OrganizationMember létrehozása továbbra is működhet az access control miatt.
|
||||
|
||||
### 4.5. Adatbázis: hiányzó `owner_id`-k pótlása
|
||||
|
||||
**Megfontolandó:** A régebbi service_providerek, amik robot által vagy más úton jöttek létre és nincs `owner_id`-jük, kapjanak alapértelmezett ownershipet vagy maradjanak owner nélkül.
|
||||
|
||||
---
|
||||
|
||||
## 5. Masterbook 2.0 illeszkedés
|
||||
|
||||
| Elv | Illeszkedés |
|
||||
|-----|-------------|
|
||||
| **Dual Entity** | Az Organization (cég) és a User (technikai fiók) szétválasztása helyes. A `service_provider` típusú szervezeteket nem szabad a user saját cégeként kezelni. |
|
||||
| **DDD Szeparáció** | A `marketplace` domain (service_provider) és a `fleet` domain (business/fleet_owner) adatai nem keveredhetnek a UI-n. |
|
||||
| **Access Control** | Az OrganizationMember létrehozása továbbra is szükséges a provider szerkesztéséhez, de a UI-n való megjelenítést megfelelően kell szűrni. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Tesztelési terv
|
||||
|
||||
1. **Unit teszt:** `GET /my` endpoint hívása service_provider típusú szervezettel → nem jelenik meg
|
||||
2. **Integrációs teszt:** quick-add provider létrehozása → nem jelenik meg a Cégeim listában
|
||||
3. **Frontend teszt:** HeaderCompanySwitcher megjelenítése service_provider típusú org-gal → nem jelenik meg
|
||||
4. **Adatbázis teszt:** service_provider rekordok `status` és `owner_id` ellenőrzése
|
||||
|
||||
---
|
||||
|
||||
## 7. Jóváhagyási pont
|
||||
|
||||
A fenti terv alapján a javítás kivitelezéséhez az alábbi módosítások szükségesek:
|
||||
|
||||
- [ ] `organizations.py`: `GET /my` endpoint szűrés `org_type` alapján
|
||||
- [ ] `organizations.py`: `owner_id` hozzáadása a response-hoz
|
||||
- [ ] `HeaderCompanySwitcher.vue`: `service_provider` és `service` kiszűrése
|
||||
- [ ] Adatbázis audit: hiányzó `owner_id`-k ellenőrzése
|
||||
|
||||
**Architect jóváhagyása:** ⏳ Függőben
|
||||
@@ -1,123 +1,90 @@
|
||||
# 🔧 Logic Spec: Provider Update & Search Fix Csomag
|
||||
# 🔧 Fix Plan: Provider Update 500 Error (folder_slug Truncation)
|
||||
|
||||
## 🎯 Cél
|
||||
A szolgáltató cégek adatainak rögzítésében és szerkesztésében fellépő hibák javítása, valamint az adatok részletes rögzíthetőségének biztosítása.
|
||||
A `PUT /api/v1/providers/{id}` végpont által dobott 500-as hiba kijavítása, amikor a felhasználó egy olyan szolgáltató adatait szerkeszti, amely még **nincs** átmigrálva az `Organization` táblába (csak `ServiceStaging`-ben létezik).
|
||||
|
||||
## 📋 Problémák Összefoglalása
|
||||
## 🔍 Root Cause Analysis
|
||||
|
||||
### 1. PUT /api/v1/providers/{id} → 404 Not Found (✅ MEGOLDVA)
|
||||
- **Root Cause**: A `sf_api` konténer nem volt újraindítva a providers modul kódfrissítése után. A `pre_start.sh` fájlban `uvicorn` `--reload` flag nélkül fut.
|
||||
- **Javítás**: `docker compose restart sf_api` (végrehajtva)
|
||||
- **Verifikáció**: PUT /providers/58 → 200 OK
|
||||
|
||||
### 2. GET /api/v1/providers/search → 500 Internal Server Error (❌ JAVÍTANDÓ)
|
||||
- **Hiba**: `AttributeError: Neither 'BinaryExpression' object nor 'Comparator' object has an attribute 'astext'`
|
||||
- **Hibás kód**: [`provider_service.py:207`](../backend/app/services/provider_service.py:207)
|
||||
```python
|
||||
(Organization.external_integration_config["source"].astext == "crowdsourced", literal("crowd_added")),
|
||||
### Hibajelenség
|
||||
`PUT /api/v1/providers/4859` → `500 Internal Server Error`
|
||||
```
|
||||
- **Root Cause**: Az [`external_integration_config`](../backend/app/models/marketplace/organization.py:109) `JSON` típusú oszlop. A `["source"]` subscript `BinaryExpression`-t ad vissza, amelyen NINCS `.astext`.
|
||||
- **Javítás**: `cast` használata:
|
||||
```python
|
||||
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
|
||||
value too long for type character varying(12)
|
||||
```
|
||||
|
||||
### 3. Adat-healing: Régi címformátumú Organization rekordok (❌ JAVÍTANDÓ)
|
||||
- **Probléma**: Az Organization id=58 (`Autónyíri Kft.`) adatai a régi formátumban:
|
||||
- `street_name = "Egressy u. 4."` (nem atomizált)
|
||||
- `address_street_name = NULL`, `address_street_type = NULL`, `address_house_number = NULL`
|
||||
- `zip = NULL`
|
||||
- **Következmény**: A frontend DetailModal üres címet mutat.
|
||||
- **Javítás**: Adat-healing script a `street_name` mezőből atomizált komponensek kinyerésére.
|
||||
### Kiváltó ok
|
||||
A provider ID=4859 a `marketplace.service_staging` táblában létezik, de **nincs** még `fleet.organizations` rekordja.
|
||||
|
||||
### 4. Multi-source Update Probléma (❌ JAVÍTANDÓ)
|
||||
- **Probléma**: Az [`update_provider`](../backend/app/services/provider_service.py:477) CSAK `Organization`-ben keres. A search UNION-nal dolgozza fel a szolgáltatókat 3 forrásból (Organization, ServiceStaging, ServiceProvider).
|
||||
- **Javítás**: Multi-source update logika.
|
||||
Az [`update_provider()`](backend/app/services/provider_service.py:786) függvény a migrációs ágon (ServiceStaging → Organization) a `folder_slug`-ot az alábbi képlettel generálja:
|
||||
|
||||
### 5. Szerver Restart Workflow Hiánya (❌ JAVÍTANDÓ)
|
||||
- **Probléma**: A [`pre_start.sh`](../backend/app/scripts/pre_start.sh) `--reload` nélkül indul.
|
||||
- **Javítás**: `--reload` flag fejlesztői módban.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Érintett Fájlok
|
||||
|
||||
| Fájl | Változtatás | Prioritás |
|
||||
|------|-------------|-----------|
|
||||
| [`provider_service.py:207`](../backend/app/services/provider_service.py:207) | `.astext` → `cast()` | **KRITIKUS** |
|
||||
| [`provider_service.py:477-562`](../backend/app/services/provider_service.py:477) | Multi-source update | **MAGAS** |
|
||||
| Új: `heal_provider_addresses.py` | Adat-healing script | **MAGAS** |
|
||||
| [`pre_start.sh`](../backend/app/scripts/pre_start.sh) | `--reload` dev módban | **ALACSONY** |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Részletes Javítási Terv
|
||||
|
||||
### 1. `.astext` → `cast()` javítás
|
||||
|
||||
**Fájl**: [`provider_service.py`](../backend/app/services/provider_service.py:207)
|
||||
|
||||
**Jelenlegi:**
|
||||
```python
|
||||
(Organization.external_integration_config["source"].astext == "crowdsourced", literal("crowd_added")),
|
||||
folder_slug = f"sp-{staging.id}-{uuid.uuid4().hex[:6]}",
|
||||
```
|
||||
|
||||
**Javított:**
|
||||
- `"sp-"` = 3 karakter
|
||||
- `"4859"` = 4 karakter (provider_id hossza)
|
||||
- `"-"` = 1 karakter
|
||||
- `"4d45d3"` = 6 karakter (uuid hex)
|
||||
- **Összesen: 14 karakter**
|
||||
|
||||
Az [`Organization`](backend/app/models/marketplace/organization.py:75) modellben a `folder_slug` mező:
|
||||
```python
|
||||
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
|
||||
folder_slug: Mapped[str] = mapped_column(String(12), unique=True, index=True)
|
||||
```
|
||||
**Csak 12 karaktert enged!** → PostgreSQL `StringDataRightTruncationError`.
|
||||
|
||||
**Ugyanez a hiba** a crowd-sourced migrációs ágon is (line 820):
|
||||
```python
|
||||
folder_slug = f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}",
|
||||
```
|
||||
|
||||
### 2. Multi-source Update Logika
|
||||
### Összehasonlítás
|
||||
- **`quick_add_provider`** (line 501-503): `hashlib.md5(...).hexdigest()[:12]` → pontosan 12 karakter → **MŰKÖDIK**
|
||||
- **`update_provider` migration** (line 786, 820): `"sp-{id}-{hex}"` / `"cr-{id}-{hex}"` → **TÚL HOSSZÚ**
|
||||
|
||||
**Fájl**: [`provider_service.py:477`](../backend/app/services/provider_service.py:477)
|
||||
## 📋 Javítási Terv
|
||||
|
||||
Az `update_provider` ellenőrizze mindhárom forrást:
|
||||
1. `db.get(Organization, provider_id)` → meglévő logika
|
||||
2. `db.get(ServiceStaging, provider_id)` → migrálás Organization-be
|
||||
3. `db.get(ServiceProvider, provider_id)` → migrálás Organization-be
|
||||
4. Ha egyikben sem → ValueError
|
||||
### 1. Model fix: [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:75)
|
||||
**Változtatás:** `folder_slug` oszlop méretének növelése `String(12)` → `String(24)`
|
||||
|
||||
### 3. Adat-healing Script
|
||||
Indoklás:
|
||||
- A jelenlegi 12 karakter túl szűk
|
||||
- A `quick_add_provider` pontosan 12 karaktert használ → kompatibilis
|
||||
- A migrációs slug-ok (pl. `sp-{id}-{hex6}`) elférnek
|
||||
- A `unique=True` megszorítás megmarad
|
||||
|
||||
**Fájl**: `backend/app/scripts/heal_provider_addresses.py`
|
||||
### 2. Service fix: [`backend/app/services/provider_service.py`](backend/app/services/provider_service.py:786)
|
||||
**Változtatás:** A migrációs `folder_slug` generálás módosítása konzisztens `hashlib.md5` alapú generálásra.
|
||||
|
||||
1. Lekérdezni Organization rekordokat, ahol `org_type='service_provider'` ÉS `address_street_name IS NULL` ÉS `street_name IS NOT NULL`
|
||||
2. Regex: `r'^([^\d]+?)\s+(u\.|utca|út|tér|köz|sor|körút|liget|part|fasor|sétány|park|híd|sugárút|rakpart|dűlő|telep|szőlő)\s*(.*)$'`
|
||||
3. Frissíteni az atomizált mezőket
|
||||
4. Naplózás
|
||||
**Staging ág (line 786):**
|
||||
```python
|
||||
# EZT:
|
||||
folder_slug = f"sp-{staging.id}-{uuid.uuid4().hex[:6]}"
|
||||
# HELYETTE:
|
||||
folder_slug = hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12]
|
||||
```
|
||||
|
||||
---
|
||||
**Crowd ág (line 820):**
|
||||
```python
|
||||
# EZT:
|
||||
folder_slug = f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}"
|
||||
# HELYETTE:
|
||||
folder_slug = hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12]
|
||||
```
|
||||
|
||||
## 🧪 Tesztelési Terv
|
||||
Indoklás:
|
||||
- Konzisztens a `quick_add_provider` által használt módszerrel
|
||||
- Mindig pontosan 12 karakter → garantáltan elfér a `String(12)` mezőben is
|
||||
- Elég nagy entrópia az egyediséghez (`hashlib.md5(...)` → 32 hex, `[:12]` → 48 bit)
|
||||
|
||||
### Teszt 1: search működés
|
||||
### 3. Adatbázis szinkronizáció
|
||||
Mivel az oszlop mérete változik (`String(12)` → `String(24)`), futtatni kell:
|
||||
```bash
|
||||
docker compose exec sf_api python3 -c "
|
||||
import httpx, asyncio
|
||||
async def t():
|
||||
async with httpx.AsyncClient(base_url='http://localhost:8000') as c:
|
||||
r = await c.post('/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'})
|
||||
t = r.json()['access_token']
|
||||
r2 = await c.get('/api/v1/providers/search', params={'q': 'Dunakeszi', 'limit': 5}, headers={'Authorization': f'Bearer {t}'})
|
||||
print(f'Search: {r2.status_code}')
|
||||
print(r2.text[:500])
|
||||
asyncio.run(t())
|
||||
"
|
||||
docker exec sf_api python -m app.scripts.sync_engine
|
||||
```
|
||||
|
||||
### Teszt 2: PUT működés
|
||||
```bash
|
||||
docker compose exec sf_api python3 -c "
|
||||
import httpx, asyncio
|
||||
async def t():
|
||||
async with httpx.AsyncClient(base_url='http://localhost:8000') as c:
|
||||
r = await c.post('/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'})
|
||||
t = r.json()['access_token']
|
||||
r2 = await c.put('/api/v1/providers/58',
|
||||
json={'address_zip': '2120', 'city': 'Dunakeszi', 'name': 'Autónyíri Kft.'},
|
||||
headers={'Authorization': f'Bearer {t}'})
|
||||
print(f'PUT: {r2.status_code}')
|
||||
print(r2.text)
|
||||
asyncio.run(t())
|
||||
"
|
||||
```
|
||||
## ✅ Elfogadási kritériumok
|
||||
1. `PUT /api/v1/providers/4859` sikeresen lefut (200 OK)
|
||||
2. A migrált Organization `folder_slug` pontosan 12 karakter hosszú
|
||||
3. Meglévő provider-ek (`quick_add`-ból) továbbra is működnek
|
||||
4. A `folder_slug` `unique` megszorítás nem sérül
|
||||
5. A logokban nincs `StringDataRightTruncationError`
|
||||
|
||||
219
tests/active/test_garage_selector_and_team_api.py
Normal file
219
tests/active/test_garage_selector_and_team_api.py
Normal file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Teszt script a P0 Garage Selector Fix & Team Management API ellenőrzéséhez.
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/tests/active/test_garage_selector_and_team_api.py
|
||||
|
||||
Ez a script:
|
||||
1. Bejelentkezik admin@profibot.hu / Admin123! (Vezető Tervező, user_id=2)
|
||||
2. Meghívja a GET /api/v1/organizations/my végpontot
|
||||
3. Ellenőrzi, hogy a teszt cég (owner_id=2) megjelenik-e
|
||||
4. Teszteli az új Team Management API végpontokat
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Add the backend directory to path
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
API_PREFIX = "/api/v1"
|
||||
|
||||
|
||||
async def login(email: str, password: str) -> str:
|
||||
"""Bejelentkezés és token lekérése."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.post(
|
||||
f"{API_PREFIX}/auth/login",
|
||||
data={"username": email, "password": password},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Login failed: {resp.status_code} - {resp.text}")
|
||||
sys.exit(1)
|
||||
data = resp.json()
|
||||
token = data.get("access_token") or data.get("token")
|
||||
print(f"✅ Login successful (user: {email})")
|
||||
return token
|
||||
|
||||
|
||||
async def get_my_organizations(token: str) -> list:
|
||||
"""GET /api/v1/organizations/my - Saját szervezetek lekérése."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.get(
|
||||
f"{API_PREFIX}/organizations/my",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 GET /api/v1/organizations/my")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Error: {resp.text}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
print(f"Count: {len(data)} organizations found")
|
||||
print(f"\nRaw response:")
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def list_members(token: str, org_id: int) -> list:
|
||||
"""GET /api/v1/organizations/{org_id}/members - Tagok listázása."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.get(
|
||||
f"{API_PREFIX}/organizations/{org_id}/members",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 GET /api/v1/organizations/{org_id}/members")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Error: {resp.text}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
print(f"Count: {len(data)} members")
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
return data
|
||||
|
||||
|
||||
async def create_invitation(token: str, org_id: int, email: str, role: str = "MANAGER") -> dict:
|
||||
"""POST /api/v1/organizations/{org_id}/invitations - Meghívó küldése."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.post(
|
||||
f"{API_PREFIX}/organizations/{org_id}/invitations",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"email": email, "role": role},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 POST /api/v1/organizations/{org_id}/invitations (email={email}, role={role})")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text}")
|
||||
|
||||
if resp.status_code in (200, 201):
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def update_member_role(token: str, org_id: int, member_id: int, role: str) -> dict:
|
||||
"""PATCH /api/v1/organizations/{org_id}/members/{member_id} - Szerepkör módosítása."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.patch(
|
||||
f"{API_PREFIX}/organizations/{org_id}/members/{member_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"role": role},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 PATCH /api/v1/organizations/{org_id}/members/{member_id} (role={role})")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def remove_member(token: str, org_id: int, member_id: int) -> dict:
|
||||
"""DELETE /api/v1/organizations/{org_id}/members/{member_id} - Tag eltávolítása."""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
resp = await client.delete(
|
||||
f"{API_PREFIX}/organizations/{org_id}/members/{member_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📋 DELETE /api/v1/organizations/{org_id}/members/{member_id}")
|
||||
print(f"{'='*70}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.text}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🔍 P0 GARAGE SELECTOR FIX & TEAM MANAGEMENT API TESZT")
|
||||
print("=" * 70)
|
||||
|
||||
# 1. Bejelentkezés admin@profibot.hu (Vezető Tervező, user_id=2)
|
||||
token = await login("admin@profibot.hu", "Admin123!")
|
||||
|
||||
# 2. Saját szervezetek lekérése
|
||||
orgs = await get_my_organizations(token)
|
||||
|
||||
if not orgs:
|
||||
print("\n❌ CRITICAL: No organizations returned! Garage Selector would be empty!")
|
||||
print(" This means the get_my_organizations query is still broken.")
|
||||
sys.exit(1)
|
||||
|
||||
# 3. Ellenőrizzük, hogy van-e olyan org ahol owner_id=2 (admin user)
|
||||
admin_owned = [o for o in orgs if o.get("owner_id") == 2]
|
||||
if admin_owned:
|
||||
print(f"\n✅ Garage Selector FIX VERIFIED: {len(admin_owned)} organization(s) where owner_id=2:")
|
||||
for o in admin_owned:
|
||||
print(f" - ID: {o['organization_id']}, Name: {o.get('name', 'N/A')}, Role: {o.get('user_role', 'N/A')}")
|
||||
else:
|
||||
print(f"\n⚠️ No organizations found with owner_id=2. Checking all orgs...")
|
||||
for o in orgs:
|
||||
print(f" - ID: {o['organization_id']}, Name: {o.get('name', 'N/A')}, Owner: {o.get('owner_id', 'N/A')}, Role: {o.get('user_role', 'N/A')}")
|
||||
|
||||
# 4. Ha van legalább egy szervezet, teszteljük a Team Management API-t
|
||||
if orgs:
|
||||
test_org_id = orgs[0]["organization_id"]
|
||||
print(f"\n{'='*70}")
|
||||
print(f"🧪 TEAM MANAGEMENT API TESZTEK (org_id={test_org_id})")
|
||||
print(f"{'='*70}")
|
||||
|
||||
# 4a. Tagok listázása
|
||||
members = await list_members(token, test_org_id)
|
||||
|
||||
# 4b. Meghívó küldése egy nem létező emailre (invited_email teszt)
|
||||
test_email = f"test_invite_{datetime.now(timezone.utc).timestamp()}@example.com"
|
||||
invite_result = await create_invitation(token, test_org_id, test_email, "MANAGER")
|
||||
|
||||
if invite_result:
|
||||
print(f"\n✅ Invitation created successfully!")
|
||||
|
||||
# 4c. Tagok újralistázása (ellenőrizzük, hogy megjelent-e a meghívó)
|
||||
members_after = await list_members(token, test_org_id)
|
||||
|
||||
# Keressük az új meghívót
|
||||
pending_invites = [m for m in members_after if m.get("status") == "pending"]
|
||||
if pending_invites:
|
||||
print(f"\n✅ Pending invitation found in member list!")
|
||||
new_member_id = pending_invites[0]["id"]
|
||||
|
||||
# 4d. Szerepkör módosítása
|
||||
role_result = await update_member_role(token, test_org_id, new_member_id, "ADMIN")
|
||||
if role_result:
|
||||
print(f"\n✅ Role update successful!")
|
||||
|
||||
# 4e. Tag eltávolítása (meghívó visszavonása)
|
||||
remove_result = await remove_member(token, test_org_id, new_member_id)
|
||||
if remove_result:
|
||||
print(f"\n✅ Member removal successful!")
|
||||
else:
|
||||
print(f"\n⚠️ No pending invitations found in member list.")
|
||||
else:
|
||||
print(f"\n⚠️ Could not create test invitation (might already exist).")
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print("✅ TESZT BEFEJEZŐDÖTT")
|
||||
print(f"{'='*70}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user