céges meghívó kezelése,

This commit is contained in:
Roo
2026-06-17 22:07:55 +00:00
parent bf3a971ff1
commit 127b130401
28 changed files with 5806 additions and 1313 deletions

View File

@@ -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 - **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 - **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 ### 🎯 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 ### 🔧 Változtatások
**1. BACKEND AUDIT - [`api.py`](backend/app/api/v1/api.py:38):** **1. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:367):**
- Ellenőrizve: `providers` router regisztrálva van (`include_router(providers.router, prefix="/providers")`) - **"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.
- Ellenőrizve: providers végpontokon NINCS trailing slash (`/categories`, `/search`, `/quick-add`) - **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.
- **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 - **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):** **2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:661):**
- `fetchCategories()` már rendelkezik try/catch blokkal, 11 hardcoded fallback kategóriával - **`_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.
- Nincs szükség módosításra - **`_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):** ### ✅ Eredmény
- **Card 3 teljes átalakítása:** A 3-lépéses kontextuális kereső űrlap (input, 2 dropdown, keresés gomb) ELTÁVOLÍTVA - "Nincs kategória" fantom badge megszűnt: a feltétel most a `categories` tömböt ellenőrzi
- **Launcher dizájn:** Két nagy gomb, szépen formázva: - Telefon és weboldal linkek megjelennek a kártyákon (ha vannak)
- `🔍 Szervizek Keresése` — nagy, zöld gradient gomb, `isSearchModalOpen` modalt nyit - Custom tag mentés 500 helyett try-except blokkban fut, hibanaplózással
- ` Új Szolgáltató Rögzítése` — szekunder, dashed border gomb, `isSfQuickAddOpen` modalt nyit (meglévő ProviderQuickAddModal) - Python szintaxis ellenőrzés: OK
- **Search Modal (Placeholder):** Teleportált modál, benne: `🔍` ikon, "Részletes kereső térképpel hamarosan..." szöveg, fejlesztés alatt státusz
- Régi `sfSearchLocation`, `sfSearchCategory`, `sfSearchVehicleId`, `sfCategories`, `fetchSfCategories()`, `onSfSearch()` eltávolítva
## 2026-06-17 - P0 Bugfix: Provider Data Persistence & Schema Alignment ## 2026-06-17 - P0 UI Overhaul: Plus Code, Universal Categories, Detail Modal
### 🎯 Cél ### 🎯 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 ### 🔧 Változtatások
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py:1) (NEW):** **1. BACKEND - Plus Code mező:**
- `ProviderQuickAddIn`: `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva - [`Organization`](backend/app/models/marketplace/organization.py:86) model: `plus_code: Mapped[Optional[str]] = mapped_column(String(20))`
- `ProviderSearchResult`: `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva - [`ProviderSearchResult`](backend/app/schemas/provider.py:115): `plus_code: Optional[str] = None`
- `ProviderUpdateIn` (NEW): name, city, address_zip, street, contact_phone, contact_email, website, tags - [`ProviderQuickAddIn`](backend/app/schemas/provider.py:148): `plus_code: Optional[str] = Field(None, max_length=20)`
- `ProviderUpdateResponse` (NEW): id, name, status, message - [`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):** **2. FRONTEND - Univerzális kategóriák:**
- `quick_add_provider()`: contact mezők mentése ServiceProfile-ba - [`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
- `search_providers()`: LEFT JOIN ServiceProfile, contact mezők visszaadása - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue): Ugyanaz a `allLevel1Categories` logika
- `update_provider()` (NEW): Organization + ServiceProfile atomi frissítése - Plus Code input mező hozzáadva mindkét modal "Alapadatok" tabjához
**3. BACKEND - [`providers.py`](backend/app/api/v1/endpoints/providers.py:134):** **3. FRONTEND - Detail Modal Rework:**
- `PUT /providers/{id}` végpont hozzáadva (authentikált, hibakezeléssel) - [`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):** **4. i18n:**
- **CRITICAL FIX**: `handleSave()` most `api.put()`-et hív, nem csak eventet emitál - [`en.ts`](frontend/src/i18n/en.ts): plus_code és detail modal kulcsok
- `@save``@saved` (past tense, API call után) - [`hu.ts`](frontend/src/i18n/hu.ts): plus_code és detail modal magyar fordítások
- `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők támogatása
- `source` kivéve a payload-ból (backend-only)
**5. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:253):** ### ✅ Eredmény
- Telefon, email, weboldal, címkék mezők hozzáadva a formhoz - Backend Python import ellenőrzés: OK (provider.py, provider_service.py, providers.py endpoints)
- Tag management (vessző/pontosvessző parsing, remove gomb) - Frontend Vite build: OK (87 modules, 6.07s)
- Payload bővítése contact mezőkkel - Adatbázis sync_engine: OK (plus_code oszlop létrejött)
**6. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:375):** ## 2026-06-17 - P0 Triage Fix: Data Persistence & UNION ALL Column Mismatch
- `@save``@saved` event binding
- `handleEditSaved()`: mentés után automatikus keresés újrafuttatás ### 🎯 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ó ### ✅ 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 ### 🎯 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 ### 🔧 Változtatások
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py):** **1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:468):**
- `street` mező ELTÁVOLÍTVA a `ProviderQuickAddIn` és `ProviderUpdateIn` sémákból - **`owner_id=None``owner_id=user_id`** (531. sor): A létrehozott Organization most a hitelesített user-hez tartozik
- `address_street_name`, `address_street_type`, `address_house_number` mezők HOZZÁADVA mindkét sémához - **6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA** (585-600. sor): Teljesen új blokk a gamifikáció előtt:
- `ProviderSearchResult` bővítve az atomizált címmezőkkel ```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):** **2. BACKEND - [`providers.py`](backend/app/api/v1/endpoints/providers.py:311):**
- `quick_add_provider()`: `data.street``data.address_street_name`, `data.address_street_type`, `data.address_house_number` az Organization és Branch táblákban - `PermissionError` → `HTTPException(403)` kezelés hozzáadva a `update_service_provider` végponthoz
- `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
### ✅ Verifikáció ### ✅ Verifikáció
- `sync_engine` lefuttatva: **1061 elem OK, 0 javítás, 0 shadow data** - rendszer tökéletesen szinkronban - `owner_id=2` (admin user) sikeresen beállítva a létrehozott Organization-ben
- Python syntax check: minden fájl szintaktikailag helyes - `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 ### 🎯 Cél
Két kritikus hiba javítása a provider endpointokban: 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.
1. **PUT /providers/{id} → 404**: A konténer nem volt újraindítva a providers modul kódváltoztatásai után
2. **GET /providers/search → 500**: `.astext` hiba JSONB subscripten + UNION oszlopszám mismatch
3. **Multi-source update**: Az `update_provider` csak Organization-ben keresett, de a search 3 forrást használ
### 🔧 Változtatások ### 🔧 Változtatások
**1. BACKEND - [`provider_service.py:207`](backend/app/services/provider_service.py:207):** **1. BACKEND - [`provider.py`](backend/app/schemas/provider.py:135):**
- `.astext``cast()` javítás: `Organization.external_integration_config["source"].astext``cast(Organization.external_integration_config["source"], String)` - `ProviderQuickAddIn.category_id` típusa `int` → `Optional[int] = None` (opcionális)
- **Root cause**: JSON oszlop subscript-je `BinaryExpression`-t ad vissza, amelyen nincs `.astext` - Ú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):** **2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:468):**
- 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) - `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):** **3. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:781):**
- Multi-source update logika: `update_provider()` most már mindhárom forrást támogatja: - `phone` → `contact_phone` és `email` → `contact_email` mezőnevek javítva (illeszkedés a backend Pydantic sémához)
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
**4. INFRA - [`pre_start.sh`](backend/app/scripts/pre_start.sh):** ### ✅ Teszt eredmény
- Dokumentálva: a `uvicorn` `--reload` nélkül fut, kódváltoztatás után `docker compose restart sf_api` szükséges - 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):** ## 2026-06-17: Bug investigation - Quick-add provider cégek megjelennek a Cégeim menüben
- **Adatvédelmi javítás (2026-06-17):** A címmezők (`address_city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`) most már **csak akkor íródnak felül**, ha a frontend explicit nem-`null` értéket küld. Ez megakadályozza, hogy a meglévő címadatok véletlenül `null`-ra állítódjanak, amikor a felhasználó csak más mezőket szerkeszt.
- **Trigger:** A Gitea kártya visszautasításra került (`denied` státusz) a felhasználó által: *"az adatok tárolása minden esetben bontottan történjen meg és ha hiányzik valamelyik az alap cím tárolási adatból akkor vissza kell tenni."*
### ✅ Verifikáció **Kártya:** #266
- **PUT /providers/58**: 200 OK ✅ **Szerepkör:** Architect
- **PUT /providers/58 (partial update - csak zip)**: 200 OK ✅ (többi mező nem nullázódik) **Állapot:** Tervezés kész
- **GET /providers/search?q=Dunakeszi**: 200 OK ✅ (2 provider)
- **GET /providers/categories**: 200 OK ✅ (11 categories)
- **Login**: 200 OK ✅
## 2026-06-17 - i18n: Hiányzó provider címmező fordítások hozzáadása ### 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 ### 🎯 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 ### 🔧 Változtatások
**1. FRONTEND - [`hu.ts`](frontend/src/i18n/hu.ts:1300):** **1. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:177-228):**
- `provider.streetNameLabel`: `'Utca neve'` - 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
- `provider.streetNamePlaceholder`: `'Pl. Egressy'` - A `service_provider`/`service` típusú org-ok utólagos szűrése: csak akkor jelennek meg, ha a user role = `OWNER`
- `provider.streetTypeLabel`: `'Közterület jellege'` - A válaszban új `user_role` mező került visszaadásra
- `provider.streetTypePlaceholder`: `'Válassz típust...'`
- `provider.houseNumberLabel`: `'Házszám'`
- `provider.houseNumberPlaceholder`: `'Pl. 4'`
**2. FRONTEND - [`en.ts`](frontend/src/i18n/en.ts:1300):** **2. FRONTEND - [`HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue:144-158):**
- `provider.streetNameLabel`: `'Street Name'` - A `companyOrganizations` computed property most már a `user_role` mezőt is figyelembe veszi
- `provider.streetNamePlaceholder`: `'e.g. Egressy'` - `service_provider`/`service` típus csak `user_role === 'OWNER'` esetén jelenik meg
- `provider.streetTypeLabel`: `'Street Type'`
- `provider.streetTypePlaceholder`: `'Select type...'` **3. FRONTEND - [`organization.ts`](frontend/src/types/organization.ts:25-30):**
- `provider.houseNumberLabel`: `'House Number'` - Új `user_role?: string` mező az `OrganizationItem` interfészben
- `provider.houseNumberPlaceholder`: `'e.g. 4'`
### 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ó ### ✅ Verifikáció
- Mindkét i18n fájl szintaktikailag helyes (Node.js require sikeres) - ✅ Sync engine: séma konzisztens (1065 elem OK)
- A ProviderEditModal.vue összes `t('provider.*')` hívása le van fedve - ✅ 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 ### 🎯 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 ### 🔧 Változtatások
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:198):** **1. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.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. - `get_my_organizations` query javítva: `INNER JOIN` → `LEFT JOIN` + `OR` feltétel (`owner_id` VAGY `OrganizationMember.user_id`)
- Példa eredmény: `"2120 Dunakeszi, Egressy utca 4"` a korábbi `"Dunakeszi"` helyett. - `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):** **2. BACKEND - [`organization.py`](backend/app/models/marketplace/organization.py:195):**
- Új `formatCardAddress(provider)` metódus, ami a kártyán az atomizált címmezőkből építi fel a címet. - `OrganizationMember` modellhez hozzáadva: `status` (String(20)), `created_at`, `updated_at` oszlopok
- Fallback: ha nincs atomizált adat, a backend által összefűzött `address` mezőt használja. - 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):** **3. ADATBÁZIS - `sync_engine`:**
- A `formattedAddress` computed property most már tartalmazza az `address_zip` mezőt is. - 3 hiányzó oszlop sikeresen hozzáadva a `fleet.organization_members` táblához
- Formátum: `"2120 Dunakeszi, Egressy utca 4"`
### ✅ Verifikáció **4. DOKUMENTÁCIÓ - [`organization_member_audit_report_2026-06-17.md`](docs/organization_member_audit_report_2026-06-17.md):**
- Backend API: `GET /api/v1/providers/search?city=Dunakeszi``address` = `"2120 Dunakeszi, Egressy utca 4"` - Részletes audit jelentés az OrganizationMember tábláról
- Backend API: Autónyíri Kft. (id=58) → `address_zip=2120`, `address_street_name=Egressy`, `address_street_type=utca`, `address_house_number=4` - Hiányzó API végpontok azonosítása (tagkezelés, meghívókezelés)
- Frontend build: `npm run build` sikeres, 0 hiba ✅ - Javasolt javítási sorrend
- Backend konténer újraindítva: `docker compose restart sf_api`
## 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 ### 🎯 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 ### 🔧 Változtatások
**1. FRONTEND - [`ServiceFinderView.vue:263`](frontend/src/views/ServiceFinderView.vue:263):** **1. BACKEND - [`organization.py`](backend/app/models/marketplace/organization.py:187-190):**
- A category badge `v-if="provider.category"` helyett `v-if`/`v-else` szerkezet: - `OrganizationMember` modellhez hozzáadva: `invited_email` (String(255)) és `expires_at` (DateTime(timezone=True)) oszlopok
- Ha van category: `bg-sf-accent/10` háttér, `text-sf-accent` szín - `OrgUserRole` enum szinkronizálva az adatbázisban lévő értékekkel: `OWNER, ADMIN, MANAGER, MEMBER, AGENT`
- Ha nincs: szaggatott vonalú (`border-dashed`) placeholder "Nincs kategória" / "No category" - `default=OrgUserRole.DRIVER` → `default=OrgUserRole.MEMBER`
**2. FRONTEND - [`hu.ts:1237`](frontend/src/i18n/hu.ts:1237):** **2. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:199-218):**
- `serviceFinder.noCategory`: `'Nincs kategória'` - **`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):** **3. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:326-607):**
- `serviceFinder.noCategory`: `'No category'` - **`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ó **4. ADATBÁZIS - `sync_engine`:**
- Frontend build: `npm run build` sikeres, 0 hiba ✅ - 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,23 @@
""" """
Provider végpontok Szolgáltató keresés és gyors felvétel (crowdsourced). 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 import logging
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.db.session import get_db from app.db.session import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user
@@ -20,6 +30,8 @@ from app.schemas.provider import (
ProviderUpdateIn, ProviderUpdateIn,
ProviderUpdateResponse, ProviderUpdateResponse,
ExpertiseCategoryOut, ExpertiseCategoryOut,
CategoryTreeNode,
CategoryAutocompleteItem,
) )
from app.services.provider_service import search_providers, quick_add_provider, update_provider from app.services.provider_service import search_providers, quick_add_provider, update_provider
@@ -27,6 +39,114 @@ router = APIRouter()
logger = logging.getLogger(__name__) 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]) @router.get("/categories", response_model=List[ExpertiseCategoryOut])
async def list_expertise_categories( async def list_expertise_categories(
db: AsyncSession = Depends(get_db), 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. 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: try:
stmt = select(ExpertiseTag).order_by(ExpertiseTag.id) stmt = select(ExpertiseTag).order_by(ExpertiseTag.level, ExpertiseTag.id)
result = await db.execute(stmt) result = await db.execute(stmt)
tags = result.scalars().all() tags = result.scalars().all()
return [ return [
@@ -46,6 +167,9 @@ async def list_expertise_categories(
name_hu=t.name_hu, name_hu=t.name_hu,
name_en=t.name_en, name_en=t.name_en,
category=t.category, category=t.category,
level=t.level,
parent_id=t.parent_id,
path=t.path,
) )
for t in tags 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ó"), 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)"), 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ő"), 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"), 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"), offset: int = Query(0, ge=0, description="Lapozási offset"),
db: AsyncSession = Depends(get_db), 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) - **crowd_added**: Közösség által hozzáadott adatok (marketplace.service_providers)
A találatok forrás szerint csoportosítva, lapozva érkeznek. 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: 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( result = await search_providers(
db=db, db=db,
q=q, q=q,
category=category, category=category,
city=city, city=city,
category_ids=parsed_category_ids,
limit=limit, limit=limit,
offset=offset, offset=offset,
) )
return result return result
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Hiba a szolgáltató keresés során: {e}", exc_info=True) logger.error(f"Hiba a szolgáltató keresés során: {e}", exc_info=True)
raise HTTPException( raise HTTPException(
@@ -143,6 +286,14 @@ async def update_service_provider(
Frissíti a szervezet alapadatait (név, cím) és a kapcsolódó Frissíti a szervezet alapadatait (név, cím) és a kapcsolódó
ServiceProfile kapcsolati mezőit (telefon, email, weboldal, címkék). 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: try:
result = await update_provider( result = await update_provider(
@@ -157,6 +308,11 @@ async def update_service_provider(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=str(e), detail=str(e),
) )
except PermissionError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except Exception as e: except Exception as e:
logger.error(f"Hiba a szolgáltató frissítése során (id={provider_id}): {e}", exc_info=True) logger.error(f"Hiba a szolgáltató frissítése során (id={provider_id}): {e}", exc_info=True)
raise HTTPException( raise HTTPException(

View File

@@ -6,7 +6,7 @@ from app.database import Base
from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole, OneTimePassword 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) # 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 # 3. Földrajzi adatok és címek
from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
@@ -59,7 +59,7 @@ ServiceRecord = AssetEvent
__all__ = [ __all__ = [
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount", "Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole", "Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole", "OrgRole",
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials", "Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading", "AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch", "Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",

View File

@@ -27,10 +27,27 @@ class OrgType(str, enum.Enum):
class OrgUserRole(str, enum.Enum): class OrgUserRole(str, enum.Enum):
OWNER = "OWNER" OWNER = "OWNER"
ADMIN = "ADMIN" ADMIN = "ADMIN"
FLEET_MANAGER = "FLEET_MANAGER" MANAGER = "MANAGER"
DRIVER = "DRIVER" MEMBER = "MEMBER"
MECHANIC = "MECHANIC" AGENT = "AGENT"
RECEPTIONIST = "RECEPTIONIST"
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): class Organization(Base):
""" """
@@ -72,7 +89,7 @@ class Organization(Base):
full_name: Mapped[str] = mapped_column(String, nullable=False) full_name: Mapped[str] = mapped_column(String, nullable=False)
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)) 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") default_currency: Mapped[str] = mapped_column(String(3), default="HUF")
country_code: Mapped[str] = mapped_column(String(2), default="HU") 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_street_type: Mapped[Optional[str]] = mapped_column(String(50))
address_house_number: Mapped[Optional[str]] = mapped_column(String(20)) address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50)) 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) tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
reg_number: Mapped[Optional[str]] = mapped_column(String(50)) 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") 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 --- # --- 🔍 CROWDSOURCED SEARCH FIELDS ---
# Provider nicknames / alternative names for matching (e.g. ["MOL", "MOL LUB", "MOL Magyarország"]) # Provider nicknames / alternative names for matching (e.g. ["MOL", "MOL LUB", "MOL Magyarország"])
aliases: Mapped[list] = mapped_column( aliases: Mapped[list] = mapped_column(
@@ -183,14 +209,27 @@ class OrganizationMember(Base):
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id")) user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id")) person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
role: Mapped[OrgUserRole] = mapped_column( # Meghívott e-mail címe (ha a meghívottnak még nincs user fiókja)
PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet"), invited_email: Mapped[Optional[str]] = mapped_column(String(255))
default=OrgUserRole.DRIVER
# 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")) permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
is_permanent: Mapped[bool] = mapped_column(Boolean, default=False) is_permanent: Mapped[bool] = mapped_column(Boolean, default=False)
is_verified: 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") organization: Mapped["Organization"] = relationship("Organization", back_populates="members")
user: Mapped[Optional["User"]] = relationship("User") user: Mapped[Optional["User"]] = relationship("User")
person: Mapped[Optional["Person"]] = relationship("Person", back_populates="memberships") person: Mapped[Optional["Person"]] = relationship("Person", back_populates="memberships")

View File

@@ -80,9 +80,21 @@ class ExpertiseTag(Base):
""" """
Szakmai címkék mesterlistája (MB 2.0). 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. 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" __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) id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(50), unique=True, index=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)) name_en: Mapped[Optional[str]] = mapped_column(String(100))
category: Mapped[Optional[str]] = mapped_column(String(30), index=True) 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 --- # --- 🎮 GAMIFICATION ÉS DISCOVERY ---
is_official: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true")) 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")) 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()) 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()) 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") services: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="tag")
suggested_by: Mapped[Optional["Person"]] = relationship("Person") suggested_by: Mapped[Optional["Person"]] = relationship("Person")

View File

@@ -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. P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
- street -> address_street_name, address_street_type, address_house_number - street -> address_street_name, address_street_type, address_house_number
- A ProviderSearchResult is tartalmazza az atomizált címmezőket. - 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 pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List 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 id: int
key: str key: str
name_hu: Optional[str] = None name_hu: Optional[str] = None
name_en: 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) 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): class ProviderSearchResult(BaseModel):
"""Egy szolgáltató keresési találatának adatai. """Egy szolgáltató keresési találatának adatai.
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők. P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
- address_street_name, address_street_type, address_house_number - address_street_name, address_street_type, address_house_number
- A frontend ezeket intelligensen fűzi össze megjelenítéskor. - 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 id: int
name: str name: str
category: Optional[str] = None category: Optional[str] = None
specialization: List[str] = Field(default_factory=list) specialization: List[str] = Field(default_factory=list)
categories: List[CategoryInfo] = Field(default_factory=list)
city: Optional[str] = None city: Optional[str] = None
address: Optional[str] = None address: Optional[str] = None
address_zip: Optional[str] = None address_zip: Optional[str] = None
address_street_name: Optional[str] = None address_street_name: Optional[str] = None
address_street_type: Optional[str] = None address_street_type: Optional[str] = None
address_house_number: Optional[str] = None address_house_number: Optional[str] = None
plus_code: Optional[str] = None
contact_phone: Optional[str] = None contact_phone: Optional[str] = None
contact_email: Optional[str] = None contact_email: Optional[str] = None
website: 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. 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 - 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") 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") 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_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_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_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)") 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_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
contact_email: Optional[str] = Field(None, max_length=255, description="Email cí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") 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. 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 - 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. - 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") name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
city: Optional[str] = Field(None, max_length=100, description="Város") 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_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_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)") 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_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
contact_email: Optional[str] = Field(None, max_length=255, description="Email cí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") 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)") 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): class ProviderUpdateResponse(BaseModel):
@@ -110,3 +205,4 @@ class ProviderUpdateResponse(BaseModel):
id: int id: int
name: str name: str
message: str = "Szolgáltató adatai sikeresen frissítve." message: str = "Szolgáltató adatai sikeresen frissítve."
earned_points: int = 0

View File

@@ -51,6 +51,12 @@ SEED_RULES = [
"description": "Szolgáltató értékelése (tagekkel)", "description": "Szolgáltató értékelése (tagekkel)",
"is_active": True, "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,
},
] ]

View File

@@ -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 - 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 - 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. - 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 logging
import re
import uuid import uuid
import hashlib import hashlib
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, List, Tuple 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole 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.identity.social import ServiceProvider
from app.models.gamification.gamification import PointRule, UserStats from app.models.gamification.gamification import PointRule, UserStats
from app.schemas.provider import ( from app.schemas.provider import (
@@ -42,6 +49,7 @@ from app.schemas.provider import (
ProviderQuickAddResponse, ProviderQuickAddResponse,
ProviderUpdateIn, ProviderUpdateIn,
ProviderUpdateResponse, ProviderUpdateResponse,
CategoryInfo,
) )
from app.services.gamification_service import gamification_service from app.services.gamification_service import gamification_service
@@ -132,6 +140,7 @@ async def search_providers(
q: Optional[str] = None, q: Optional[str] = None,
category: Optional[str] = None, category: Optional[str] = None,
city: Optional[str] = None, city: Optional[str] = None,
category_ids: Optional[List[int]] = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
) -> ProviderSearchResponse: ) -> ProviderSearchResponse:
@@ -144,11 +153,27 @@ async def search_providers(
P1 CRITICAL ALIGN (2026-06-17): Az org lekérdezés most már visszaadja 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. 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: Args:
db: AsyncSession db: AsyncSession
q: Keresőszó (tokenizálva, AND kapcsolat) — opcionális q: Keresőszó (tokenizálva, AND kapcsolat) — opcionális
category: Kategória szűrő category: Kategória szűrő
city: Város szűrő — AND kapcsolat a q-val 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 limit: Lapozási limit
offset: Lapozási offset offset: Lapozási offset
@@ -175,18 +200,42 @@ async def search_providers(
or_( or_(
Organization.name.ilike(f"%{token}%"), Organization.name.ilike(f"%{token}%"),
cast(Organization.aliases, Text).ilike(f"%{token}%"), cast(Organization.aliases, Text).ilike(f"%{token}%"),
cast(Organization.tags, Text).ilike(f"%{token}%"),
) )
) )
org_conditions.append(and_(*token_conditions)) org_conditions.append(and_(*token_conditions))
if city: if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
city_clean = city.strip()
org_conditions.append( org_conditions.append(
or_( or_(
Organization.address_city.ilike(f"%{city}%"), Organization.address_city == city_clean,
Organization.address_zip.ilike(f"%{city}%"), 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 # 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). # 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 # 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_name.label("address_street_name"),
Organization.address_street_type.label("address_street_type"), Organization.address_street_type.label("address_street_type"),
Organization.address_house_number.label("address_house_number"), Organization.address_house_number.label("address_house_number"),
Organization.plus_code.label("plus_code"),
Organization.is_verified.label("is_verified"), Organization.is_verified.label("is_verified"),
ServiceProfile.contact_phone.label("contact_phone"), ServiceProfile.contact_phone.label("contact_phone"),
ServiceProfile.contact_email.label("contact_email"), ServiceProfile.contact_email.label("contact_email"),
@@ -234,10 +284,14 @@ async def search_providers(
) )
staging_conditions.append(and_(*token_conditions)) staging_conditions.append(and_(*token_conditions))
if city: if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
city_clean = city.strip()
staging_conditions.append( staging_conditions.append(
or_( or_(
ServiceStaging.city.ilike(f"%{city}%"), ServiceStaging.city == city_clean,
ServiceStaging.postal_code.ilike(f"%{city}%"), 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_name"),
literal(None).label("address_street_type"), literal(None).label("address_street_type"),
literal(None).label("address_house_number"), literal(None).label("address_house_number"),
literal(None).label("plus_code"),
literal(False).label("is_verified"), literal(False).label("is_verified"),
literal(None).label("contact_phone"), literal(None).label("contact_phone"),
literal(None).label("contact_email"), literal(None).label("contact_email"),
@@ -270,8 +325,13 @@ async def search_providers(
) )
crowd_conditions.append(and_(*token_conditions)) crowd_conditions.append(and_(*token_conditions))
if city: if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
city_clean = city.strip()
crowd_conditions.append( crowd_conditions.append(
ServiceProvider.address.ilike(f"%{city}%") or_(
ServiceProvider.address == city_clean,
ServiceProvider.address.ilike(f"{city_clean}%"),
)
) )
crowd_stmt = select( crowd_stmt = select(
@@ -283,6 +343,7 @@ async def search_providers(
literal(None).label("address_street_name"), literal(None).label("address_street_name"),
literal(None).label("address_street_type"), literal(None).label("address_street_type"),
literal(None).label("address_house_number"), literal(None).label("address_house_number"),
literal(None).label("plus_code"),
literal(False).label("is_verified"), literal(False).label("is_verified"),
literal(None).label("contact_phone"), literal(None).label("contact_phone"),
literal(None).label("contact_email"), literal(None).label("contact_email"),
@@ -315,6 +376,43 @@ async def search_providers(
result = await db.execute(paginated_query) result = await db.execute(paginated_query)
rows = result.fetchall() 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: for row in rows:
tags_list: list = [] tags_list: list = []
spec_tags = getattr(row, "specialization_tags", None) spec_tags = getattr(row, "specialization_tags", None)
@@ -323,6 +421,19 @@ async def search_providers(
if user_tags: if user_tags:
tags_list = list(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( results.append(
ProviderSearchResult( ProviderSearchResult(
id=row.id, id=row.id,
@@ -333,10 +444,12 @@ async def search_providers(
address_street_name=getattr(row, "address_street_name", None), address_street_name=getattr(row, "address_street_name", None),
address_street_type=getattr(row, "address_street_type", None), address_street_type=getattr(row, "address_street_type", None),
address_house_number=getattr(row, "address_house_number", 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_phone=getattr(row, "contact_phone", None),
contact_email=getattr(row, "contact_email", None), contact_email=getattr(row, "contact_email", None),
website=getattr(row, "website", None), website=getattr(row, "website", None),
tags=tags_list, tags=tags_list,
categories=categories_out,
source=row.source, source=row.source,
is_verified=row.is_verified, 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 - data.street -> data.address_street_name, data.address_street_type, data.address_house_number
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek. - 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: 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') 2. Organization létrehozása (is_verified=False, org_type='service_provider')
3. ServiceProfile létrehozása 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 5. Branch létrehozása a címmel
6. OrganizationMember létrehozása a felhasználóhoz 6. OrganizationMember létrehozása a felhasználóhoz
@@ -383,10 +501,15 @@ async def quick_add_provider(
Raises: Raises:
ValueError: Ha a kategória nem létezik ValueError: Ha a kategória nem létezik
""" """
# 1. Kategória ellenőrzése # 1. (Opcionális) Elsődleges kategória ellenőrzése
category = await db.get(ExpertiseTag, data.category_id) primary_category_key = None
if not category: if data.category_id is not None:
raise ValueError(f"Category with id={data.category_id} not found") 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 # 2. Organization létrehozása
folder_slug = hashlib.md5( folder_slug = hashlib.md5(
@@ -414,6 +537,7 @@ async def quick_add_provider(
address_street_name=data.address_street_name, address_street_name=data.address_street_name,
address_street_type=data.address_street_type, address_street_type=data.address_street_type,
address_house_number=data.address_house_number, address_house_number=data.address_house_number,
plus_code=data.plus_code,
owner_id=None, owner_id=None,
first_registered_at=datetime.now(timezone.utc), first_registered_at=datetime.now(timezone.utc),
current_lifecycle_started_at=datetime.now(timezone.utc), current_lifecycle_started_at=datetime.now(timezone.utc),
@@ -428,7 +552,9 @@ async def quick_add_provider(
).hexdigest()[:64] ).hexdigest()[:64]
# specialization_tags összeállítása: primary_category + user-supplied tags # 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: if data.tags:
spec_tags["user_tags"] = data.tags spec_tags["user_tags"] = data.tags
@@ -445,13 +571,49 @@ async def quick_add_provider(
db.add(profile) db.add(profile)
await db.flush() await db.flush()
# 4. ServiceExpertise kapcsolat # =====================================================================
expertise = ServiceExpertise( # 4. 🏗️ ServiceExpertise kapcsolatok létrehozása (4-LEVEL CATEGORY)
service_id=profile.id, # =====================================================================
expertise_id=data.category_id, # Összegyűjtjük az összes kategória ID-t a ServiceExpertise kapcsolatokhoz.
confidence_level=50, # Közepes bizonyosság (crowdsourced) all_category_ids: List[int] = []
)
db.add(expertise) # 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=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 # 5. Branch létrehozása atomizált címmezőkkel
branch = Branch( branch = Branch(
@@ -467,6 +629,28 @@ async def quick_add_provider(
) )
db.add(branch) 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) # 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( async def update_provider(
db: AsyncSession, db: AsyncSession,
provider_id: int, 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 - data.street -> data.address_street_name, data.address_street_type, data.address_house_number
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek. - 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: Multi-source update (2026-06-17): Támogatja mindhárom forrást:
1. fleet.organizations (verified orgs) - közvetlen frissítés 1. fleet.organizations (verified orgs) - közvetlen frissítés
2. marketplace.service_staging (robot adatok) - migrálás Organization-be 2. marketplace.service_staging (robot adatok) - migrálás Organization-be
@@ -527,15 +883,43 @@ async def update_provider(
Raises: Raises:
ValueError: Ha a szolgáltató nem található ValueError: Ha a szolgáltató nem található
PermissionError: Ha a felhasználó nem tagja a szervezetnek
""" """
# 1. Organization lekérdezése # 1. Organization lekérdezése
org = await db.get(Organization, provider_id) 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: if not org:
# 1b. Ha nincs Organization-ben, ServiceStaging-ben keresünk # 1b. Ha nincs Organization-ben, ServiceStaging-ben keresünk
staging = await db.get(ServiceStaging, provider_id) staging = await db.get(ServiceStaging, provider_id)
if staging: if staging:
# Migráljuk Organization-be # Migráljuk Organization-be
now = datetime.now(timezone.utc)
org = Organization( org = Organization(
id=staging.id, id=staging.id,
name=staging.name[:100], name=staging.name[:100],
@@ -549,7 +933,16 @@ async def update_provider(
address_house_number=data.address_house_number, address_house_number=data.address_house_number,
status="pending_verification", status="pending_verification",
is_verified=False, 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) db.add(org)
await db.flush() await db.flush()
@@ -562,6 +955,7 @@ async def update_provider(
crowd = await db.get(ServiceProvider, provider_id) crowd = await db.get(ServiceProvider, provider_id)
if crowd: if crowd:
# Migráljuk Organization-be # Migráljuk Organization-be
now = datetime.now(timezone.utc)
org = Organization( org = Organization(
id=crowd.id, id=crowd.id,
name=crowd.name[:100], name=crowd.name[:100],
@@ -575,7 +969,16 @@ async def update_provider(
address_house_number=data.address_house_number, address_house_number=data.address_house_number,
status="pending_verification", status="pending_verification",
is_verified=False, 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) db.add(org)
await db.flush() await db.flush()
@@ -605,6 +1008,8 @@ async def update_provider(
org.address_street_type = data.address_street_type org.address_street_type = data.address_street_type
if data.address_house_number is not None: if data.address_house_number is not None:
org.address_house_number = data.address_house_number 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) # 3. ServiceProfile lekérdezése (ha létezik)
profile_stmt = select(ServiceProfile).where( profile_stmt = select(ServiceProfile).where(
@@ -625,26 +1030,156 @@ async def update_provider(
spec_tags["user_tags"] = data.tags spec_tags["user_tags"] = data.tags
profile.specialization_tags = spec_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( logger.info(
f"Provider update: org_id={org.id}, profile_id={profile.id}, " f"Provider update: org_id={org.id}, profile_id={profile.id}, "
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, " 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: 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( logger.warning(
f"Provider update: org_id={org.id} has no ServiceProfile. " 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() await db.commit()
logger.info( logger.info(
f"Provider updated successfully: org_id={org.id}, " 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( return ProviderUpdateResponse(
id=org.id, id=org.id,
name=data.name, name=data.name,
message="Szolgáltató adatai sikeresen frissítve.", message="Szolgáltató adatai sikeresen frissítve.",
earned_points=earned_points,
) )

File diff suppressed because it is too large Load Diff

View File

@@ -34,5 +34,43 @@
"BUTTON": "Go to Dashboard", "BUTTON": "Go to Dashboard",
"FOOTER": "Service Finder - Test system message" "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."
}
} }
} }

View File

@@ -41,5 +41,43 @@
}, },
"COMMON": { "COMMON": {
"SAVE_SUCCESS": "Sikeres mentés!" "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."
}
} }
} }

View 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())

View 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

View File

@@ -139,9 +139,14 @@ onMounted(async () => {
}) })
// ── Filter: only real companies (exclude individual/private orgs) ── // ── 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(() => { const companyOrganizations = computed(() => {
return authStore.myOrganizations.filter( 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'
) )
}) })

View File

@@ -17,7 +17,7 @@
</svg> </svg>
</div> </div>
<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>
</div> </div>
<button <button
@@ -33,116 +33,149 @@
<!-- Body --> <!-- Body -->
<div class="p-6 space-y-5"> <div class="p-6 space-y-5">
<!-- Provider Name --> <!-- 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>
<!-- Categories (chips/badges) -->
<div> <div>
<h2 class="text-2xl font-extrabold text-slate-800">{{ provider.name }}</h2> <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> </div>
<!-- Category --> <!-- Contact Grid -->
<div v-if="provider.category" class="flex items-start gap-3"> <div>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent"> <p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailContact') }}</p>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="grid grid-cols-1 gap-2">
<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" /> <!-- Phone -->
</svg> <a
</div> v-if="provider.contact_phone"
<div> :href="'tel:' + provider.contact_phone"
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailCategory') }}</p> 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"
<p class="text-sm font-medium text-slate-800">{{ provider.category }}</p> >
</div> <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">
</div> <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" />
<!-- Address (P1 CRITICAL ALIGN: atomizált címmezők intelligens összefűzése) --> </svg>
<div v-if="hasAddress" class="flex items-start gap-3"> </div>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent"> <span>{{ provider.contact_phone }}</span>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </a>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /> <div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /> <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
</svg> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <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" />
<div> </svg>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailAddress') }}</p> </div>
<p class="text-sm font-medium text-slate-800"> <span class="italic">{{ t('provider.detailNoPhone') }}</span>
{{ formattedAddress }}
</p>
</div>
</div>
<!-- Source -->
<div class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSource') }}</p>
<p class="text-sm font-medium text-slate-800">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="sourceBadgeClass"
>
{{ sourceLabel }}
</span>
</p>
</div>
</div>
<!-- Specialization -->
<div v-if="provider.specialization && provider.specialization.length > 0" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSpecialization') }}</p>
<div class="flex flex-wrap gap-1.5 mt-1">
<span
v-for="(tag, idx) in provider.specialization"
:key="idx"
class="inline-flex items-center rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
</span>
</div> </div>
</div>
</div>
<!-- Rating --> <!-- Email -->
<div v-if="provider.rating" class="flex items-start gap-3"> <a
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent"> v-if="provider.contact_email"
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> :href="'mailto:' + provider.contact_email"
<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" /> 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"
</svg> >
</div> <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">
<div> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p> <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" />
<div class="flex items-center gap-1 mt-0.5"> </svg>
<svg </div>
v-for="star in 5" <span class="truncate">{{ provider.contact_email }}</span>
:key="star" </a>
class="h-4 w-4" <div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
:class="star <= Math.round(provider.rating!) ? 'text-amber-400' : 'text-slate-200'" <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
fill="currentColor" <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
viewBox="0 0 20 20" <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>
<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" /> </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> </svg>
<span class="ml-1 text-sm font-medium text-slate-700">{{ provider.rating.toFixed(1) }}</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="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> </div>
</div> </div>
<!-- No rating fallback --> <!-- Location & Plus Code -->
<div v-if="!provider.rating" class="flex items-start gap-3"> <div v-if="hasAddress || provider.plus_code">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400"> <p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailLocation') }}</p>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> <div class="rounded-xl bg-slate-50 p-4 space-y-3">
<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" /> <!-- Address -->
</svg> <div v-if="hasAddress" class="flex items-start gap-3">
</div> <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<div> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<p class="text-sm text-slate-400 italic">{{ t('serviceFinder.detailNoRating') }}</p> <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>
<p class="text-sm font-medium text-slate-700">{{ formattedAddress }}</p>
</div>
<!-- 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 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>
<p class="text-sm font-medium text-slate-700 font-mono">{{ provider.plus_code }}</p>
</div>
<!-- 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="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>
{{ t('provider.detailNavigate') }}
</a>
</div> </div>
</div> </div>
@@ -169,20 +202,30 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t, locale } = useI18n()
// ── Types matching backend ProviderSearchResult ── // ── Types matching backend ProviderSearchResult ──
interface CategoryInfo {
id: number
name_hu: string | null
name_en: string | null
level: number
key: string
}
interface ProviderSearchResult { interface ProviderSearchResult {
id: number id: number
name: string name: string
category: string | null category: string | null
specialization: string[] specialization: string[]
categories: CategoryInfo[]
city: string | null city: string | null
address: string | null address: string | null
address_zip: string | null address_zip: string | null
address_street_name: string | null address_street_name: string | null
address_street_type: string | null address_street_type: string | null
address_house_number: string | null address_house_number: string | null
plus_code: string | null
contact_phone: string | null contact_phone: string | null
contact_email: string | null contact_email: string | null
website: 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. * 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 hasAddress = computed(() => {
const p = props.provider const p = props.provider
@@ -258,6 +323,31 @@ const formattedAddress = computed(() => {
return parts.join(', ') 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> </script>
<style scoped> <style scoped>

View File

@@ -6,10 +6,11 @@
@click.self="emit('close')" @click.self="emit('close')"
> >
<div <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 --> <!-- 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 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"> <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"> <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -32,178 +33,395 @@
</button> </button>
</div> </div>
<!-- Form Body --> <!-- Tab Bar -->
<div class="p-6 space-y-5"> <div class="flex border-b border-slate-200 px-6 shrink-0">
<!-- Name --> <button
<div> @click="activeTab = 'basic'"
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span> :class="activeTab === 'basic'
</label> ? 'border-sf-accent text-sf-accent'
<input : 'border-transparent text-slate-500 hover:text-slate-700'"
v-model="form.name" >
type="text" <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
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" <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" />
:placeholder="t('provider.namePlaceholder')" </svg>
/> {{ t('provider.tabBasic') }}
</div> </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>
<!-- Atomizált címmezők (P1 CRITICAL ALIGN) --> <!-- Scrollable Body -->
<!-- Street Name --> <div class="overflow-y-auto p-6 space-y-5" style="max-height: calc(85vh - 140px);">
<div> <!-- -->
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> <!-- TAB 1: Alapadatok (Basic Data) -->
{{ t('provider.streetNameLabel') }} <!-- -->
</label> <template v-if="activeTab === 'basic'">
<input <!-- Name -->
v-model="form.address_street_name"
type="text"
maxlength="150"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.streetNamePlaceholder')"
/>
</div>
<!-- Street Type + House Number row -->
<div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> <label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.streetTypeLabel') }} {{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
</label>
<select
v-model="form.address_street_type"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="">{{ t('provider.streetTypePlaceholder') }}</option>
<option value="utca">utca</option>
<option value="út">út</option>
<option value="tér">tér</option>
<option value="körút">körút</option>
<option value="sugárút">sugárút</option>
<option value="sétány">sétány</option>
<option value="köz">köz</option>
<option value="sor">sor</option>
<option value="liget">liget</option>
<option value="park">park</option>
<option value="rakpart">rakpart</option>
<option value="dűlő">dűlő</option>
<option value="hegy">hegy</option>
<option value="lakópark">lakópark</option>
<option value="üdülőtelep">üdülőtelep</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.houseNumberLabel') }}
</label> </label>
<input <input
v-model="form.address_house_number" v-model="form.name"
type="text" 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" class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.houseNumberPlaceholder')" :placeholder="t('provider.namePlaceholder')"
/> />
</div> </div>
</div>
<!-- City + ZIP row --> <!-- Atomizált címmezők (P1 CRITICAL ALIGN) -->
<div class="grid grid-cols-2 gap-4"> <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 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"
type="text"
maxlength="150"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.streetNamePlaceholder')"
/>
</div>
<!-- Street Type + House Number row -->
<div class="grid grid-cols-2 gap-4 mb-3">
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.streetTypeLabel') }}
</label>
<select
v-model="form.address_street_type"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20 appearance-none cursor-pointer"
>
<option value="">{{ t('provider.streetTypePlaceholder') }}</option>
<option value="utca">utca</option>
<option value="út">út</option>
<option value="tér">tér</option>
<option value="körút">körút</option>
<option value="sugárút">sugárút</option>
<option value="sétány">sétány</option>
<option value="köz">köz</option>
<option value="sor">sor</option>
<option value="liget">liget</option>
<option value="park">park</option>
<option value="rakpart">rakpart</option>
<option value="dűlő">dűlő</option>
<option value="hegy">hegy</option>
<option value="lakópark">lakópark</option>
<option value="üdülőtelep">üdülőtelep</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.houseNumberLabel') }}
</label>
<input
v-model="form.address_house_number"
type="text"
maxlength="20"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.houseNumberPlaceholder')"
/>
</div>
</div>
<!-- City + ZIP row -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.zipLabel') }}
</label>
<input
v-model="form.zip"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.zipPlaceholder')"
/>
</div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.cityLabel') }}
</label>
<input
v-model="form.city"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.cityPlaceholder')"
/>
</div>
</div>
<!-- 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 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"
type="tel"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.phonePlaceholder')"
/>
</div>
<!-- Email -->
<div 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"
type="email"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.emailPlaceholder')"
/>
</div>
<!-- Website -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.websiteLabel') }}
<span class="text-xs font-normal text-slate-400 lowercase">({{ t('provider.websiteOptional') }})</span>
</label>
<input
v-model="form.website"
type="url"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.websitePlaceholder')"
/>
</div>
</div>
</template>
<!-- -->
<!-- TAB 2: Szolgáltatások (Services) -->
<!-- -->
<template v-if="activeTab === 'services'">
<!-- -->
<!-- BLOCK A: Fix Struktúra (Szint 0 + Szint 1) -->
<!-- -->
<div> <div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> <div class="flex items-center gap-2 mb-3">
{{ t('provider.zipLabel') }} <div class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent">
</label> <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<input <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" />
v-model="form.zip" </svg>
type="text" </div>
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" <span class="text-sm font-bold text-slate-700">{{ t('categories.block_a_title') }}</span>
:placeholder="t('provider.zipPlaceholder')" </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
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> </div>
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> <!-- -->
{{ t('provider.cityLabel') }} <!-- BLOCK B: Okos Szövegmező (Szint 2 + Szint 3) -->
</label> <!-- -->
<input <div class="border-t border-slate-100 pt-4">
v-model="form.city" <div class="flex items-center gap-2 mb-3">
type="text" <div class="flex h-6 w-6 items-center justify-center rounded-full bg-blue-50 text-blue-500">
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" <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
:placeholder="t('provider.cityPlaceholder')" <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)"
class="ml-0.5 text-blue-400 hover:text-red-500 cursor-pointer"
>&times;</button>
</span>
</div>
</div> </div>
</div>
<!-- Phone --> <!-- Gamification Toast -->
<div> <div
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> v-if="gamificationToast"
{{ t('provider.phoneLabel') }} class="flex items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium shadow-sm transition-all duration-300"
</label> :class="gamificationToast.type === 'success'
<input ? 'bg-green-50 text-green-700 border border-green-200'
v-model="form.phone" : 'bg-amber-50 text-amber-700 border border-amber-200'"
type="tel" >
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20" <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">
:placeholder="t('provider.phonePlaceholder')" <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>
</div> <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>
<!-- Email --> <!-- Divider -->
<div> <hr class="border-slate-200" />
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.emailLabel') }}
</label>
<input
v-model="form.email"
type="email"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.emailPlaceholder')"
/>
</div>
<!-- Website --> <!-- Info text -->
<div> <p class="text-xs text-slate-400 italic">
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider"> {{ t('provider.editInfo') }}
{{ t('provider.websiteLabel') }}
</label>
<input
v-model="form.website"
type="url"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.websitePlaceholder')"
/>
</div>
<!-- Tags (comma-separated) -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.tagsLabel') }}
</label>
<input
v-model="form.tagsInput"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.tagsPlaceholder')"
/>
<p v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="(tag, idx) in form.tags"
:key="idx"
class="inline-flex items-center gap-1 rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
<button
@click="removeTag(idx)"
class="ml-0.5 text-sf-accent/60 hover:text-red-500 cursor-pointer"
>&times;</button>
</span>
</p> </p>
</div> </template>
</div>
<!-- Divider --> <!-- Sticky Footer (Save / Cancel) -->
<hr class="border-slate-200" /> <div class="shrink-0 border-t border-slate-200 bg-white px-6 py-4 rounded-b-2xl">
<!-- Info text -->
<p class="text-xs text-slate-400 italic">
{{ t('provider.editInfo') }}
</p>
<!-- Action buttons -->
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
@click="emit('close')" @click="emit('close')"
@@ -241,13 +459,28 @@
* address_street_name, address_street_type, address_house_number * address_street_name, address_street_type, address_house_number
* - A payload pontosan az új, atomizált kulcsokkal megy a backend felé. * - 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 close — modal bezárása
* @emits saved — sikeres mentés után, payload: { id: number } * @emits saved — sikeres mentés után, payload: { id: number }
* *
* @see backend/app/api/v1/endpoints/providers.py — PUT /providers/{id} * @see backend/app/api/v1/endpoints/providers.py — PUT /providers/{id}
* @see frontend/src/components/provider/ProviderDetailModal.vue — hívó * @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 { useI18n } from 'vue-i18n'
import api from '@/api/axios' import api from '@/api/axios'
@@ -274,6 +507,34 @@ interface ProviderSearchResult {
rating: number | null 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 ── // ── Props ──
const props = defineProps<{ const props = defineProps<{
isOpen: boolean isOpen: boolean
@@ -286,9 +547,15 @@ const emit = defineEmits<{
(e: 'saved', payload: { id: number }): void (e: 'saved', payload: { id: number }): void
}>() }>()
// ── Tab State ──
const activeTab = ref<'basic' | 'services'>('basic')
// ── Form State ── // ── Form State ──
const isSaving = ref(false) const isSaving = ref(false)
// ── Gamification Toast ──
const gamificationToast = ref<{ type: 'success' | 'penalty'; message: string } | null>(null)
interface FormState { interface FormState {
name: string name: string
city: string city: string
@@ -296,11 +563,10 @@ interface FormState {
address_street_name: string address_street_name: string
address_street_type: string address_street_type: string
address_house_number: string address_house_number: string
plus_code: string
phone: string phone: string
email: string email: string
website: string website: string
tagsInput: string
tags: string[]
} }
const form = reactive<FormState>({ const form = reactive<FormState>({
@@ -310,11 +576,55 @@ const form = reactive<FormState>({
address_street_name: '', address_street_name: '',
address_street_type: '', address_street_type: '',
address_house_number: '', address_house_number: '',
plus_code: '',
phone: '', phone: '',
email: '', email: '',
website: '', 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 ── // ── Watch provider changes → populate form ──
@@ -332,33 +642,125 @@ watch(
form.phone = provider.contact_phone || '' form.phone = provider.contact_phone || ''
form.email = provider.contact_email || '' form.email = provider.contact_email || ''
form.website = provider.website || '' form.website = provider.website || ''
form.tagsInput = ''
form.tags = provider.tags && provider.tags.length > 0
? [...provider.tags]
: provider.specialization
? [...provider.specialization]
: []
} }
}, },
{ immediate: true } { immediate: true }
) )
// ── Tag management ── // ── Load Category Tree from API ──
watch( async function loadCategoryTree() {
() => form.tagsInput, categoryTreeLoading.value = true
(val) => { try {
if (val.endsWith(',') || val.endsWith(';')) { const response = await api.get('providers/categories/tree')
const newTag = val.slice(0, -1).trim() categoryTree.value = response.data || []
if (newTag && !form.tags.includes(newTag)) { } catch (err) {
form.tags.push(newTag) console.error('[ProviderEditModal] Failed to load category tree:', err)
} } finally {
form.tagsInput = '' 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) { function removeTag(index: number) {
form.tags.splice(index, 1) selectedTags.value.splice(index, 1)
} }
// ── Save handler (async — calls backend PUT /providers/{id}) ── // ── Save handler (async — calls backend PUT /providers/{id}) ──
@@ -366,27 +768,58 @@ async function handleSave() {
if (!form.name.trim() || !props.provider) return if (!form.name.trim() || !props.provider) return
isSaving.value = true isSaving.value = true
gamificationToast.value = null
try { 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 // P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
const body = { const body: Record<string, any> = {
name: form.name.trim(), name: form.name.trim(),
city: form.city.trim() || null, city: form.city.trim() || null,
address_zip: form.zip.trim() || null, address_zip: form.zip.trim() || null,
address_street_name: form.address_street_name.trim() || null, address_street_name: form.address_street_name.trim() || null,
address_street_type: form.address_street_type.trim() || null, address_street_type: form.address_street_type.trim() || null,
address_house_number: form.address_house_number.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_phone: form.phone.trim() || null,
contact_email: form.email.trim() || null, contact_email: form.email.trim() || null,
website: form.website.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 success so parent can refresh search results
emit('saved', { id: props.provider.id }) emit('saved', { id: props.provider.id })
emit('close')
} catch (err: any) { } catch (err: any) {
console.error('[ProviderEditModal] Save error:', err) console.error('[ProviderEditModal] Save error:', err)
// Keep modal open on error so user can retry // Keep modal open on error so user can retry

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ export default {
finance: 'Finance & Costs', finance: 'Finance & Costs',
diagnostic: 'Live Diagnostics', diagnostic: 'Live Diagnostics',
addVehicle: '+ Add New Vehicle', addVehicle: '+ Add New Vehicle',
service: 'Service',
}, },
header: { header: {
welcome: 'Welcome', welcome: 'Welcome',
@@ -1242,6 +1243,11 @@ export default {
filterAutoMento: '🚛 Tow Truck', filterAutoMento: '🚛 Tow Truck',
filterBenzinkut: '⛽ Gas Station', filterBenzinkut: '⛽ Gas Station',
filterAutoKozmetika: '✨ Car Detailing', 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 // SOS Tab
sosTabTitle: 'SOS Rescue', sosTabTitle: 'SOS Rescue',
sosPlaceholder: '🚧 This feature is under development. You will be able to request emergency roadside assistance here soon!', 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?', duplicateWarningShort: 'Similar provider found nearby: {name}. Are you sure you want to add a new one?',
// Edit Modal // Edit Modal
editTitle: 'Edit Provider Data', editTitle: 'Edit Provider Data',
tabBasic: 'Basic Data',
tabServices: 'Services',
addressSection: 'Address Details',
contactSection: 'Contact Details',
streetNameLabel: 'Street Name', streetNameLabel: 'Street Name',
streetNameOptional: 'optional',
streetNamePlaceholder: 'e.g. Egressy', streetNamePlaceholder: 'e.g. Egressy',
streetTypeLabel: 'Street Type', streetTypeLabel: 'Street Type',
streetTypePlaceholder: 'Select type...', streetTypePlaceholder: 'Select type...',
houseNumberLabel: 'House Number', houseNumberLabel: 'House Number',
houseNumberPlaceholder: 'e.g. 4', houseNumberPlaceholder: 'e.g. 4',
phoneLabel: 'Phone', phoneLabel: 'Phone',
phoneOptional: 'optional',
phonePlaceholder: 'e.g. +36 20 123 4567', phonePlaceholder: 'e.g. +36 20 123 4567',
emailLabel: 'Email', emailLabel: 'Email',
emailOptional: 'optional',
emailPlaceholder: 'e.g. info@provider.hu', emailPlaceholder: 'e.g. info@provider.hu',
websiteLabel: 'Website', websiteLabel: 'Website',
websiteOptional: 'optional',
websitePlaceholder: 'e.g. https://provider.hu', websitePlaceholder: 'e.g. https://provider.hu',
tagsLabel: 'Service Tags', tagsLabel: 'Service Tags',
tagsPlaceholder: 'Add tags (comma-separated)', tagsPlaceholder: 'Add tags (comma-separated)',
level0Label: 'Vehicle Type',
level1Label: 'Industry / Main Group',
editInfo: 'Your edits will be reviewed and may earn gamification points!', 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: { admin: {

View File

@@ -30,6 +30,7 @@ export default {
finance: 'Pénzügyek és Költségek', finance: 'Pénzügyek és Költségek',
diagnostic: 'Élő Diagnosztika', diagnostic: 'Élő Diagnosztika',
addVehicle: '+ Új Jármű Hozzáadása', addVehicle: '+ Új Jármű Hozzáadása',
service: 'Szervíz',
}, },
header: { header: {
welcome: 'Üdvözlünk', welcome: 'Üdvözlünk',
@@ -1242,6 +1243,11 @@ export default {
filterAutoMento: '🚛 Autómentő', filterAutoMento: '🚛 Autómentő',
filterBenzinkut: '⛽ Benzinkút', filterBenzinkut: '⛽ Benzinkút',
filterAutoKozmetika: '✨ Autókozmetika', 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 // SOS Tab
sosTabTitle: 'SOS Mentés', sosTabTitle: 'SOS Mentés',
sosPlaceholder: '🚧 Ez a funkció fejlesztés alatt áll. Hamarosan kérhető lesz itt az azonnali úti segítség!', 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?', duplicateWarningShort: 'Hasonló szolgáltató található a környéken: {name}. Biztosan újat szeretnél rögzíteni?',
// Edit Modal // Edit Modal
editTitle: 'Szolgáltató adatok szerkesztése', editTitle: 'Szolgáltató adatok szerkesztése',
tabBasic: 'Alapadatok',
tabServices: 'Szolgáltatások',
addressSection: 'Cím adatok',
contactSection: 'Elérhetőségek',
streetNameLabel: 'Utca neve', streetNameLabel: 'Utca neve',
streetNameOptional: 'opcionális',
streetNamePlaceholder: 'Pl. Egressy', streetNamePlaceholder: 'Pl. Egressy',
streetTypeLabel: 'Közterület jellege', streetTypeLabel: 'Közterület jellege',
streetTypePlaceholder: 'Válassz típust...', streetTypePlaceholder: 'Válassz típust...',
houseNumberLabel: 'Házszám', houseNumberLabel: 'Házszám',
houseNumberPlaceholder: 'Pl. 4', houseNumberPlaceholder: 'Pl. 4',
phoneLabel: 'Telefonszám', phoneLabel: 'Telefonszám',
phoneOptional: 'opcionális',
phonePlaceholder: 'Pl. +36 20 123 4567', phonePlaceholder: 'Pl. +36 20 123 4567',
emailLabel: 'Email', emailLabel: 'Email',
emailOptional: 'opcionális',
emailPlaceholder: 'Pl. info@szolgaltato.hu', emailPlaceholder: 'Pl. info@szolgaltato.hu',
websiteLabel: 'Weboldal', websiteLabel: 'Weboldal',
websiteOptional: 'opcionális',
websitePlaceholder: 'Pl. https://szolgaltato.hu', websitePlaceholder: 'Pl. https://szolgaltato.hu',
tagsLabel: 'Szolgáltatás Címkék', tagsLabel: 'Szolgáltatás Címkék',
tagsPlaceholder: 'Címkék hozzáadása (vesszővel elválasztva)', 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!', 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: { admin: {

View File

@@ -66,7 +66,7 @@
{{ t('menu.finance') }} {{ t('menu.finance') }}
</button> </button>
<!-- Szerviz kereső --> <!-- Szerviz kereső (belső) -->
<button <button
@click="navigateToCard('service')" @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" 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> </svg>
{{ t('menu.features') }} {{ t('menu.features') }}
</button> </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>
</div> </div>
</Transition> </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 ────────────────────────────── // ── Close hamburger on outside click ──────────────────────────────
function handleOutsideClick(e: MouseEvent) { function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement const target = e.target as HTMLElement

View File

@@ -21,4 +21,10 @@ export interface OrganizationItem {
* Set via PATCH /organizations/{org_id}/visual-settings. * Set via PATCH /organizations/{org_id}/visual-settings.
*/ */
visual_settings?: Record<string, any> | null 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
} }

View File

@@ -164,37 +164,38 @@
</div> </div>
<!-- <!--
Card 3: 🔧 Service Finder (3-Way Indítópult) Card 3: 🔧 Service Finder (3-Way Indítópult)
--> -->
<div <div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl" class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
> @click="openExternalServiceFinder"
<!-- Top header bar --> >
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4"> <!-- Top header bar -->
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span> <div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
</div> <span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
<!-- Content: 3 simple buttons stacked vertically --> </div>
<div class="p-4 flex-1 flex flex-col justify-center gap-2"> <!-- Content: 3 simple buttons stacked vertically -->
<button <div class="p-4 flex-1 flex flex-col justify-center gap-2">
@click="router.push('/dashboard/service-finder?mode=planned')" <button
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" @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> 🔍 {{ t('serviceFinder.plannedMaintenance') }}
<button </button>
@click="router.push('/dashboard/service-finder?mode=sos')" <button
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" @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> 🚨 SOS {{ t('serviceFinder.sosTitle') }}
<button </button>
@click="isSfQuickAddOpen = true" <button
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" @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') }} >
</button> {{ t('provider.quickAddTitle') }}
</div> </button>
</div> </div>
</div>
<!-- <!--
Card 4: 🏆 Gamification (Játékosítás) Card 4: 🏆 Gamification (Játékosítás)
@@ -440,6 +441,14 @@ const openCard = (cardId: string, targetId: string | null = null) => {
const isSearchModalOpen = ref(false) const isSearchModalOpen = ref(false)
const isSfQuickAddOpen = 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. * Called when ProviderQuickAddModal saves a new provider.
*/ */

View File

@@ -66,6 +66,90 @@
</button> </button>
</div> </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 --> <!-- Quick filter chips -->
<div class="mt-6 flex flex-wrap items-center justify-center gap-2"> <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> <span class="text-xs font-medium text-slate-400">{{ t('serviceFinder.quickFilters') }}</span>
@@ -240,6 +324,32 @@
</span> </span>
</div> </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 --> <!-- Specialization chips / tags -->
<div <div
v-if="provider.specialization && provider.specialization.length > 0" v-if="provider.specialization && provider.specialization.length > 0"
@@ -260,10 +370,36 @@
</span> </span>
</div> </div>
<!-- Category badge ( 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 ( szolgáltatás - csak ha van kategória) -->
<div class="mb-3"> <div class="mb-3">
<span <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" 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 }} {{ provider.category }}
@@ -397,17 +533,35 @@ import api from '@/api/axios'
import ProviderDetailModal from '@/components/provider/ProviderDetailModal.vue' import ProviderDetailModal from '@/components/provider/ProviderDetailModal.vue'
import ProviderEditModal from '@/components/provider/ProviderEditModal.vue' import ProviderEditModal from '@/components/provider/ProviderEditModal.vue'
const { t } = useI18n() const { t, locale } = useI18n()
// ── Types matching backend ProviderSearchResult ── // ── Types matching backend ProviderSearchResult ──
// @see backend/app/schemas/provider.py:21-33 // @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 { interface ProviderSearchResult {
id: number id: number
name: string name: string
category: string | null category: string | null
specialization: string[] specialization: string[]
categories: CategoryInfo[]
city: string | null city: string | null
address: 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' source: 'verified_org' | 'staged_data' | 'crowd_added'
is_verified: boolean is_verified: boolean
rating: number | null rating: number | null
@@ -430,12 +584,54 @@ const quickFilters = computed(() => [
{ key: 'autókozmetika', label: t('serviceFinder.filterAutoKozmetika') }, { 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 router = useRouter()
const route = useRoute() const route = useRoute()
// ── Active Tab (from route.query.mode or default 'planned') ── // ── Active Tab (from route.query.mode or default 'planned') ──
const activeTab = ref<'planned' | 'sos'>('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 ── // ── Reactive state ──
const searchQuery = ref('') const searchQuery = ref('')
const searchCity = ref('') const searchCity = ref('')
@@ -464,21 +660,28 @@ onMounted(() => {
} }
}) })
// ── Search logic (passes both q and city) ── // ── 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() { async function handleSearch() {
isSearching.value = true isSearching.value = true
hasSearched.value = true hasSearched.value = true
currentPage.value = 1 currentPage.value = 1
try { try {
const offset = 0 const params = buildSearchParams(0)
const params: Record<string, any> = {
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
const res = await api.get<ProviderSearchResponse>('providers/search', { params }) const res = await api.get<ProviderSearchResponse>('providers/search', { params })
results.value = res.data.results || [] results.value = res.data.results || []
totalResults.value = res.data.total || 0 totalResults.value = res.data.total || 0
@@ -506,13 +709,7 @@ async function goToPage(page: number) {
try { try {
const offset = (page - 1) * perPage.value const offset = (page - 1) * perPage.value
const params: Record<string, any> = { const params = buildSearchParams(offset)
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
const res = await api.get<ProviderSearchResponse>('providers/search', { params }) const res = await api.get<ProviderSearchResponse>('providers/search', { params })
results.value = res.data.results || [] results.value = res.data.results || []
totalResults.value = res.data.total || 0 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 ── // ── Detail Modal ──
function openDetail(provider: ProviderSearchResult) { function openDetail(provider: ProviderSearchResult) {
selectedProvider.value = provider selectedProvider.value = provider

View 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

View 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

View File

@@ -1,123 +1,90 @@
# 🔧 Logic Spec: Provider Update & Search Fix Csomag # 🔧 Fix Plan: Provider Update 500 Error (folder_slug Truncation)
## 🎯 Cél ## 🎯 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) ### Hibajelenség
- **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. `PUT /api/v1/providers/4859``500 Internal Server Error`
- **Javítás**: `docker compose restart sf_api` (végrehajtva) ```
- **Verifikáció**: PUT /providers/58 → 200 OK value too long for type character varying(12)
```
### 2. GET /api/v1/providers/search → 500 Internal Server Error (❌ JAVÍTANDÓ) ### Kiváltó ok
- **Hiba**: `AttributeError: Neither 'BinaryExpression' object nor 'Comparator' object has an attribute 'astext'` A provider ID=4859 a `marketplace.service_staging` táblában létezik, de **nincs** még `fleet.organizations` rekordja.
- **Hibás kód**: [`provider_service.py:207`](../backend/app/services/provider_service.py:207)
```python
(Organization.external_integration_config["source"].astext == "crowdsourced", literal("crowd_added")),
```
- **Root Cause**: Az [`external_integration_config`](../backend/app/models/marketplace/organization.py:109) `JSON` típusú oszlop. A `["source"]` subscript `BinaryExpression`-t ad vissza, amelyen NINCS `.astext`.
- **Javítás**: `cast` használata:
```python
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
```
### 3. Adat-healing: Régi címformátumú Organization rekordok (❌ JAVÍTANDÓ) 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:
- **Probléma**: Az Organization id=58 (`Autónyíri Kft.`) adatai a régi formátumban:
- `street_name = "Egressy u. 4."` (nem atomizált)
- `address_street_name = NULL`, `address_street_type = NULL`, `address_house_number = NULL`
- `zip = NULL`
- **Következmény**: A frontend DetailModal üres címet mutat.
- **Javítás**: Adat-healing script a `street_name` mezőből atomizált komponensek kinyerésére.
### 4. Multi-source Update Probléma (❌ JAVÍTANDÓ)
- **Probléma**: Az [`update_provider`](../backend/app/services/provider_service.py:477) CSAK `Organization`-ben keres. A search UNION-nal dolgozza fel a szolgáltatókat 3 forrásból (Organization, ServiceStaging, ServiceProvider).
- **Javítás**: Multi-source update logika.
### 5. Szerver Restart Workflow Hiánya (❌ JAVÍTANDÓ)
- **Probléma**: A [`pre_start.sh`](../backend/app/scripts/pre_start.sh) `--reload` nélkül indul.
- **Javítás**: `--reload` flag fejlesztői módban.
---
## 🗺️ Érintett Fájlok
| Fájl | Változtatás | Prioritás |
|------|-------------|-----------|
| [`provider_service.py:207`](../backend/app/services/provider_service.py:207) | `.astext` → `cast()` | **KRITIKUS** |
| [`provider_service.py:477-562`](../backend/app/services/provider_service.py:477) | Multi-source update | **MAGAS** |
| Új: `heal_provider_addresses.py` | Adat-healing script | **MAGAS** |
| [`pre_start.sh`](../backend/app/scripts/pre_start.sh) | `--reload` dev módban | **ALACSONY** |
---
## 🛠️ Részletes Javítási Terv
### 1. `.astext` → `cast()` javítás
**Fájl**: [`provider_service.py`](../backend/app/services/provider_service.py:207)
**Jelenlegi:**
```python ```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 ```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. Model fix: [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:75)
1. `db.get(Organization, provider_id)` → meglévő logika **Változtatás:** `folder_slug` oszlop méretének növelése `String(12)``String(24)`
2. `db.get(ServiceStaging, provider_id)` → migrálás Organization-be
3. `db.get(ServiceProvider, provider_id)` → migrálás Organization-be
4. Ha egyikben sem → ValueError
### 3. Adat-healing Script 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` **Staging ág (line 786):**
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*(.*)$'` ```python
3. Frissíteni az atomizált mezőket # EZT:
4. Naplózás 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 ```bash
docker compose exec sf_api python3 -c " docker exec sf_api python -m app.scripts.sync_engine
import httpx, asyncio
async def t():
async with httpx.AsyncClient(base_url='http://localhost:8000') as c:
r = await c.post('/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'})
t = r.json()['access_token']
r2 = await c.get('/api/v1/providers/search', params={'q': 'Dunakeszi', 'limit': 5}, headers={'Authorization': f'Bearer {t}'})
print(f'Search: {r2.status_code}')
print(r2.text[:500])
asyncio.run(t())
"
``` ```
### Teszt 2: PUT működés ## ✅ Elfogadási kritériumok
```bash 1. `PUT /api/v1/providers/4859` sikeresen lefut (200 OK)
docker compose exec sf_api python3 -c " 2. A migrált Organization `folder_slug` pontosan 12 karakter hosszú
import httpx, asyncio 3. Meglévő provider-ek (`quick_add`-ból) továbbra is működnek
async def t(): 4. A `folder_slug` `unique` megszorítás nem sérül
async with httpx.AsyncClient(base_url='http://localhost:8000') as c: 5. A logokban nincs `StringDataRightTruncationError`
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())
"
```

View 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())