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
- **Clear/X gomb:** A keresőmezőbe egy `X` gomb került, ami egy kattintással törli a keresési feltételt
## 2026-06-16 - Fix 404 API Router & Redesign Dashboard Launcher Card
## 2026-06-17 - P0 UI Card Cleanup & Custom Tag 500 Fix
### 🎯 Cél
A Dashboard Service Finder kártya (Card 3) átalakítása elegáns, kétgombos indítópulttá (Launcher), valamint a providers API 404 hiba kivizsgálása és javítása.
Három kritikus hiba javítása: (1) "Nincs kategória" fantom badge eltüntetése a provider kártyákról, (2) Telefon/Weboldal ikonok megjelenítése a kártyákon, (3) 500-as hiba javítása custom tag mentéskor.
### 🔧 Változtatások
**1. BACKEND AUDIT - [`api.py`](backend/app/api/v1/api.py:38):**
- Ellenőrizve: `providers` router regisztrálva van (`include_router(providers.router, prefix="/providers")`)
- Ellenőrizve: providers végpontokon NINCS trailing slash (`/categories`, `/search`, `/quick-add`)
- **Route-ok élőben is ellenőrizve:** `GET /api/v1/providers/categories`, `GET /api/v1/providers/search`, `POST /api/v1/providers/quick-add` mind aktívak
**1. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:367):**
- **"Nincs kategória" javítás:** A `v-if="provider.category"` feltétel helyett `v-if="provider.categories && provider.categories.length > 0"` lett beállítva. Ha a `categories` array üres, de a régi `category` string létezik, azt használja. Csak ha egyik sincs, akkor jelenik meg a "Nincs kategória" felirat.
- **Telefon/Web ikonok:** Az address blokk után egy új contact info sor került be, amely a `contact_phone` mezőhöz telefon ikont + linket (`tel:`), a `website` mezőhöz weboldal ikont + linket (`target="_blank"`) jelenít meg.
- **TypeScript interface bővítés:** A `ProviderSearchResult` interfész kibővítve a hiányzó mezőkkel: `contact_phone`, `contact_email`, `website`, `tags`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`.
**2. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:223):**
- `fetchCategories()` már rendelkezik try/catch blokkal, 11 hardcoded fallback kategóriával
- Nincs szükség módosításra
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:661):**
- **`_slugify()` függvény:** Új segédfüggvény, amely biztonságos slug-ot generál magyar ékezetes karakterekből (á→a, é→e, í→i, ó→o, ö→o, ő→o, ú→u, ü→u, ű→u), majd nem-alfanumerikus karaktereket alulvonásra cserél.
- **`_create_new_tags()` javítás:**
- `try-except` blokkba csomagolva a teljes beszúrási logika
- `logger.error(..., exc_info=True)` hibanaplózás hozzáadva
- `parent_id=None` és `path=None` explicit beállítva
- A kulcs generálás most a `_slugify()` függvényt használja
- Egyetlen hibás címke nem szakítja meg a teljes folyamatot (`continue`)
**3. FRONTEND - [`DashboardView.vue`](frontend/src/views/DashboardView.vue:187):**
- **Card 3 teljes átalakítása:** A 3-lépéses kontextuális kereső űrlap (input, 2 dropdown, keresés gomb) ELTÁVOLÍTVA
- **Launcher dizájn:** Két nagy gomb, szépen formázva:
- `🔍 Szervizek Keresése` — nagy, zöld gradient gomb, `isSearchModalOpen` modalt nyit
- ` Új Szolgáltató Rögzítése` — szekunder, dashed border gomb, `isSfQuickAddOpen` modalt nyit (meglévő ProviderQuickAddModal)
- **Search Modal (Placeholder):** Teleportált modál, benne: `🔍` ikon, "Részletes kereső térképpel hamarosan..." szöveg, fejlesztés alatt státusz
- Régi `sfSearchLocation`, `sfSearchCategory`, `sfSearchVehicleId`, `sfCategories`, `fetchSfCategories()`, `onSfSearch()` eltávolítva
### ✅ Eredmény
- "Nincs kategória" fantom badge megszűnt: a feltétel most a `categories` tömböt ellenőrzi
- Telefon és weboldal linkek megjelennek a kártyákon (ha vannak)
- Custom tag mentés 500 helyett try-except blokkban fut, hibanaplózással
- Python szintaxis ellenőrzés: OK
## 2026-06-17 - P0 Bugfix: Provider Data Persistence & Schema Alignment
## 2026-06-17 - P0 UI Overhaul: Plus Code, Universal Categories, Detail Modal
### 🎯 Cél
Kritikus adatperzisztencia hiba javítása a Service Finder Provider rendszerében. A frontend edit modal nem hívott backend API-t, így a szerkesztett adatok elvesztek. Emellett a contact mezők (phone, email, website, tags) nem kerültek perzisztálásra a quick-add során, és a search nem adta vissza ezeket.
Három területet fed le: (1) Plus Code integráció a backendben és adatbázisban, (2) Univerzális kategóriák megjelenítése a ProviderEditModal és QuickAdd modalokban, (3) ProviderDetailModal gazdag újraírása kategória chipekkel, kontakt grid-del és navigációs gombbal.
### 🔧 Változtatások
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py:1) (NEW):**
- `ProviderQuickAddIn`: `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva
- `ProviderSearchResult`: `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva
- `ProviderUpdateIn` (NEW): name, city, address_zip, street, contact_phone, contact_email, website, tags
- `ProviderUpdateResponse` (NEW): id, name, status, message
**1. BACKEND - Plus Code mező:**
- [`Organization`](backend/app/models/marketplace/organization.py:86) model: `plus_code: Mapped[Optional[str]] = mapped_column(String(20))`
- [`ProviderSearchResult`](backend/app/schemas/provider.py:115): `plus_code: Optional[str] = None`
- [`ProviderQuickAddIn`](backend/app/schemas/provider.py:148): `plus_code: Optional[str] = Field(None, max_length=20)`
- [`ProviderUpdateIn`](backend/app/schemas/provider.py:180): `plus_code: Optional[str] = Field(None, max_length=20)`
- [`provider_service.py`](backend/app/services/provider_service.py:262): plus_code hozzáadva a search SELECT-hez, quick_add Organization creation-höz, és update null-safety checkhez
- `sync_engine` sikeresen lefuttatva, a `plus_code` oszlop létrejött
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:486):**
- `quick_add_provider()`: contact mezők mentése ServiceProfile-ba
- `search_providers()`: LEFT JOIN ServiceProfile, contact mezők visszaadása
- `update_provider()` (NEW): Organization + ServiceProfile atomi frissítése
**2. FRONTEND - Univerzális kategóriák:**
- [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue): `allLevel1Categories` computed property, amely a checked Level 0 szülők Level 1 gyermekeit gyűjti össze deduplikálva
- [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue): Ugyanaz a `allLevel1Categories` logika
- Plus Code input mező hozzáadva mindkét modal "Alapadatok" tabjához
**3. BACKEND - [`providers.py`](backend/app/api/v1/endpoints/providers.py:134):**
- `PUT /providers/{id}` végpont hozzáadva (authentikált, hibakezeléssel)
**3. FRONTEND - Detail Modal Rework:**
- [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue): Teljes újraírás
- Kategória chipek (szintenként eltérő színnel: Level0=lila, Level1=kék, Level2=teal, Level3=szürke)
- Kontakt grid: Telefon (tel: link), Email (mailto: link), Weboldal (_blank, external icon)
- Helyszín szekció: cím + plus_code + "Navigáció" gomb (Google Maps URL)
- Hiányzó adatok esetén i18n fallback szövegek
**4. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue:191):**
- **CRITICAL FIX**: `handleSave()` most `api.put()`-et hív, nem csak eventet emitál
- `@save``@saved` (past tense, API call után)
- `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők támogatása
- `source` kivéve a payload-ból (backend-only)
**4. i18n:**
- [`en.ts`](frontend/src/i18n/en.ts): plus_code és detail modal kulcsok
- [`hu.ts`](frontend/src/i18n/hu.ts): plus_code és detail modal magyar fordítások
**5. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:253):**
- Telefon, email, weboldal, címkék mezők hozzáadva a formhoz
- Tag management (vessző/pontosvessző parsing, remove gomb)
- Payload bővítése contact mezőkkel
### ✅ Eredmény
- Backend Python import ellenőrzés: OK (provider.py, provider_service.py, providers.py endpoints)
- Frontend Vite build: OK (87 modules, 6.07s)
- Adatbázis sync_engine: OK (plus_code oszlop létrejött)
**6. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:375):**
- `@save``@saved` event binding
- `handleEditSaved()`: mentés után automatikus keresés újrafuttatás
## 2026-06-17 - P0 Triage Fix: Data Persistence & UNION ALL Column Mismatch
### 🎯 Cél
Két kritikus hiba javítása a Provider Search/Update folyamatban:
1. **Data Persistence Black Hole**: Organization ID 4859 (Shell Dunakeszi) `plus_code` és `contact_phone` mezői nem perzisztáltak mentés után
2. **UNION ALL 500 error**: `CompoundSelect must have identical numbers of columns` hiba a search végponton
### 🔧 Változtatások
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:445):**
- `plus_code` hozzáadva a `ProviderSearchResult` konstruktorhoz (`getattr(row, "plus_code", None)`) - a mező SELECT-ben volt, de a Python objektumba nem került át
- `plus_code` oszlop (`literal(None).label("plus_code")`) hozzáadva a `UNION ALL` második (`staging_stmt`) és harmadik (`crowd_stmt`) ágához, hogy a 15 oszlopszám konzisztens legyen
- **P0 CRITICAL BUGFIX**: Ha nincs `ServiceProfile` rekord az Organization-höz, a `update_provider` most létrehozza azt (fingerprint + location + contact adatokkal), ahelyett hogy csak logolná a hiányt és eldobná a contact/category adatokat
- `ServiceStatus` import hozzáadva a provider_service.py-hoz
**2. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue:617):**
- `allLevel1Categories` szétbontva `vehicleLevel1Categories`-re (Level 0 checkboxoktól függő) és `universalLevel1Categories`-re (mindig látható, `categoryTree.value.filter(c => c.level === 1)`)
- Új UI blokk "Iparágak és Univerzális Szolgáltatások" címmel, amber színű dashed border-rel
### ✅ Verifikáció
- `sync_engine` lefuttatva: **1061 elem OK, 0 javítás, 0 shadow data** - rendszer tökéletesen szinkronban
- `plus_code=8FVX+3W`, `contact_phone=+36 20 123 4567`, `contact_email=shell.dunakeszi@example.com` sikeresen mentve és visszaolvasva ID 4859-hez
- API `/api/v1/providers/search` hibátlanul fut (nincs 500-as UNION ALL hiba)
- Adatbázis szinten ellenőrizve: `fleet.organizations.plus_code` és `marketplace.service_profiles.contact_phone/contact_email` helyesen perzisztálva
## 2026-06-17 - P1 Critical Align: Atomizált címmezők a Provider sémákban
## 2026-06-17 - P0 Ownership & Access Control Fix: OrganizationMember + owner_id
### 🎯 Cél
A `street: Optional[str]` mező eltávolítása és helyette atomizált címmezők (`address_street_name`, `address_street_type`, `address_house_number`) bevezetése a Pydantic sémákban, backend service-ben és frontend űrlapokon. A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek.
Két P0 kritikus hiba javítása a Provider Quick-Add és Update folyamatban:
1. **Ownerless Providers**: A `quick_add_provider` `owner_id=None`-t használt, így az újonnan létrehozott szolgáltatóknak nem volt tulajdonosa
2. **Missing OrganizationMember**: A 6-lépéses folyamat 5. lépésnél megállt - nem jött létre `OrganizationMember` rekord, így senki sem volt kapcsolatban a céggel
3. **No Access Control**: A `update_provider` végpont bárki által hívható volt - nem ellenőrizte, hogy a hívó user jogosult-e szerkeszteni az adott szervezetet
### 🔧 Változtatások
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py):**
- `street` mező ELTÁVOLÍTVA a `ProviderQuickAddIn` és `ProviderUpdateIn` sémákból
- `address_street_name`, `address_street_type`, `address_house_number` mezők HOZZÁADVA mindkét sémához
- `ProviderSearchResult` bővítve az atomizált címmezőkkel
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:468):**
- **`owner_id=None``owner_id=user_id`** (531. sor): A létrehozott Organization most a hitelesített user-hez tartozik
- **6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA** (585-600. sor): Teljesen új blokk a gamifikáció előtt:
```python
member = OrganizationMember(
organization_id=org.id,
user_id=user_id,
role=OrgUserRole.OWNER,
is_permanent=True,
is_verified=True,
)
db.add(member)
await db.flush()
```
- **🔐 ACCESS CONTROL ELLENŐRZÉS** (841-863. sor): A `update_provider` függvény elején új blokk, amely ellenőrzi, hogy a user rendelkezik-e OWNER vagy ADMIN szerepkörrel az adott Organization-ben. Ha nem, `PermissionError`-t dob.
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py):**
- `quick_add_provider()`: `data.street``data.address_street_name`, `data.address_street_type`, `data.address_house_number` az Organization és Branch táblákban
- `update_provider()`: ugyanez az atomizált címkezelés
- `search_providers()`: az org SELECT most már tartalmazza az `address_street_name`, `address_street_type`, `address_house_number` mezőket
- A Branch létrehozásánál a `street_name`, `street_type`, `house_number` mezők külön-külön töltődnek
**3. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue):**
- A régi egyesített "Cím (Utca, házszám)" mező helyett 3 külön mező: Utca neve (text), Közterület jellege (select/dropdown 15 opcióval), Házszám (text)
- Payload az új atomizált kulcsokkal megy a backend felé
**4. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue):**
- Ugyanaz a 3 mezős szétbontás, a form populate az atomizált mezőkből történik
- Payload atomizált kulcsokkal
**5. FRONTEND - [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue):**
- Az intelligens címösszefűzés (`formattedAddress` computed) a `provider.address_street_name + ' ' + provider.address_street_type + ' ' + provider.address_house_number` alapján történik
- `hasAddress` computed ellenőrzi az atomizált mezők meglétét
**2. BACKEND - [`providers.py`](backend/app/api/v1/endpoints/providers.py:311):**
- `PermissionError` → `HTTPException(403)` kezelés hozzáadva a `update_service_provider` végponthoz
### ✅ Verifikáció
- `sync_engine` lefuttatva: **1061 elem OK, 0 javítás, 0 shadow data** - rendszer tökéletesen szinkronban
- Python syntax check: minden fájl szintaktikailag helyes
- `owner_id=2` (admin user) sikeresen beállítva a létrehozott Organization-ben
- `OrganizationMember(user_id=2, role=OWNER)` rekord sikeresen létrejött
- Admin user sikeresen frissítheti a provider adatokat (200 OK)
- Érvénytelen token esetén 401 Unauthorized
- Docker konténer újraépítve: `docker compose up -d --build sf_api`
- Tesztadatok (org 59, 60, 61) tisztítva
## 2026-06-17 - Provider Update & Search Fix Csomag (#264)
## 2026-06-17 - P0 Critical Bugfix: Quick-Add Provider 422 category_id null
### 🎯 Cél
Két kritikus hiba javítása a provider endpointokban:
1. **PUT /providers/{id} → 404**: A konténer nem volt újraindítva a providers modul kódváltoztatásai után
2. **GET /providers/search → 500**: `.astext` hiba JSONB subscripten + UNION oszlopszám mismatch
3. **Multi-source update**: Az `update_provider` csak Organization-ben keresett, de a search 3 forrást használ
A `POST /api/v1/providers/quick-add` endpoint 422-es hibát dobott, amikor a frontend `category_id: null`-t küldött. A 4-level kategória rendszer bevezetésével a frontend már a `category_ids` tömböt használja, de a backend `ProviderQuickAddIn` séma továbbra is kötelező `int`-ként várta a `category_id`-t.
### 🔧 Változtatások
**1. BACKEND - [`provider_service.py:207`](backend/app/services/provider_service.py:207):**
- `.astext``cast()` javítás: `Organization.external_integration_config["source"].astext``cast(Organization.external_integration_config["source"], String)`
- **Root cause**: JSON oszlop subscript-je `BinaryExpression`-t ad vissza, amelyen nincs `.astext`
**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py:135):**
- `ProviderQuickAddIn.category_id` típusa `int` → `Optional[int] = None` (opcionális)
- Új mezők: `category_ids: Optional[List[int]]` és `new_tags: Optional[List[str]]` a 4-level kategória rendszer támogatásához
**2. BACKEND - [`provider_service.py:232-274`](backend/app/services/provider_service.py:232):**
- UNION oszlopszám mismatch javítva: staging és crowd SELECT-ekhez hozzáadva a hiányzó `contact_phone`, `contact_email`, `website`, `specialization_tags` mezők (14 oszlopra egységesítve)
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:468):**
- `quick_add_provider()` most már kezeli a `category_id=None` esetet (kihagyja az elsődleges kategória lekérdezést)
- A `category_ids` és `new_tags` mezők feldolgozása: ServiceExpertise kapcsolatok létrehozása a 4-level kategória rendszerből
- Deduplikáció: az összes kategória ID egyesítve, duplikátumok eltávolítva
**3. BACKEND - [`provider_service.py:477-622`](backend/app/services/provider_service.py:477):**
- Multi-source update logika: `update_provider()` most már mindhárom forrást támogatja:
1. `fleet.organizations` (verified orgs) - közvetlen frissítés
2. `marketplace.service_staging` (robot adatok) - migrálás Organization-be
3. `marketplace.service_providers` (crowdsourced) - migrálás Organization-be
**3. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:781):**
- `phone` → `contact_phone` és `email` → `contact_email` mezőnevek javítva (illeszkedés a backend Pydantic sémához)
**4. INFRA - [`pre_start.sh`](backend/app/scripts/pre_start.sh):**
- Dokumentálva: a `uvicorn` `--reload` nélkül fut, kódváltoztatás után `docker compose restart sf_api` szükséges
### ✅ Teszt eredmény
- A pontosan reprodukált hiba (Bokebo Kft., category_id=null, category_ids=[149]) **HTTP 201** választ ad
- Adatbázisban: Organization létrejött, ServiceProfile létrejött, ServiceExpertise kapcsolat a 149-es kategóriával létrejött
- Gamification: 500 pont jóváírva
**5. BACKEND - [`provider_service.py:578-596`](backend/app/services/provider_service.py:578):**
- **Adatvédelmi javítás (2026-06-17):** A címmezők (`address_city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`) most már **csak akkor íródnak felül**, ha a frontend explicit nem-`null` értéket küld. Ez megakadályozza, hogy a meglévő címadatok véletlenül `null`-ra állítódjanak, amikor a felhasználó csak más mezőket szerkeszt.
- **Trigger:** A Gitea kártya visszautasításra került (`denied` státusz) a felhasználó által: *"az adatok tárolása minden esetben bontottan történjen meg és ha hiányzik valamelyik az alap cím tárolási adatból akkor vissza kell tenni."*
## 2026-06-17: Bug investigation - Quick-add provider cégek megjelennek a Cégeim menüben
### ✅ Verifikáció
- **PUT /providers/58**: 200 OK ✅
- **PUT /providers/58 (partial update - csak zip)**: 200 OK ✅ (többi mező nem nullázódik)
- **GET /providers/search?q=Dunakeszi**: 200 OK ✅ (2 provider)
- **GET /providers/categories**: 200 OK ✅ (11 categories)
- **Login**: 200 OK ✅
**Kártya:** #266
**Szerepkör:** Architect
**Állapot:** Tervezés kész
## 2026-06-17 - i18n: Hiányzó provider címmező fordítások hozzáadása
### Felfedezett hibák
1. **`GET /organizations/my` endpoint** (`backend/app/api/v1/endpoints/organizations.py:183-186`): Nincs `org_type` szűrés, minden szervezetet visszaad, ahol a user tag az `OrganizationMember` táblában.
2. **`HeaderCompanySwitcher.vue:142-146`**: A `companyOrganizations` computed property csak az `individual` típust szűri ki, a `service_provider` típusú szervezeteket nem.
3. **`quick_add_provider()`** (`backend/app/services/provider_service.py:638-646`): Létrehoz `OrganizationMember`-et `role=OWNER`-rel, ami miatt a `GET /my` visszaadja a szolgáltatót.
### Javítási terv
1. `organizations.py:183-186`: `org_type` szűrés hozzáadása (csak `business`, `fleet_owner`, `individual`)
2. `organizations.py:193-206`: `owner_id` mező hozzáadása a response-hoz
3. `HeaderCompanySwitcher.vue:142-146`: `service_provider` és `service` típusok kiszűrése
4. Adatbázis audit: hiányzó `owner_id`-k ellenőrzése a régi service_providereknél
### Kapcsolódó fájlok
- `plans/logic_spec_provider_cegeim_bugfix.md` - Részletes logic_spec
## 2026-06-17: Bugfix implementáció - Quick-add provider cégek a Cégeim menüben (#266)
**Szerepkör:** Code (Fast Coder)
**Állapot:** Implementálva és tesztelve
### Végrehajtott módosítások
1. **`backend/app/api/v1/endpoints/organizations.py:183-186`** — `GET /my` endpoint:
- Hozzáadva `org_type` szűrés: csak `business`, `fleet_owner`, `individual` típusú szervezetek visszaadása
- `owner_id` mező hozzáadva a response dict-hez
2. **`frontend/src/components/header/HeaderCompanySwitcher.vue:142-146`** — `companyOrganizations` computed:
- `service_provider` és `service` típusok kiszűrése (biztonsági réteg)
### Verifikáció
- ✅ `sync_engine.py` — 1065 elem OK, 0 hiba
- ✅ Backend Python import check — OK
- ✅ Frontend `npm run build` — 5.97s, 0 hiba
### 🔧 Javítás (v2) — 2026-06-17
**Probléma:** A whitelist (`in_([business, fleet_owner, individual])`) túl szigorú volt, kizárta a valódi cégeket (pl. `club` típus).
**Megoldás:**
1. **`backend/app/api/v1/endpoints/organizations.py:193`** — `in_([...])` → `notin_([OrgType.service_provider, OrgType.service])`
- Blacklist megközelítés: csak a service_provider és service típusokat szűrjük ki
- Minden más típus (business, fleet_owner, individual, club, stb.) megjelenik
### Verifikáció
- ✅ `sync_engine.py` — 1065 elem OK, 0 hiba
- ✅ Backend Python import check — OK
- ✅ `notin_` szintaxis ellenőrizve — OK
## 2026-06-17 - Garázsválasztó Bugfix: service_provider org-ok nem látszottak OWNER-nek sem
### 🎯 Cél
A ProviderEditModal.vue 6 darab `provider.*` i18n kulcsa hiányzott mindkét nyelvi modulból (`hu.ts`, `en.ts`), így a felhasználói felületen a kulcsnevek (pl. `provider.streetNameLabel`) jelentek meg a lefordított szöveg helyett.
A saját tulajdonú cégek (service_provider org_type) megjelenítése a garázsválasztó gombban, ha a felhasználó OWNER joggal rendelkezik.
### 🔧 Változtatások
**1. FRONTEND - [`hu.ts`](frontend/src/i18n/hu.ts:1300):**
- `provider.streetNameLabel`: `'Utca neve'`
- `provider.streetNamePlaceholder`: `'Pl. Egressy'`
- `provider.streetTypeLabel`: `'Közterület jellege'`
- `provider.streetTypePlaceholder`: `'Válassz típust...'`
- `provider.houseNumberLabel`: `'Házszám'`
- `provider.houseNumberPlaceholder`: `'Pl. 4'`
**1. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:177-228):**
- A `GET /my` végpont most már `select(Organization, OrganizationMember.role)`-t használ, hogy a felhasználó szerepkörét is lekérdezze
- A `service_provider`/`service` típusú org-ok utólagos szűrése: csak akkor jelennek meg, ha a user role = `OWNER`
- A válaszban új `user_role` mező került visszaadásra
**2. FRONTEND - [`en.ts`](frontend/src/i18n/en.ts:1300):**
- `provider.streetNameLabel`: `'Street Name'`
- `provider.streetNamePlaceholder`: `'e.g. Egressy'`
- `provider.streetTypeLabel`: `'Street Type'`
- `provider.streetTypePlaceholder`: `'Select type...'`
- `provider.houseNumberLabel`: `'House Number'`
- `provider.houseNumberPlaceholder`: `'e.g. 4'`
**2. FRONTEND - [`HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue:144-158):**
- A `companyOrganizations` computed property most már a `user_role` mezőt is figyelembe veszi
- `service_provider`/`service` típus csak `user_role === 'OWNER'` esetén jelenik meg
**3. FRONTEND - [`organization.ts`](frontend/src/types/organization.ts:25-30):**
- Új `user_role?: string` mező az `OrganizationItem` interfészben
### Verifikáció
- ✅ Backend syntax check — OK
- ✅ API teszt: 4 org visszatér (1 individual + 3 service_provider OWNER)
- ✅ `user_role` mező jelen van a válaszban (ADMIN, OWNER)
- ✅ Nincs regresszió: non-OWNER role esetén a service_provider org-ok ki vannak szűrve
## 2026-06-17: Root cause fix - quick_add_provider owner_id és role javítás (#266)
### 🔍 Felismert probléma
A `quick_add_provider` függvény a crowdsourcingból felvett szolgáltatóknál `owner_id=user_id`-t állított be és `OWNER` szerepkörű tagot hozott létre. Ez hibás, mert:
- A közösségi jelöléssel felvett cégeknek NINCS tulajdonosa (`owner_id=NULL`)
- Aki felvesz egy céget a Service Finder-be, az NEM lesz a cég tulajdonosa
- Az `OWNER` szerepkör miatt ezek a cégek megjelentek a "Cégeim" garázsválasztóban
### 🔧 Végrehajtott módosítások
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:541):**
- `owner_id=user_id` → `owner_id=None` — crowdsourcingból felvett szolgáltatónak nincs tulajdonosa
**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:641):**
- `role=OrgUserRole.OWNER` → `role=OrgUserRole.ADMIN` — a felhasználó ADMIN jogot kap (szerkesztheti), de nem tulajdonos
### ✅ Verifikáció
- Mindkét i18n fájl szintaktikailag helyes (Node.js require sikeres)
- A ProviderEditModal.vue összes `t('provider.*')` hívása le van fedve
- ✅ Sync engine: séma konzisztens (1065 elem OK)
- ✅ API teszt: `GET /api/v1/organizations/my` → 1 org (Test Company, individual)
- ✅ service_provider org-ok (57, 58, 62) NEM jelennek meg a garázsválasztóban
- ✅ A meglévő adatok (owner_id=2, role=OWNER) a régi kóddal jöttek létre, az új kód már helyesen hozza létre az új provider-eket
## 2026-06-17 - "Dunakeszi, Dunakeszi" duplikáció javítása + irányítószám megjelenítés
## 2026-06-17 - P1 Garage Selector Implementation & OrganizationMember Audit
### 🎯 Cél
A szervizkereső oldalon a kártyán "Dunakeszi, Dunakeszi" duplikált városnév jelent meg, mert a backend `address` mezője megegyezett a `city` mezővel. A részletes nézetből hiányzott az irányítószám.
Dashboard "Garage Selector" (cégválasztó) pontosítása, hogy a bejelentkezett felhasználó azon Organization rekordokat lássa, ahol:
a) Technikai tulajdonos (`owner_id`) VAGY
b) Aktív tag (`OrganizationMember`)
Emellett az `OrganizationMember` tábla hiányzó oszlopainak pótlása és audit jelentés készítése.
### 🔧 Változtatások
**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:198):**
- Az `address` mezőt `Organization.address_city.label("address")`-ről `func.concat(...)`-re változtattuk, ami az összes atomizált címmezőt (irányítószám, város, utca, közterület, házszám) fűzi össze.
- Példa eredmény: `"2120 Dunakeszi, Egressy utca 4"` a korábbi `"Dunakeszi"` helyett.
**1. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:198):**
- `get_my_organizations` query javítva: `INNER JOIN` → `LEFT JOIN` + `OR` feltétel (`owner_id` VAGY `OrganizationMember.user_id`)
- `DISTINCT` hozzáadva a duplikációk elkerülésére
- `user_role` mező visszaadása a frontend számára
- `_get_user_role()` segédfüggvény implementálva
**2. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:543):**
- Új `formatCardAddress(provider)` metódus, ami a kártyán az atomizált címmezőkből építi fel a címet.
- Fallback: ha nincs atomizált adat, a backend által összefűzött `address` mezőt használja.
**2. BACKEND - [`organization.py`](backend/app/models/marketplace/organization.py:195):**
- `OrganizationMember` modellhez hozzáadva: `status` (String(20)), `created_at`, `updated_at` oszlopok
- Ezek az oszlopok hiányoztak az adatbázisból, de a kód már hivatkozott rájuk
**3. FRONTEND - [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue:237):**
- A `formattedAddress` computed property most már tartalmazza az `address_zip` mezőt is.
- Formátum: `"2120 Dunakeszi, Egressy utca 4"`
**3. ADATBÁZIS - `sync_engine`:**
- 3 hiányzó oszlop sikeresen hozzáadva a `fleet.organization_members` táblához
### ✅ Verifikáció
- Backend API: `GET /api/v1/providers/search?city=Dunakeszi``address` = `"2120 Dunakeszi, Egressy utca 4"`
- Backend API: Autónyíri Kft. (id=58) → `address_zip=2120`, `address_street_name=Egressy`, `address_street_type=utca`, `address_house_number=4`
- Frontend build: `npm run build` sikeres, 0 hiba ✅
- Backend konténer újraindítva: `docker compose restart sf_api`
**4. DOKUMENTÁCIÓ - [`organization_member_audit_report_2026-06-17.md`](docs/organization_member_audit_report_2026-06-17.md):**
- Részletes audit jelentés az OrganizationMember tábláról
- Hiányzó API végpontok azonosítása (tagkezelés, meghívókezelés)
- Javasolt javítási sorrend
## 2026-06-17 - Kártya fő szolgáltatás (category) mindig megjelenítése
### ✅ Eredmény
- `GET /api/v1/organizations/my` helyesen adja vissza a tulajdonosi és tagsági viszonyokat
- `user_role` mező elérhető a frontend számára
- Adatbázis séma konzisztens a kóddal
- Audit jelentés elkészítve a docs mappában
## 2026-06-17 - P0 Garage Selector Fix & Team Management API Implementation
### 🎯 Cél
A szervizkereső kártyákon a fő szolgáltatás (category) mindig látszódjon. Ha van, akkor a kategória neve, ha nincs, akkor egy szaggatott vonalú placeholder.
A P0 Garage Selector hiba végleges javítása (a `GET /organizations/my` 500-as hibát dobott JSON oszlopok miatt) és a hiányzó Team Management API végpontok implementálása.
### 🔧 Változtatások
**1. FRONTEND - [`ServiceFinderView.vue:263`](frontend/src/views/ServiceFinderView.vue:263):**
- A category badge `v-if="provider.category"` helyett `v-if`/`v-else` szerkezet:
- Ha van category: `bg-sf-accent/10` háttér, `text-sf-accent` szín
- Ha nincs: szaggatott vonalú (`border-dashed`) placeholder "Nincs kategória" / "No category"
**1. BACKEND - [`organization.py`](backend/app/models/marketplace/organization.py:187-190):**
- `OrganizationMember` modellhez hozzáadva: `invited_email` (String(255)) és `expires_at` (DateTime(timezone=True)) oszlopok
- `OrgUserRole` enum szinkronizálva az adatbázisban lévő értékekkel: `OWNER, ADMIN, MANAGER, MEMBER, AGENT`
- `default=OrgUserRole.DRIVER` → `default=OrgUserRole.MEMBER`
**2. FRONTEND - [`hu.ts:1237`](frontend/src/i18n/hu.ts:1237):**
- `serviceFinder.noCategory`: `'Nincs kategória'`
**2. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:199-218):**
- **`GET /organizations/my` SQL javítás:** A `DISTINCT` a teljes Organization modellen JSON oszlopok miatt hibát dobott (`could not identify an equality operator for type json`)
- **Megoldás:** Subquery approach: először csak az ID-kat szedjük DISTINCT-tel, majd a második query-ben `selectinload(Organization.members)`-szel töltjük be a teljes objektumokat
- `selectinload` import hozzáadva a `sqlalchemy.orm`-ból
**3. FRONTEND - [`en.ts:1237`](frontend/src/i18n/en.ts:1237):**
- `serviceFinder.noCategory`: `'No category'`
**3. BACKEND - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:326-607):**
- **`GET /{org_id}/members`** — Tagok listázása (user email + role + status)
- **`POST /{org_id}/invitations`** — Meghívó küldése (invited_email, expires_at=+7 nap)
- **`PATCH /{org_id}/members/{member_id}`** — Szerepkör módosítása (utolsó OWNER védelme)
- **`DELETE /{org_id}/members/{member_id}`** — Tag eltávolítása / meghívó visszavonása
- **`_check_org_admin_access()`** — Segédfüggvény: csak OWNER/ADMIN férhet hozzá
- Pydantic modellek: `MemberResponse`, `MemberRoleUpdate`, `InvitationCreate`
- Régi duplikált `POST /invitations` és `POST /invitations/{token}/accept` végpontok eltávolítva
### ✅ Verifikáció
- Frontend build: `npm run build` sikeres, 0 hiba ✅
**4. ADATBÁZIS - `sync_engine`:**
- 2 új oszlop (`invited_email`, `expires_at`) sikeresen hozzáadva a `fleet.organization_members` táblához
### ✅ Teszt eredmény
- ✅ `GET /api/v1/organizations/my` → **200 OK** (1 org: Test Company, user_role: ADMIN)
- ✅ `GET /api/v1/organizations/1/members` → **200 OK** (1 member: admin@profibot.hu)
- ✅ `POST /api/v1/organizations/1/invitations` → **201 Created** (invited_email, expires_at, role=MANAGER)
- ✅ `PATCH /api/v1/organizations/1/members/25` → **200 OK** (role MANAGER → ADMIN)
- ✅ `DELETE /api/v1/organizations/1/members/25` → **200 OK** (invitation revoked)
## 2026-06-17 - Team Management Modul Refaktorálás (Dynamic RBAC, Settings, i18n)
### 🎯 Cél
A Team Management modul teljes refaktorálása: hardcoded `OrgUserRole` Enum kiváltása adatbázis-vezérelt `OrgRole` modellel, hardcoded 7 napos meghívási lejárat kiváltása dinamikus `Organization.settings` JSONB mezővel, és az összes hardcoded magyar szöveg kiváltása i18n kulcsokkal a `TranslationService.get_text()` segítségével.
### 🔧 Változtatások
**1. MODELL - [`organization.py`](backend/app/models/marketplace/organization.py:34):**
- **`OrgRole`** új modell létrehozva a `fleet.org_roles` táblában (id, name_key, display_name, description, is_system, priority, is_active, created_at)
- **`Organization.settings`** JSONB oszlop hozzáadva alapértelmezett értékekkel: `{"invite_expiry_days": 7, "max_members": 50, "allow_public_join": false}`
- **`OrganizationMember.role`** típusa megváltoztatva `PG_ENUM(OrgUserRole)`-ról `String(50)`-re, alapértelmezett `"MEMBER"`
**2. MODELL EXPORT - [`__init__.py`](backend/app/models/__init__.py:1):**
- `OrgRole` hozzáadva az importokhoz és az `__all__` listához
**3. API VÉGPONTOK - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:1):**
- `_()` helper függvény hozzáadva a `TranslationService.get_text()` rövidítésére
- `_get_org_settings()` helper hozzáadva a dinamikus beállítások olvasásához
- Összes hardcoded magyar szöveg kiváltva i18n kulcsokkal (pl. `ORGANIZATION.ERROR.INVALID_TAX_FORMAT`, `ORGANIZATION.SUCCESS.ONBOARDED`, `ORGANIZATION.CLAIM.OTP_SENT`)
- Összes `OrgUserRole.OWNER` / `OrgUserRole.ADMIN` Enum referencia kiváltva string literálokkal (`"OWNER"`, `"ADMIN"`)
- `member.role.value` → `str(member.role)`
- `timedelta(days=7)` → `timedelta(days=invite_expiry_days)` a `_get_org_settings()`-ből
**4. LOCALE FÁJLOK - [`hu.json`](backend/static/locales/hu.json:1), [`en.json`](backend/static/locales/en.json:1):**
- `ORGANIZATION` névtér hozzáadva mindkét nyelvhez a következő alkulcsokkal: `ERROR.*` (11 hibaüzenet), `SUCCESS.*` (5 sikerüzenet), `INVITATION.*` (3 meghívó üzenet), `JOIN_REQUEST.*` (1 csatlakozási üzenet), `CLAIM.*` (2 átvételi üzenet)
**5. ADATBÁZIS SZINKRONIZÁCIÓ:**
- `sync_engine.py` sikeresen lefuttatva: `fleet.org_roles` tábla létrehozva, `fleet.organizations.settings` oszlop hozzáadva
### ✅ Eredmény
- Adatbázis séma frissítve (2 új elem: org_roles tábla + settings oszlop)
- API végpontok mind i18n kulcsokat használnak a `TranslationService.get_text()`-en keresztül
- Nincs több hardcoded `OrgUserRole` Enum a Team Management modulban
- Nincs több hardcoded 7 napos lejárat
- Nincs több hardcoded magyar szöveg az API válaszokban
- Business onboarding teszt: ✅ PASS

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).
4-Level Category Architecture (2026-06-17):
============================================
- GET /categories/tree — Teljes hierarchikus fa a frontend checkboxai számára
- GET /categories/autocomplete — Szöveges kereső Szint 2 és Szint 3 címkékhez
- GET /categories — Meglévő endpoint (ExpertiseTag lista)
- GET /search — Szolgáltató keresés
- POST /quick-add — Gyors szolgáltató felvétel
- PUT /{provider_id} — Szolgáltató szerkesztés (hibrid mentés)
"""
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.db.session import get_db
from app.api.deps import get_current_user
@@ -20,6 +30,8 @@ from app.schemas.provider import (
ProviderUpdateIn,
ProviderUpdateResponse,
ExpertiseCategoryOut,
CategoryTreeNode,
CategoryAutocompleteItem,
)
from app.services.provider_service import search_providers, quick_add_provider, update_provider
@@ -27,6 +39,114 @@ router = APIRouter()
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────
# 4-LEVEL CATEGORY ENDPOINTS (2026-06-17)
# ──────────────────────────────────────────────
@router.get("/categories/tree", response_model=List[CategoryTreeNode])
async def get_category_tree(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Visszaadja a teljes kategória hierarchikus fát a frontend checkboxai számára.
Level 0: Járműtípus (Vehicle Type)
Level 1: Iparág/Főcsoport (Industry)
Level 2: Szakma (Profession)
Level 3: Specifikus feladat/címke (Specific Tag)
Rekurzívan építi fel a fa struktúrát a parent_id és path mezők alapján.
"""
try:
# Összes tag betöltése
stmt = select(ExpertiseTag).order_by(ExpertiseTag.level, ExpertiseTag.id)
result = await db.execute(stmt)
all_tags = result.scalars().all()
# Indexelés ID alapján
tag_map = {t.id: t for t in all_tags}
# Fa építése: csak a gyökér elemeket (parent_id IS NULL) adjuk vissza
def build_tree(parent_id: Optional[int] = None) -> List[CategoryTreeNode]:
nodes = []
for tag in all_tags:
if tag.parent_id == parent_id:
children = build_tree(tag.id)
nodes.append(CategoryTreeNode(
id=tag.id,
key=tag.key,
name_hu=tag.name_hu,
name_en=tag.name_en,
level=tag.level,
path=tag.path,
is_official=tag.is_official,
children=children,
))
return nodes
tree = build_tree(parent_id=None)
return tree
except Exception as e:
logger.error(f"Hiba a kategória fa lekérése során: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba történt a kategória fa lekérése során.",
)
@router.get("/categories/autocomplete", response_model=List[CategoryAutocompleteItem])
async def autocomplete_categories(
q: str = Query(..., min_length=2, max_length=100, description="Keresőszó"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
Csak azokat listázza, ahol is_official=True.
A keresés a name_hu, name_en és key mezőkben történik (ILIKE).
"""
try:
stmt = select(ExpertiseTag).where(
ExpertiseTag.level >= 2,
ExpertiseTag.is_official == True,
or_(
ExpertiseTag.name_hu.ilike(f"%{q}%"),
ExpertiseTag.name_en.ilike(f"%{q}%"),
ExpertiseTag.key.ilike(f"%{q}%"),
),
).order_by(ExpertiseTag.level, ExpertiseTag.name_hu).limit(20)
result = await db.execute(stmt)
tags = result.scalars().all()
return [
CategoryAutocompleteItem(
id=t.id,
key=t.key,
name_hu=t.name_hu,
name_en=t.name_en,
level=t.level,
path=t.path,
parent_id=t.parent_id,
)
for t in tags
]
except Exception as e:
logger.error(f"Hiba a kategória autocomplete során: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba történt a kategória keresés során.",
)
# ──────────────────────────────────────────────
# EXISTING ENDPOINTS (extended)
# ──────────────────────────────────────────────
@router.get("/categories", response_model=List[ExpertiseCategoryOut])
async def list_expertise_categories(
db: AsyncSession = Depends(get_db),
@@ -34,9 +154,10 @@ async def list_expertise_categories(
):
"""
Visszaadja az összes expertise kategóriát a frontend dropdown számára.
Kibővítve a 4-level hierarchy mezőkkel (level, parent_id, path).
"""
try:
stmt = select(ExpertiseTag).order_by(ExpertiseTag.id)
stmt = select(ExpertiseTag).order_by(ExpertiseTag.level, ExpertiseTag.id)
result = await db.execute(stmt)
tags = result.scalars().all()
return [
@@ -46,6 +167,9 @@ async def list_expertise_categories(
name_hu=t.name_hu,
name_en=t.name_en,
category=t.category,
level=t.level,
parent_id=t.parent_id,
path=t.path,
)
for t in tags
]
@@ -62,6 +186,7 @@ async def search_service_providers(
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"),
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),
limit: int = Query(20, ge=1, le=100, description="Találatok száma oldalanként"),
offset: int = Query(0, ge=0, description="Lapozási offset"),
db: AsyncSession = Depends(get_db),
@@ -76,17 +201,35 @@ async def search_service_providers(
- **crowd_added**: Közösség által hozzáadott adatok (marketplace.service_providers)
A találatok forrás szerint csoportosítva, lapozva érkeznek.
**category_ids**: Ha meg van adva, csak azok a szervezetek jelennek meg,
amelyek ServiceProfile-jához tartozik legalább egy megadott ExpertiseTag
(közvetlenül vagy hierarchikusan a path LIKE segítségével).
"""
try:
# Parse category_ids: vesszővel elválasztott string -> List[int]
parsed_category_ids: Optional[List[int]] = None
if category_ids:
try:
parsed_category_ids = [int(x.strip()) for x in category_ids.split(",") if x.strip()]
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Érvénytelen category_ids formátum. Vesszővel elválasztott számok szükségesek (pl. '201,205').",
)
result = await search_providers(
db=db,
q=q,
category=category,
city=city,
category_ids=parsed_category_ids,
limit=limit,
offset=offset,
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Hiba a szolgáltató keresés során: {e}", exc_info=True)
raise HTTPException(
@@ -143,6 +286,14 @@ async def update_service_provider(
Frissíti a szervezet alapadatait (név, cím) és a kapcsolódó
ServiceProfile kapcsolati mezőit (telefon, email, weboldal, címkék).
4-Level Category Hybrid Save (2026-06-17):
- category_ids: A kiválasztott kategória ID-k (Szint 0-3 checkboxokból)
-> ServiceExpertise kapcsolatok frissítése
- new_tags: A user által gépelt új címkenevek
-> Létrehozás ExpertiseTag-ban (is_official=False, level=3)
-> ServiceExpertise kapcsolat létrehozása
- tags: Meglévő specialization_tags frissítése
"""
try:
result = await update_provider(
@@ -157,6 +308,11 @@ async def update_service_provider(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except PermissionError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except Exception as e:
logger.error(f"Hiba a szolgáltató frissítése során (id={provider_id}): {e}", exc_info=True)
raise HTTPException(

View File

@@ -6,7 +6,7 @@ from app.database import Base
from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole, OneTimePassword
# 2. Szervezeti felépítés (MUST be before Address/Rating due to FK references)
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, OrgRole, Branch
# 3. Földrajzi adatok és címek
from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
@@ -59,7 +59,7 @@ ServiceRecord = AssetEvent
__all__ = [
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole",
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole", "OrgRole",
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",

View File

@@ -27,10 +27,27 @@ class OrgType(str, enum.Enum):
class OrgUserRole(str, enum.Enum):
OWNER = "OWNER"
ADMIN = "ADMIN"
FLEET_MANAGER = "FLEET_MANAGER"
DRIVER = "DRIVER"
MECHANIC = "MECHANIC"
RECEPTIONIST = "RECEPTIONIST"
MANAGER = "MANAGER"
MEMBER = "MEMBER"
AGENT = "AGENT"
class OrgRole(Base):
"""
Dinamikus szerepkör modell (RBAC).
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
Alapértelmezett szerepkörök: OWNER, ADMIN, MANAGER, MEMBER, AGENT.
"""
__tablename__ = "org_roles"
__table_args__ = {"schema": "fleet"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name_key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(String(255))
is_system: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Organization(Base):
"""
@@ -72,7 +89,7 @@ class Organization(Base):
full_name: Mapped[str] = mapped_column(String, nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
display_name: Mapped[Optional[str]] = mapped_column(String(50))
folder_slug: Mapped[str] = mapped_column(String(12), unique=True, index=True)
folder_slug: Mapped[str] = mapped_column(String(24), unique=True, index=True)
default_currency: Mapped[str] = mapped_column(String(3), default="HUF")
country_code: Mapped[str] = mapped_column(String(2), default="HU")
@@ -84,6 +101,7 @@ class Organization(Base):
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
plus_code: Mapped[Optional[str]] = mapped_column(String(20))
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
@@ -115,6 +133,14 @@ class Organization(Base):
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
)
# Dinamikus szervezeti beállítások (pl. invite_expiry_days, role_hierarchy)
settings: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=lambda: {"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False},
server_default=text("'{\"invite_expiry_days\": 7, \"max_members\": 50, \"allow_public_join\": false}'::jsonb")
)
# --- 🔍 CROWDSOURCED SEARCH FIELDS ---
# Provider nicknames / alternative names for matching (e.g. ["MOL", "MOL LUB", "MOL Magyarország"])
aliases: Mapped[list] = mapped_column(
@@ -183,14 +209,27 @@ class OrganizationMember(Base):
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
role: Mapped[OrgUserRole] = mapped_column(
PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet"),
default=OrgUserRole.DRIVER
# Meghívott e-mail címe (ha a meghívottnak még nincs user fiókja)
invited_email: Mapped[Optional[str]] = mapped_column(String(255))
# Meghívó lejárati ideje
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# Dinamikus szerepkör (String, nem Enum) - az OrgRole táblából validáljuk
role: Mapped[str] = mapped_column(
String(50),
default="MEMBER",
server_default=text("'MEMBER'")
)
permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
is_permanent: Mapped[bool] = mapped_column(Boolean, default=False)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
# 🔴 HIÁNYZÓ OSZLOPOK - Pótolva a logic_spec alapján
status: Mapped[str] = mapped_column(String(20), default="active", server_default=text("'active'"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
organization: Mapped["Organization"] = relationship("Organization", back_populates="members")
user: Mapped[Optional["User"]] = relationship("User")
person: Mapped[Optional["Person"]] = relationship("Person", back_populates="memberships")

View File

@@ -80,9 +80,21 @@ class ExpertiseTag(Base):
"""
Szakmai címkék mesterlistája (MB 2.0).
Ez a tábla vezérli a robotok keresését és a Gamification pontozást is.
4-Level Category Hierarchy (2026-06-17):
=========================================
Level 0: Járműtípus (Vehicle Type) — e.g. "Személyautó", "Motorkerékpár"
Level 1: Iparág/Főcsoport (Industry) — e.g. "Karbantartás és javítás", "Üzemanyagtöltő"
Level 2: Szakma (Profession) — e.g. "Autószerelő", "Gumiszerviz"
Level 3: Specifikus feladat/címke (Specific Tag) — e.g. "Vezérműszíj csere", "Klíma töltés"
Adjacency List (parent_id) + Materialized Path (path) for efficient tree queries.
"""
__tablename__ = "expertise_tags"
__table_args__ = {"schema": "marketplace"}
__table_args__ = (
Index('idx_expertise_path', 'path'),
{"schema": "marketplace"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
@@ -90,6 +102,13 @@ class ExpertiseTag(Base):
name_en: Mapped[Optional[str]] = mapped_column(String(100))
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
parent_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("marketplace.expertise_tags.id"), nullable=True, index=True
)
level: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"), nullable=False)
path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
# --- 🎮 GAMIFICATION ÉS DISCOVERY ---
is_official: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true"))
suggested_by_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
@@ -102,6 +121,20 @@ class ExpertiseTag(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
# Self-referential relationship for tree traversal
# NOTE: cascade="all, delete-orphan" is intentionally NOT set here because
# this is a self-referential tree. Deleting a parent should NOT cascade-delete
# children (they may be re-parented). The "many" side of a self-referential
# relationship cannot have delete-orphan without single_parent=True.
children: Mapped[List["ExpertiseTag"]] = relationship(
"ExpertiseTag", back_populates="parent",
remote_side=[id],
)
parent: Mapped[Optional["ExpertiseTag"]] = relationship(
"ExpertiseTag", back_populates="children",
remote_side=[parent_id],
)
services: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="tag")
suggested_by: Mapped[Optional["Person"]] = relationship("Person")

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.
- street -> address_street_name, address_street_type, address_house_number
- A ProviderSearchResult is tartalmazza az atomizált címmezőket.
4-Level Category Architecture (2026-06-17):
===========================================
- CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz
- CategoryAutocompleteItem: Szöveges kereső találatok Szint 2 és Szint 3 címkékhez
- ProviderUpdateIn: kibővítve category_ids listával a hibrid mentéshez
"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
class ExpertiseCategoryOut(BaseModel):
"""Expertise kategória kimeneti sémája a frontend dropdown számára."""
# ──────────────────────────────────────────────
# 4-LEVEL CATEGORY SCHEMAS (2026-06-17)
# ──────────────────────────────────────────────
class CategoryTreeNode(BaseModel):
"""Egy csomópont a kategória hierarchikus fában.
Tartalmazza a gyermek csomópontokat rekurzívan.
"""
id: int
key: str
name_hu: Optional[str] = None
name_en: Optional[str] = None
category: str
level: int = 0
path: Optional[str] = None
is_official: bool = True
children: List["CategoryTreeNode"] = Field(default_factory=list)
model_config = ConfigDict(from_attributes=True)
class CategoryAutocompleteItem(BaseModel):
"""Egy találat a kategória autocomplete keresőben.
Csak Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkéket
listáz, ahol is_official=True.
"""
id: int
key: str
name_hu: Optional[str] = None
name_en: Optional[str] = None
level: int = 0
path: Optional[str] = None
parent_id: Optional[int] = None
model_config = ConfigDict(from_attributes=True)
# ──────────────────────────────────────────────
# EXISTING SCHEMAS (extended)
# ──────────────────────────────────────────────
class ExpertiseCategoryOut(BaseModel):
"""Expertise kategória kimeneti sémája a frontend dropdown számára.
Kibővítve a 4-level hierarchy mezőkkel.
"""
id: int
key: str
name_hu: Optional[str] = None
name_en: Optional[str] = None
category: Optional[str] = None
level: int = 0
parent_id: Optional[int] = None
path: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class CategoryInfo(BaseModel):
"""Egy kategória rövid adatai a keresési találatokhoz.
Tartalmazza a kategória ID-ját, nevét és szintjét,
hogy a frontend meg tudja jeleníteni a kapcsolódó
szolgáltatásokat a kártyákon.
"""
id: int
name_hu: Optional[str] = None
name_en: Optional[str] = None
level: int = 0
key: str
class ProviderSearchResult(BaseModel):
"""Egy szolgáltató keresési találatának adatai.
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
- address_street_name, address_street_type, address_house_number
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
FEATURE (2026-06-17): categories mező.
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
"""
id: int
name: str
category: Optional[str] = None
specialization: List[str] = Field(default_factory=list)
categories: List[CategoryInfo] = Field(default_factory=list)
city: Optional[str] = None
address: Optional[str] = None
address_zip: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
plus_code: Optional[str] = None
contact_phone: Optional[str] = None
contact_email: Optional[str] = None
website: Optional[str] = None
@@ -63,14 +137,24 @@ class ProviderQuickAddIn(BaseModel):
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
- A 4-level kategória rendszer bevezetésével a frontend a category_ids
tömböt használja a kategóriák kiválasztására, nem a régi category_id-t.
- category_id megtartva opcionálisként a backward compatibility miatt.
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3 checkboxokból)
- new_tags: A user által gépelt új (még nem létező) címkék listája
"""
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
category_id: int = Field(..., ge=1, description="Kategória ID (marketplace.expertise_tags.id)")
category_id: Optional[int] = Field(None, ge=1, description="Elsődleges kategória ID (opcionális, backward compatibility)")
category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)")
new_tags: Optional[List[str]] = Field(None, description="User által gépelt új címkenevek (is_official=False, level=3)")
city: Optional[str] = Field(None, max_length=100, description="Város")
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
@@ -92,6 +176,10 @@ class ProviderUpdateIn(BaseModel):
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
- Tartalmazza a contact_phone, contact_email, website és tags mezőket is.
4-Level Category (2026-06-17):
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3)
- new_tags: A user által gépelt, új (még nem létező) címkék listája
"""
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
city: Optional[str] = Field(None, max_length=100, description="Város")
@@ -99,10 +187,17 @@ class ProviderUpdateIn(BaseModel):
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
category_ids: Optional[List[int]] = Field(
None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)"
)
new_tags: Optional[List[str]] = Field(
None, description="User által gépelt új címkenevek (is_official=False, level=3)"
)
class ProviderUpdateResponse(BaseModel):
@@ -110,3 +205,4 @@ class ProviderUpdateResponse(BaseModel):
id: int
name: str
message: str = "Szolgáltató adatai sikeresen frissítve."
earned_points: int = 0

View File

@@ -51,6 +51,12 @@ SEED_RULES = [
"description": "Szolgáltató értékelése (tagekkel)",
"is_active": True,
},
{
"action_key": "UPDATE_PROVIDER",
"points": 100,
"description": "Szolgáltató adatainak szerkesztése (kategóriák, címkék frissítése)",
"is_active": True,
},
]

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
- search_providers: visszaadja az address_street_name, address_street_type, address_house_number mezőket
- A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek.
4-Level Category Hybrid Save (2026-06-17):
===========================================
- update_provider: category_ids -> ServiceExpertise kapcsolatok frissítése
- update_provider: new_tags -> új ExpertiseTag létrehozása (is_official=False, level=3)
- A hibrid logika biztosítja, hogy a meglévő kapcsolatok ne sérüljenek
"""
import logging
import re
import uuid
import hashlib
from datetime import datetime, timezone
from typing import Optional, List, Tuple
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
from app.models.marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging
from app.models.identity.social import ServiceProvider
from app.models.gamification.gamification import PointRule, UserStats
from app.schemas.provider import (
@@ -42,6 +49,7 @@ from app.schemas.provider import (
ProviderQuickAddResponse,
ProviderUpdateIn,
ProviderUpdateResponse,
CategoryInfo,
)
from app.services.gamification_service import gamification_service
@@ -132,6 +140,7 @@ async def search_providers(
q: Optional[str] = None,
category: Optional[str] = None,
city: Optional[str] = None,
category_ids: Optional[List[int]] = None,
limit: int = 20,
offset: int = 0,
) -> ProviderSearchResponse:
@@ -144,11 +153,27 @@ async def search_providers(
P1 CRITICAL ALIGN (2026-06-17): Az org lekérdezés most már visszaadja
az address_street_name, address_street_type, address_house_number mezőket is.
FEATURE (2026-06-17): category_ids szűrő — SZIGORÚ AND KAPCSOLAT.
- Ha több kategória ID van megadva, a szolgáltatónak MINDEGYIKHEZ
tartoznia kell (AND), nem elég, ha csak az egyikhez (OR).
- Nincs hierarchikus terjeszkedés: csak a pontosan megadott ID-kat
ellenőrizzük a ServiceExpertise táblában.
FEATURE (2026-06-17): Keresés szigorítása.
- A q (free-text) keresés csak a name és aliases mezőkben keres (ILIKE),
a tags mezőt NEM vizsgálja.
- A city keresés exact match vagy StartsWith, nem ILIKE.
FEATURE (2026-06-17): Kategória adatok visszaadása.
- Minden ProviderSearchResult tartalmazza a kapcsolódó kategóriák
(ExpertiseTag) ID-ját, nevét és szintjét a categories mezőben.
Args:
db: AsyncSession
q: Keresőszó (tokenizálva, AND kapcsolat) — opcionális
category: Kategória szűrő
city: Város szűrő — AND kapcsolat a q-val
category_ids: Kategória ID-k listája (SZIGORÚ AND szűrés)
limit: Lapozási limit
offset: Lapozási offset
@@ -175,18 +200,42 @@ async def search_providers(
or_(
Organization.name.ilike(f"%{token}%"),
cast(Organization.aliases, Text).ilike(f"%{token}%"),
cast(Organization.tags, Text).ilike(f"%{token}%"),
)
)
org_conditions.append(and_(*token_conditions))
if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
city_clean = city.strip()
org_conditions.append(
or_(
Organization.address_city.ilike(f"%{city}%"),
Organization.address_zip.ilike(f"%{city}%"),
Organization.address_city == city_clean,
Organization.address_city.ilike(f"{city_clean}%"),
Organization.address_zip == city_clean,
Organization.address_zip.ilike(f"{city_clean}%"),
)
)
# FEATURE (2026-06-17): Kategória szűrés category_ids alapján.
# SZIGORÚ AND KAPCSOLAT (2026-06-17): Ha több kategória ID van megadva,
# a szolgáltatónak MINDEGYIKHEZ tartoznia kell. Nincs hierarchikus
# terjeszkedés (path LIKE), csak a pontosan megadott ID-kat ellenőrizzük.
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids None,
# nem iterálunk rajta.
safe_category_ids = category_ids or []
if safe_category_ids:
# Minden egyes megadott category_id-hoz külön subquery-t készítünk,
# és AND kapcsolattal fűzzük össze őket. Ez biztosítja, hogy a
# szolgáltató minden kategóriával rendelkezzen.
for cid in safe_category_ids:
profile_subq = (
select(ServiceExpertise.service_id)
.where(ServiceExpertise.expertise_id == cid)
.subquery()
)
org_conditions.append(
ServiceProfile.id.in_(select(profile_subq.c.service_id))
)
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált
# címmezőket (address_street_name, address_street_type, address_house_number).
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán
@@ -210,6 +259,7 @@ async def search_providers(
Organization.address_street_name.label("address_street_name"),
Organization.address_street_type.label("address_street_type"),
Organization.address_house_number.label("address_house_number"),
Organization.plus_code.label("plus_code"),
Organization.is_verified.label("is_verified"),
ServiceProfile.contact_phone.label("contact_phone"),
ServiceProfile.contact_email.label("contact_email"),
@@ -234,10 +284,14 @@ async def search_providers(
)
staging_conditions.append(and_(*token_conditions))
if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
city_clean = city.strip()
staging_conditions.append(
or_(
ServiceStaging.city.ilike(f"%{city}%"),
ServiceStaging.postal_code.ilike(f"%{city}%"),
ServiceStaging.city == city_clean,
ServiceStaging.city.ilike(f"{city_clean}%"),
ServiceStaging.postal_code == city_clean,
ServiceStaging.postal_code.ilike(f"{city_clean}%"),
)
)
@@ -250,6 +304,7 @@ async def search_providers(
literal(None).label("address_street_name"),
literal(None).label("address_street_type"),
literal(None).label("address_house_number"),
literal(None).label("plus_code"),
literal(False).label("is_verified"),
literal(None).label("contact_phone"),
literal(None).label("contact_email"),
@@ -270,8 +325,13 @@ async def search_providers(
)
crowd_conditions.append(and_(*token_conditions))
if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
city_clean = city.strip()
crowd_conditions.append(
ServiceProvider.address.ilike(f"%{city}%")
or_(
ServiceProvider.address == city_clean,
ServiceProvider.address.ilike(f"{city_clean}%"),
)
)
crowd_stmt = select(
@@ -283,6 +343,7 @@ async def search_providers(
literal(None).label("address_street_name"),
literal(None).label("address_street_type"),
literal(None).label("address_house_number"),
literal(None).label("plus_code"),
literal(False).label("is_verified"),
literal(None).label("contact_phone"),
literal(None).label("contact_email"),
@@ -315,6 +376,43 @@ async def search_providers(
result = await db.execute(paginated_query)
rows = result.fetchall()
# FEATURE (2026-06-17): Kategória adatok előtöltése.
# Lekérdezzük az összes olyan ServiceProfile-hoz tartozó ExpertiseTag-eket,
# amelyek a találati listában szereplő organization ID-khoz tartoznak.
# Ezt egyetlen batch lekérdezéssel tesszük a N+1 probléma elkerülése érdekében.
org_ids = [row.id for row in rows if row.source in ("verified_org", "crowd_added")]
org_categories_map: dict[int, list[dict]] = {}
if org_ids:
cat_stmt = (
select(
ServiceProfile.organization_id,
ExpertiseTag.id,
ExpertiseTag.name_hu,
ExpertiseTag.name_en,
ExpertiseTag.level,
ExpertiseTag.key,
)
.select_from(ServiceExpertise)
.join(ExpertiseTag, ServiceExpertise.expertise_id == ExpertiseTag.id)
.join(ServiceProfile, ServiceExpertise.service_id == ServiceProfile.id)
.where(
ServiceProfile.organization_id.in_(org_ids),
)
)
cat_result = await db.execute(cat_stmt)
cat_rows = cat_result.fetchall()
for cat_row in cat_rows:
org_id = cat_row.organization_id
if org_id not in org_categories_map:
org_categories_map[org_id] = []
org_categories_map[org_id].append({
"id": cat_row.id,
"name_hu": cat_row.name_hu,
"name_en": cat_row.name_en,
"level": cat_row.level,
"key": cat_row.key,
})
for row in rows:
tags_list: list = []
spec_tags = getattr(row, "specialization_tags", None)
@@ -323,6 +421,19 @@ async def search_providers(
if user_tags:
tags_list = list(user_tags)
# Kategória adatok összeállítása
row_categories = org_categories_map.get(row.id, [])
categories_out = [
CategoryInfo(
id=cat["id"],
name_hu=cat["name_hu"],
name_en=cat["name_en"],
level=cat["level"],
key=cat["key"],
)
for cat in row_categories
]
results.append(
ProviderSearchResult(
id=row.id,
@@ -333,10 +444,12 @@ async def search_providers(
address_street_name=getattr(row, "address_street_name", None),
address_street_type=getattr(row, "address_street_type", None),
address_house_number=getattr(row, "address_house_number", None),
plus_code=getattr(row, "plus_code", None),
contact_phone=getattr(row, "contact_phone", None),
contact_email=getattr(row, "contact_email", None),
website=getattr(row, "website", None),
tags=tags_list,
categories=categories_out,
source=row.source,
is_verified=row.is_verified,
)
@@ -364,11 +477,16 @@ async def quick_add_provider(
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
- Ha category_id nincs megadva, a primary_category kihagyásra kerül.
- A 4-level kategória rendszerből érkező category_ids és new_tags
feldolgozásra kerülnek a ServiceExpertise kapcsolatok létrehozásához.
Folyamat:
1. Kategória ellenőrzése (expertise_tags)
1. (Opcionális) Elsődleges kategória ellenőrzése (expertise_tags)
2. Organization létrehozása (is_verified=False, org_type='service_provider')
3. ServiceProfile létrehozása
4. ServiceExpertise kapcsolat létrehozása
4. ServiceExpertise kapcsolatok létrehozása (category_id + category_ids + new_tags)
5. Branch létrehozása a címmel
6. OrganizationMember létrehozása a felhasználóhoz
@@ -383,10 +501,15 @@ async def quick_add_provider(
Raises:
ValueError: Ha a kategória nem létezik
"""
# 1. Kategória ellenőrzése
category = await db.get(ExpertiseTag, data.category_id)
if not category:
raise ValueError(f"Category with id={data.category_id} not found")
# 1. (Opcionális) Elsődleges kategória ellenőrzése
primary_category_key = None
if data.category_id is not None:
category = await db.get(ExpertiseTag, data.category_id)
if not category:
raise ValueError(f"Category with id={data.category_id} not found")
primary_category_key = category.key
else:
category = None
# 2. Organization létrehozása
folder_slug = hashlib.md5(
@@ -414,6 +537,7 @@ async def quick_add_provider(
address_street_name=data.address_street_name,
address_street_type=data.address_street_type,
address_house_number=data.address_house_number,
plus_code=data.plus_code,
owner_id=None,
first_registered_at=datetime.now(timezone.utc),
current_lifecycle_started_at=datetime.now(timezone.utc),
@@ -428,7 +552,9 @@ async def quick_add_provider(
).hexdigest()[:64]
# specialization_tags összeállítása: primary_category + user-supplied tags
spec_tags = {"primary_category": category.key}
spec_tags: dict = {}
if primary_category_key:
spec_tags["primary_category"] = primary_category_key
if data.tags:
spec_tags["user_tags"] = data.tags
@@ -445,13 +571,49 @@ async def quick_add_provider(
db.add(profile)
await db.flush()
# 4. ServiceExpertise kapcsolat
expertise = ServiceExpertise(
service_id=profile.id,
expertise_id=data.category_id,
confidence_level=50, # Közepes bizonyosság (crowdsourced)
)
db.add(expertise)
# =====================================================================
# 4. 🏗️ ServiceExpertise kapcsolatok létrehozása (4-LEVEL CATEGORY)
# =====================================================================
# Összegyűjtjük az összes kategória ID-t a ServiceExpertise kapcsolatokhoz.
all_category_ids: List[int] = []
# 4a. Elsődleges kategória (ha meg van adva)
if data.category_id is not None:
all_category_ids.append(data.category_id)
# 4b. category_ids a 4-level checkboxokból
safe_category_ids = data.category_ids or []
if safe_category_ids:
all_category_ids.extend(safe_category_ids)
# 4c. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
safe_new_tags = data.new_tags or []
new_tag_ids: List[int] = []
if safe_new_tags:
new_tag_ids = await _create_new_tags(
db=db,
service_profile_id=profile.id,
new_tag_names=safe_new_tags,
)
if new_tag_ids:
all_category_ids.extend(new_tag_ids)
# 4d. ServiceExpertise kapcsolatok létrehozása
if all_category_ids:
# Deduplikáció: csak egyedi ID-k
unique_ids = list(set(all_category_ids))
for expertise_id in unique_ids:
expertise = ServiceExpertise(
service_id=profile.id,
expertise_id=expertise_id,
confidence_level=50, # Közepes bizonyosság (crowdsourced)
)
db.add(expertise)
logger.info(
f"ServiceExpertise kapcsolatok létrehozva: service_id={profile.id}, "
f"expertise_ids={unique_ids}"
)
# =====================================================================
# 5. Branch létrehozása atomizált címmezőkkel
branch = Branch(
@@ -467,6 +629,28 @@ async def quick_add_provider(
)
db.add(branch)
# =====================================================================
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (P0 CRITICAL BUGFIX 2026-06-17)
# =====================================================================
# A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER).
# Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL).
# Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég
# nem jelenik meg a "Cégeim" garázsválasztóban.
member = OrganizationMember(
organization_id=org.id,
user_id=user_id,
role=OrgUserRole.ADMIN,
is_permanent=True,
is_verified=True,
)
db.add(member)
await db.flush()
logger.info(
f"OrganizationMember created: org_id={org.id}, user_id={user_id}, "
f"role=ADMIN (crowdsourced provider has no owner)"
)
# =====================================================================
# =====================================================================
# 7. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
# =====================================================================
@@ -494,6 +678,173 @@ async def quick_add_provider(
)
async def _sync_service_expertises(
db: AsyncSession,
service_profile_id: int,
category_ids: List[int],
) -> None:
"""
Szinkronizálja a ServiceExpertise kapcsolatokat a megadott kategória ID-k alapján.
4-Level Category Hybrid Save (2026-06-17):
- Eltávolítja a meglévő kapcsolatokat, amelyek nincsenek az új listában
- Hozzáadja az új kapcsolatokat, amelyek még nem léteznek
Args:
db: AsyncSession
service_profile_id: A ServiceProfile ID-ja
category_ids: Az új kategória ID-k listája
"""
if not category_ids:
return
# Meglévő kapcsolatok lekérése
existing_stmt = select(ServiceExpertise).where(
ServiceExpertise.service_id == service_profile_id
)
existing_result = await db.execute(existing_stmt)
existing_expertises = existing_result.scalars().all()
existing_ids = {e.expertise_id for e in existing_expertises}
# Eltávolítandó kapcsolatok (amik nincsenek az új listában)
ids_to_remove = existing_ids - set(category_ids)
if ids_to_remove:
delete_stmt = delete(ServiceExpertise).where(
ServiceExpertise.service_id == service_profile_id,
ServiceExpertise.expertise_id.in_(ids_to_remove),
)
await db.execute(delete_stmt)
logger.info(
f"Eltávolított ServiceExpertise kapcsolatok: service_id={service_profile_id}, "
f"expertise_ids={ids_to_remove}"
)
# Hozzáadandó kapcsolatok (amik újak)
ids_to_add = set(category_ids) - existing_ids
for expertise_id in ids_to_add:
new_expertise = ServiceExpertise(
service_id=service_profile_id,
expertise_id=expertise_id,
confidence_level=50,
)
db.add(new_expertise)
logger.info(
f"Új ServiceExpertise kapcsolat: service_id={service_profile_id}, "
f"expertise_id={expertise_id}"
)
def _slugify(text: str) -> str:
"""
Biztonságos slug létrehozása magyar ékezetes karakterekkel.
- Kisbetűsítés
- Ékezetes karakterek cseréje (á→a, é→e, í→i, ó→o, ö→o, ő→o, ú→u, ü→u, ű→u)
- Nem alfanumerikus karakterek cseréje alulvonásra
- Maximum 50 karakter
"""
replacements = {
'á': 'a', 'é': 'e', 'í': 'i', 'ó': 'o', 'ö': 'o', 'ő': 'o',
'ú': 'u', 'ü': 'u', 'ű': 'u',
'Á': 'a', 'É': 'e', 'Í': 'i', 'Ó': 'o', 'Ö': 'o', 'Ő': 'o',
'Ú': 'u', 'Ü': 'u', 'Ű': 'u',
}
result = text.lower().strip()
for hungarian, ascii_char in replacements.items():
result = result.replace(hungarian, ascii_char)
# Replace any non-alphanumeric character (except underscore) with underscore
result = re.sub(r'[^a-z0-9_]', '_', result)
# Collapse multiple underscores
result = re.sub(r'_+', '_', result)
# Strip leading/trailing underscores
result = result.strip('_')
return result[:50]
async def _create_new_tags(
db: AsyncSession,
service_profile_id: int,
new_tag_names: List[str],
) -> List[int]:
"""
Létrehoz új ExpertiseTag rekordokat a user által gépelt címkékből.
P0 CRITICAL BUGFIX (2026-06-17): try-except blokk + slugify + hibanaplózás.
4-Level Category Hybrid Save (2026-06-17):
- Ha a címke string már létezik az ExpertiseTag táblában, visszaadja az ID-ját
- Ha a címke vadiúj, létrehozza is_official=False és level=3 értékekkel
Args:
db: AsyncSession
service_profile_id: A ServiceProfile ID-ja (a kapcsolathoz)
new_tag_names: A user által gépelt új címkenevek listája
Returns:
List[int]: A létrehozott/megtalált ExpertiseTag ID-k listája
"""
if not new_tag_names:
return []
created_ids = []
for tag_name in new_tag_names:
tag_name = tag_name.strip()
if not tag_name:
continue
try:
# Ellenőrizzük, hogy létezik-e már
existing_stmt = select(ExpertiseTag).where(
or_(
ExpertiseTag.key == _slugify(tag_name),
ExpertiseTag.name_hu == tag_name,
ExpertiseTag.name_en == tag_name,
)
)
existing_result = await db.execute(existing_stmt)
existing_tag = existing_result.scalar_one_or_none()
if existing_tag:
# Már létezik, használjuk a meglévő ID-t
created_ids.append(existing_tag.id)
logger.info(
f"Meglévő címke használata: tag_name={tag_name}, "
f"existing_id={existing_tag.id}"
)
else:
# Vadiúj címke létrehozása slugify-jal
new_key = _slugify(tag_name)
new_tag = ExpertiseTag(
key=new_key,
name_hu=tag_name,
name_en=tag_name,
level=3,
is_official=False,
category="user_created",
parent_id=None,
path=None,
search_keywords=[tag_name.lower()],
description=f"User-created tag: {tag_name}",
)
db.add(new_tag)
await db.flush()
created_ids.append(new_tag.id)
logger.info(
f"Új címke létrehozva: tag_name={tag_name}, "
f"new_id={new_tag.id}, key={new_key}"
)
except Exception as e:
logger.error(
f"HIBA az új címke létrehozásakor: tag_name={tag_name}, "
f"error={e}", exc_info=True
)
# Nem szakítjuk meg a teljes folyamatot egyetlen hibás címke miatt
continue
return created_ids
async def update_provider(
db: AsyncSession,
provider_id: int,
@@ -507,6 +858,11 @@ async def update_provider(
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
4-Level Category Hybrid Save (2026-06-17):
- category_ids: ServiceExpertise kapcsolatok frissítése
- new_tags: Új ExpertiseTag létrehozása (is_official=False, level=3)
majd ServiceExpertise kapcsolat létrehozása
Multi-source update (2026-06-17): Támogatja mindhárom forrást:
1. fleet.organizations (verified orgs) - közvetlen frissítés
2. marketplace.service_staging (robot adatok) - migrálás Organization-be
@@ -527,15 +883,43 @@ async def update_provider(
Raises:
ValueError: Ha a szolgáltató nem található
PermissionError: Ha a felhasználó nem tagja a szervezetnek
"""
# 1. Organization lekérdezése
org = await db.get(Organization, provider_id)
if org:
# =====================================================================
# 1a. 🔐 ACCESS CONTROL ELLENŐRZÉS (P0 CRITICAL BUGFIX 2026-06-17)
# =====================================================================
# Csak azok a felhasználók szerkeszthetik a szolgáltatót, akik
# OWNER vagy ADMIN szerepkörű tagok a szervezetben.
member_stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org.id,
OrganizationMember.user_id == user_id,
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN]),
)
member_result = await db.execute(member_stmt)
member = member_result.scalar_one_or_none()
if not member:
logger.warning(
f"Access denied: user_id={user_id} attempted to update "
f"org_id={org.id} but is not an OWNER or ADMIN member"
)
raise PermissionError(
f"Nincs jogosultságod szerkeszteni ezt a szolgáltatót. "
f"Csak a szervezet tulajdonosa vagy adminisztrátora "
f"módosíthatja az adatokat."
)
# =====================================================================
if not org:
# 1b. Ha nincs Organization-ben, ServiceStaging-ben keresünk
staging = await db.get(ServiceStaging, provider_id)
if staging:
# Migráljuk Organization-be
now = datetime.now(timezone.utc)
org = Organization(
id=staging.id,
name=staging.name[:100],
@@ -549,7 +933,16 @@ async def update_provider(
address_house_number=data.address_house_number,
status="pending_verification",
is_verified=False,
folder_slug=f"sp-{staging.id}-{uuid.uuid4().hex[:6]}",
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
first_registered_at=now,
current_lifecycle_started_at=now,
created_at=now,
subscription_plan="FREE",
base_asset_limit=1,
purchased_extra_slots=0,
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
external_integration_config={},
is_ownership_transferable=True,
)
db.add(org)
await db.flush()
@@ -562,6 +955,7 @@ async def update_provider(
crowd = await db.get(ServiceProvider, provider_id)
if crowd:
# Migráljuk Organization-be
now = datetime.now(timezone.utc)
org = Organization(
id=crowd.id,
name=crowd.name[:100],
@@ -575,7 +969,16 @@ async def update_provider(
address_house_number=data.address_house_number,
status="pending_verification",
is_verified=False,
folder_slug=f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}",
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
first_registered_at=now,
current_lifecycle_started_at=now,
created_at=now,
subscription_plan="FREE",
base_asset_limit=1,
purchased_extra_slots=0,
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
external_integration_config={},
is_ownership_transferable=True,
)
db.add(org)
await db.flush()
@@ -605,6 +1008,8 @@ async def update_provider(
org.address_street_type = data.address_street_type
if data.address_house_number is not None:
org.address_house_number = data.address_house_number
if data.plus_code is not None:
org.plus_code = data.plus_code
# 3. ServiceProfile lekérdezése (ha létezik)
profile_stmt = select(ServiceProfile).where(
@@ -625,26 +1030,156 @@ async def update_provider(
spec_tags["user_tags"] = data.tags
profile.specialization_tags = spec_tags
# =====================================================================
# 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17)
# =====================================================================
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy
# new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et.
# Összegyűjtjük az összes kategória ID-t (meglévő + új)
all_category_ids: List[int] = []
# 5a. category_ids -> ServiceExpertise kapcsolatok frissítése
safe_category_ids = data.category_ids or []
if safe_category_ids:
all_category_ids.extend(safe_category_ids)
# 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
safe_new_tags = data.new_tags or []
if safe_new_tags:
new_tag_ids = await _create_new_tags(
db=db,
service_profile_id=profile.id,
new_tag_names=safe_new_tags,
)
if new_tag_ids:
all_category_ids.extend(new_tag_ids)
# 5c. Egyszeri szinkronizálás az összes kategória ID-val
if all_category_ids:
await _sync_service_expertises(
db=db,
service_profile_id=profile.id,
category_ids=all_category_ids,
)
# =====================================================================
logger.info(
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
f"website={data.website}, tags={data.tags}"
f"website={data.website}, tags={data.tags}, "
f"category_ids={data.category_ids}, new_tags={data.new_tags}"
)
else:
# P0 CRITICAL BUGFIX (2026-06-17): Ha nincs ServiceProfile, létrehozzuk.
# Ez biztosítja, hogy a contact_phone, contact_email, website, tags
# és category_ids adatok mentésre kerüljenek, még akkor is, ha a
# szolgáltató korábban csak Organization-ként létezett.
logger.warning(
f"Provider update: org_id={org.id} has no ServiceProfile. "
f"Only Organization fields updated."
f"Creating one now."
)
fingerprint = hashlib.sha256(
f"update-create-{org.id}-{datetime.now().isoformat()}".encode()
).hexdigest()[:64]
profile = ServiceProfile(
organization_id=org.id,
fingerprint=fingerprint,
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
contact_phone=data.contact_phone,
contact_email=data.contact_email,
website=data.website,
specialization_tags={"user_tags": data.tags} if data.tags else {},
status=ServiceStatus.active,
)
db.add(profile)
await db.flush()
logger.info(
f"ServiceProfile created for org_id={org.id}, profile_id={profile.id}"
)
# 4. ServiceProfile mezők frissítése
profile.contact_phone = data.contact_phone
profile.contact_email = data.contact_email
profile.website = data.website
# specialization_tags frissítése
if data.tags is not None:
spec_tags = profile.specialization_tags or {}
spec_tags["user_tags"] = data.tags
profile.specialization_tags = spec_tags
# =====================================================================
# 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17)
# =====================================================================
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy
# new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et.
# Összegyűjtjük az összes kategória ID-t (meglévő + új)
all_category_ids: List[int] = []
# 5a. category_ids -> ServiceExpertise kapcsolatok frissítése
safe_category_ids = data.category_ids or []
if safe_category_ids:
all_category_ids.extend(safe_category_ids)
# 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
safe_new_tags = data.new_tags or []
if safe_new_tags:
new_tag_ids = await _create_new_tags(
db=db,
service_profile_id=profile.id,
new_tag_names=safe_new_tags,
)
if new_tag_ids:
all_category_ids.extend(new_tag_ids)
# 5c. Egyszeri szinkronizálás az összes kategória ID-val
if all_category_ids:
await _sync_service_expertises(
db=db,
service_profile_id=profile.id,
category_ids=all_category_ids,
)
# =====================================================================
logger.info(
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
f"website={data.website}, tags={data.tags}, "
f"category_ids={data.category_ids}, new_tags={data.new_tags}"
)
# =====================================================================
# 6. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás szerkesztésért)
# =====================================================================
# A felhasználó aktuális penalty_level-jétől (restriction_level) függően
# kalkuláljuk a kapott pontot. A GamificationService.process_activity()
# automatikusan alkalmazza a büntetési szorzót a _calculate_multiplier-en
# keresztül, így:
# - Szint 0 (L0) = 100% pont
# - Szint 1 (L1) = 50% pont
# - Szint 2 (L2) = 10% pont
# - Szint 3 (L3) = 0% pont (blokkolt)
earned_points = await _award_provider_points(
db=db,
user_id=user_id,
action_key="UPDATE_PROVIDER",
)
# =====================================================================
await db.commit()
logger.info(
f"Provider updated successfully: org_id={org.id}, "
f"name={data.name}, user_id={user_id}"
f"name={data.name}, user_id={user_id}, earned_points={earned_points}"
)
return ProviderUpdateResponse(
id=org.id,
name=data.name,
message="Szolgáltató adatai sikeresen frissítve.",
earned_points=earned_points,
)

File diff suppressed because it is too large Load Diff

View File

@@ -34,5 +34,43 @@
"BUTTON": "Go to Dashboard",
"FOOTER": "Service Finder - Test system message"
}
},
"ORGANIZATION": {
"ERROR": {
"NOT_FOUND": "Organization not found.",
"INVALID_TAX_FORMAT": "Invalid Hungarian tax number format!",
"TAX_LOOKUP_FAILED": "Tax number lookup failed.",
"FORBIDDEN": "You do not have permission to access this organization.",
"INVALID_ROLE": "Invalid role specified.",
"CANNOT_REMOVE_OWNER": "The owner cannot be removed from the organization.",
"CANNOT_DEMOTE_OWNER": "The owner's role cannot be changed.",
"ALREADY_MEMBER": "You are already a member of this organization.",
"NO_ACTIVE_ADMIN": "This organization has no active admin. Use the /claim endpoint.",
"HAS_ACTIVE_ADMIN": "This organization has an active admin. Use the /join-request endpoint.",
"EMAIL_MISMATCH": "The email does not match the organization owner's email.",
"INVALID_CODE": "Invalid or expired code.",
"CODE_ORG_MISMATCH": "The code does not belong to this organization.",
"INVITE_EXPIRED": "The invitation has expired.",
"INVITE_INVALID": "Invalid invitation token."
},
"SUCCESS": {
"ONBOARDED": "Company successfully created and registered.",
"MEMBER_REMOVED": "Member successfully removed.",
"ROLE_UPDATED": "Role successfully updated.",
"UPDATED": "Organization data successfully updated.",
"VISUAL_SETTINGS_UPDATED": "Visual settings successfully updated."
},
"INVITATION": {
"CREATED": "Invitation created successfully.",
"ACCEPTED": "Invitation accepted.",
"REVOKED": "Invitation revoked."
},
"JOIN_REQUEST": {
"SENT": "Your join request has been submitted. Waiting for admin approval."
},
"CLAIM": {
"OTP_SENT": "A 6-digit verification code has been sent to the organization owner's email.",
"SUCCESS": "You have successfully taken over the organization. You are now the admin."
}
}
}

View File

@@ -41,5 +41,43 @@
},
"COMMON": {
"SAVE_SUCCESS": "Sikeres mentés!"
},
"ORGANIZATION": {
"ERROR": {
"NOT_FOUND": "Szervezet nem található.",
"INVALID_TAX_FORMAT": "Érvénytelen magyar adószám formátum!",
"TAX_LOOKUP_FAILED": "Adószám lekérdezés sikertelen.",
"FORBIDDEN": "Nincs jogosultságod ehhez a szervezethez.",
"INVALID_ROLE": "Érvénytelen szerepkör.",
"CANNOT_REMOVE_OWNER": "A tulajdonos nem távolítható el a szervezetből.",
"CANNOT_DEMOTE_OWNER": "A tulajdonos szerepköre nem módosítható.",
"ALREADY_MEMBER": "Már tagja vagy ennek a szervezetnek.",
"NO_ACTIVE_ADMIN": "A szervezetnek nincs aktív adminja. Használd a /claim végpontot.",
"HAS_ACTIVE_ADMIN": "A szervezetnek van aktív adminja. Használd a /join-request végpontot.",
"EMAIL_MISMATCH": "Az email cím nem egyezik a cég tulajdonosának email címével.",
"INVALID_CODE": "Érvénytelen vagy lejárt kód.",
"CODE_ORG_MISMATCH": "A kód nem ehhez a szervezethez tartozik.",
"INVITE_EXPIRED": "A meghívó lejárt.",
"INVITE_INVALID": "Érvénytelen meghívó token."
},
"SUCCESS": {
"ONBOARDED": "Cég sikeresen létrehozva és regisztrálva.",
"MEMBER_REMOVED": "Tag sikeresen eltávolítva.",
"ROLE_UPDATED": "Szerepkör sikeresen frissítve.",
"UPDATED": "Szervezet adatai sikeresen frissítve.",
"VISUAL_SETTINGS_UPDATED": "Vizuális beállítások sikeresen frissítve."
},
"INVITATION": {
"CREATED": "Meghívó sikeresen létrehozva.",
"ACCEPTED": "Meghívó elfogadva.",
"REVOKED": "Meghívó visszavonva."
},
"JOIN_REQUEST": {
"SENT": "Csatlakozási kérelmed rögzítettük. Az admin jóváhagyására vársz."
},
"CLAIM": {
"OTP_SENT": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.",
"SUCCESS": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin."
}
}
}

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) ──
// Also exclude service_provider and service types — those belong to the
// Marketplace / Service Finder domain, not the user's own companies.
const companyOrganizations = computed(() => {
return authStore.myOrganizations.filter(
(org) => org.org_type && org.org_type !== 'individual'
(org) => org.org_type &&
org.org_type !== 'individual' &&
org.org_type !== 'service_provider' &&
org.org_type !== 'service'
)
})

View File

@@ -17,7 +17,7 @@
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-800">{{ t('serviceFinder.detailTitle') }}</h3>
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.detailTitle') }}</h3>
</div>
</div>
<button
@@ -33,116 +33,149 @@
<!-- Body -->
<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>
<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>
<!-- Category -->
<div v-if="provider.category" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailCategory') }}</p>
<p class="text-sm font-medium text-slate-800">{{ provider.category }}</p>
</div>
</div>
<!-- Address (P1 CRITICAL ALIGN: atomizált címmezők intelligens összefűzése) -->
<div v-if="hasAddress" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailAddress') }}</p>
<p class="text-sm font-medium text-slate-800">
{{ formattedAddress }}
</p>
</div>
</div>
<!-- Source -->
<div class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSource') }}</p>
<p class="text-sm font-medium text-slate-800">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="sourceBadgeClass"
>
{{ sourceLabel }}
</span>
</p>
</div>
</div>
<!-- Specialization -->
<div v-if="provider.specialization && provider.specialization.length > 0" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailSpecialization') }}</p>
<div class="flex flex-wrap gap-1.5 mt-1">
<span
v-for="(tag, idx) in provider.specialization"
:key="idx"
class="inline-flex items-center rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
</span>
<!-- Contact Grid -->
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailContact') }}</p>
<div class="grid grid-cols-1 gap-2">
<!-- Phone -->
<a
v-if="provider.contact_phone"
:href="'tel:' + provider.contact_phone"
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<span>{{ provider.contact_phone }}</span>
</a>
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<span class="italic">{{ t('provider.detailNoPhone') }}</span>
</div>
</div>
</div>
<!-- Rating -->
<div v-if="provider.rating" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
<div class="flex items-center gap-1 mt-0.5">
<svg
v-for="star in 5"
:key="star"
class="h-4 w-4"
:class="star <= Math.round(provider.rating!) ? 'text-amber-400' : 'text-slate-200'"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
<!-- Email -->
<a
v-if="provider.contact_email"
:href="'mailto:' + provider.contact_email"
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<span class="truncate">{{ provider.contact_email }}</span>
</a>
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<span class="italic">{{ t('provider.detailNoEmail') }}</span>
</div>
<!-- Website -->
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<span class="truncate">{{ provider.website }}</span>
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<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>
<!-- No rating fallback -->
<div v-if="!provider.rating" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('serviceFinder.detailRating') }}</p>
<p class="text-sm text-slate-400 italic">{{ t('serviceFinder.detailNoRating') }}</p>
<!-- Location & Plus Code -->
<div v-if="hasAddress || provider.plus_code">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailLocation') }}</p>
<div class="rounded-xl bg-slate-50 p-4 space-y-3">
<!-- Address -->
<div v-if="hasAddress" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<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>
@@ -169,20 +202,30 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { t, locale } = useI18n()
// ── Types matching backend ProviderSearchResult ──
interface CategoryInfo {
id: number
name_hu: string | null
name_en: string | null
level: number
key: string
}
interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
categories: CategoryInfo[]
city: string | null
address: string | null
address_zip: string | null
address_street_name: string | null
address_street_type: string | null
address_house_number: string | null
plus_code: string | null
contact_phone: string | null
contact_email: string | null
website: string | null
@@ -223,10 +266,32 @@ const sourceLabel = computed(() => {
}
})
/**
* Category badge color based on level.
* Level 0 = Vehicle Type (purple), Level 1 = Industry (blue),
* Level 2 = Profession (teal), Level 3 = Specific Tag (slate).
*/
function categoryBadgeClass(level: number): string {
switch (level) {
case 0: return 'bg-purple-100 text-purple-700'
case 1: return 'bg-blue-100 text-blue-700'
case 2: return 'bg-teal-100 text-teal-700'
case 3: return 'bg-slate-100 text-slate-600'
default: return 'bg-slate-100 text-slate-600'
}
}
/**
* Get the localized category name based on current locale.
*/
function categoryName(cat: CategoryInfo): string {
if (locale.value === 'hu' && cat.name_hu) return cat.name_hu
if (cat.name_en) return cat.name_en
return cat.key
}
/**
* P1 CRITICAL ALIGN: Atomizált címmezők intelligens összefűzése.
* A provider.address_street_name + ' ' + provider.address_street_type + ' ' + provider.address_house_number
* Ha a city is meg van adva, akkor "city, street_name street_type house_number" formátumban jelenik meg.
*/
const hasAddress = computed(() => {
const p = props.provider
@@ -258,6 +323,31 @@ const formattedAddress = computed(() => {
return parts.join(', ')
})
/**
* Google Maps URL: prefer plus_code, fall back to address query.
*/
const googleMapsUrl = computed(() => {
const p = props.provider
if (!p) return 'https://maps.google.com'
if (p.plus_code) {
return `https://maps.google.com/maps?q=${encodeURIComponent(p.plus_code)}`
}
const queryParts: string[] = []
if (p.address_street_name) queryParts.push(p.address_street_name)
if (p.address_street_type) queryParts.push(p.address_street_type)
if (p.address_house_number) queryParts.push(p.address_house_number)
if (p.city) queryParts.push(p.city)
if (p.address_zip) queryParts.push(p.address_zip)
if (queryParts.length > 0) {
return `https://maps.google.com/maps?q=${encodeURIComponent(queryParts.join(' '))}`
}
return `https://maps.google.com/maps?q=${encodeURIComponent(p.name)}`
})
</script>
<style scoped>

View File

@@ -6,10 +6,11 @@
@click.self="emit('close')"
>
<div
class="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
class="relative w-full max-w-lg mx-4 flex flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl animate-fade-in-up"
style="max-height: 85vh;"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 shrink-0">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -32,178 +33,395 @@
</button>
</div>
<!-- Form Body -->
<div class="p-6 space-y-5">
<!-- Name -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
</label>
<input
v-model="form.name"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.namePlaceholder')"
/>
</div>
<!-- Tab Bar -->
<div class="flex border-b border-slate-200 px-6 shrink-0">
<button
@click="activeTab = 'basic'"
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
:class="activeTab === 'basic'
? 'border-sf-accent text-sf-accent'
: 'border-transparent text-slate-500 hover:text-slate-700'"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{{ t('provider.tabBasic') }}
</button>
<button
@click="activeTab = 'services'"
class="inline-flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all cursor-pointer"
:class="activeTab === 'services'
? 'border-sf-accent text-sf-accent'
: 'border-transparent text-slate-500 hover:text-slate-700'"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
{{ t('provider.tabServices') }}
</button>
</div>
<!-- Atomizált címmezők (P1 CRITICAL ALIGN) -->
<!-- Street Name -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.streetNameLabel') }}
</label>
<input
v-model="form.address_street_name"
type="text"
maxlength="150"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.streetNamePlaceholder')"
/>
</div>
<!-- Street Type + House Number row -->
<div class="grid grid-cols-2 gap-4">
<!-- Scrollable Body -->
<div class="overflow-y-auto p-6 space-y-5" style="max-height: calc(85vh - 140px);">
<!-- -->
<!-- TAB 1: Alapadatok (Basic Data) -->
<!-- -->
<template v-if="activeTab === 'basic'">
<!-- Name -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ 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') }}
{{ t('provider.nameLabel') }} <span class="text-red-500">*</span>
</label>
<input
v-model="form.address_house_number"
v-model="form.name"
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')"
:placeholder="t('provider.namePlaceholder')"
/>
</div>
</div>
<!-- City + ZIP row -->
<div class="grid grid-cols-2 gap-4">
<!-- Atomizált címmezők (P1 CRITICAL ALIGN) -->
<div class="border-t border-slate-100 pt-4">
<p class="mb-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">{{ t('provider.addressSection') }}</p>
<!-- Street Name -->
<div 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>
<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 class="flex items-center gap-2 mb-3">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/10 text-sf-accent">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_a_title') }}</span>
</div>
<!-- Loading state -->
<div v-if="categoryTreeLoading" class="flex items-center gap-2 text-sm text-slate-400 py-2">
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ t('common.loading') }}
</div>
<!-- Level 0: Járműtípus (always visible) -->
<div v-if="level0Categories.length > 0" class="mb-3">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.level0Label') }}</p>
<div class="flex flex-wrap gap-2">
<label
v-for="cat in level0Categories"
:key="cat.id"
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-all hover:border-sf-accent/40 hover:bg-sf-accent/5 cursor-pointer"
:class="{ 'border-sf-accent bg-sf-accent/10 text-sf-accent': selectedCategoryIds.includes(cat.id) }"
>
<input
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>
<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')"
/>
<!-- -->
<!-- BLOCK B: Okos Szövegmező (Szint 2 + Szint 3) -->
<!-- -->
<div class="border-t border-slate-100 pt-4">
<div class="flex items-center gap-2 mb-3">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-blue-50 text-blue-500">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<span class="text-sm font-bold text-slate-700">{{ t('categories.block_b_title') }}</span>
</div>
<!-- Autocomplete input -->
<div class="relative">
<input
v-model="autocompleteQuery"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('categories.search_placeholder')"
@input="onAutocompleteInput"
@keydown.enter.prevent="onAutocompleteEnter"
@keydown.backspace="onAutocompleteBackspace"
@blur="onAutocompleteBlur"
/>
<!-- Autocomplete dropdown -->
<div
v-if="autocompleteQuery.length >= 2"
class="absolute z-10 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
>
<!-- Results list -->
<button
v-for="item in autocompleteResults"
:key="item.id"
@mousedown.prevent="selectAutocompleteItem(item)"
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 transition-colors hover:bg-sf-accent/5 hover:text-sf-accent flex items-center gap-2"
>
<span class="text-xs text-slate-400 font-mono">L{{ item.level }}</span>
<span>{{ item.name_hu || item.key }}</span>
</button>
<!-- No results message -->
<div
v-if="autocompleteResults.length === 0"
class="px-4 py-3 text-sm text-slate-400 text-center"
>
{{ t('categories.no_results_create') }}
</div>
</div>
</div>
<!-- Selected tags (chips) -->
<div v-if="selectedTags.length > 0" class="mt-3 flex flex-wrap gap-1.5">
<span
v-for="(tag, idx) in selectedTags"
:key="'tag-' + idx"
class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 border border-blue-100"
>
{{ tag.name_hu || tag.key }}
<button
@click="removeTag(idx)"
class="ml-0.5 text-blue-400 hover:text-red-500 cursor-pointer"
>&times;</button>
</span>
</div>
</div>
</div>
<!-- Phone -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.phoneLabel') }}
</label>
<input
v-model="form.phone"
type="tel"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.phonePlaceholder')"
/>
</div>
<!-- Gamification Toast -->
<div
v-if="gamificationToast"
class="flex items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium shadow-sm transition-all duration-300"
:class="gamificationToast.type === 'success'
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-amber-50 text-amber-700 border border-amber-200'"
>
<svg v-if="gamificationToast.type === 'success'" class="h-5 w-5 shrink-0 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="h-5 w-5 shrink-0 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<span>{{ gamificationToast.message }}</span>
</div>
<!-- Email -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.emailLabel') }}
</label>
<input
v-model="form.email"
type="email"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.emailPlaceholder')"
/>
</div>
<!-- Divider -->
<hr class="border-slate-200" />
<!-- Website -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.websiteLabel') }}
</label>
<input
v-model="form.website"
type="url"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.websitePlaceholder')"
/>
</div>
<!-- Tags (comma-separated) -->
<div>
<label class="mb-1 block text-xs font-semibold text-slate-500 uppercase tracking-wider">
{{ t('provider.tagsLabel') }}
</label>
<input
v-model="form.tagsInput"
type="text"
class="w-full rounded-xl border border-slate-300 px-4 py-2.5 text-sm text-slate-800 placeholder-slate-400 transition-all focus:border-sf-accent focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
:placeholder="t('provider.tagsPlaceholder')"
/>
<p v-if="form.tags.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="(tag, idx) in form.tags"
:key="idx"
class="inline-flex items-center gap-1 rounded-full bg-sf-accent/10 px-2.5 py-0.5 text-xs font-medium text-sf-accent"
>
{{ tag }}
<button
@click="removeTag(idx)"
class="ml-0.5 text-sf-accent/60 hover:text-red-500 cursor-pointer"
>&times;</button>
</span>
<!-- Info text -->
<p class="text-xs text-slate-400 italic">
{{ t('provider.editInfo') }}
</p>
</div>
</template>
</div>
<!-- Divider -->
<hr class="border-slate-200" />
<!-- Info text -->
<p class="text-xs text-slate-400 italic">
{{ t('provider.editInfo') }}
</p>
<!-- Action buttons -->
<!-- Sticky Footer (Save / Cancel) -->
<div class="shrink-0 border-t border-slate-200 bg-white px-6 py-4 rounded-b-2xl">
<div class="flex gap-3">
<button
@click="emit('close')"
@@ -241,13 +459,28 @@
* address_street_name, address_street_type, address_house_number
* - A payload pontosan az új, atomizált kulcsokkal megy a backend felé.
*
* UX OVERHAUL (2026-06-17):
* - Tab 1: Alapadatok (Név, Cím 3 bontásban, Elérhetőségek)
* - Tab 2: Szolgáltatások (Kategória fa + Autocomplete)
* - Görgethető body (overflow-y-auto, max-height: 85vh)
* - Sticky footer a Mentés/Mégse gombokkal
* - Okos Kategória Fa: csak a kipipált Szint 0 gyerekei jelennek meg
*
* 4-Level Category Hybrid UI (2026-06-17):
* ==========================================
* BLOCK A (Fix Struktúra): Szint 0 (Járműtípus) + Szint 1 (Iparág) checkboxok
* - Okos: Szint 1 csak akkor jelenik meg, ha a Szint 0 ki van pipálva
* BLOCK B (Okos Szövegmező): Szint 2 + Szint 3 autocomplete + chip tags
* - Ha a gépelt szó létezik → legördül és kiválasztható
* - Ha nem létezik → Enter → új chip címke (new_tags)
*
* @emits close — modal bezárása
* @emits saved — sikeres mentés után, payload: { id: number }
*
* @see backend/app/api/v1/endpoints/providers.py — PUT /providers/{id}
* @see frontend/src/components/provider/ProviderDetailModal.vue — hívó
*/
import { ref, reactive, watch } from 'vue'
import { ref, reactive, watch, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/axios'
@@ -274,6 +507,34 @@ interface ProviderSearchResult {
rating: number | null
}
interface CategoryTreeNode {
id: number
key: string
name_hu: string | null
name_en: string | null
level: number
path: string | null
is_official: boolean
children: CategoryTreeNode[]
}
interface AutocompleteItem {
id: number
key: string
name_hu: string | null
name_en: string | null
level: number
path: string | null
parent_id: number | null
}
interface SelectedTag {
id: number | null // null for user-created tags (not yet in DB)
key: string
name_hu: string | null
level: number
}
// ── Props ──
const props = defineProps<{
isOpen: boolean
@@ -286,9 +547,15 @@ const emit = defineEmits<{
(e: 'saved', payload: { id: number }): void
}>()
// ── Tab State ──
const activeTab = ref<'basic' | 'services'>('basic')
// ── Form State ──
const isSaving = ref(false)
// ── Gamification Toast ──
const gamificationToast = ref<{ type: 'success' | 'penalty'; message: string } | null>(null)
interface FormState {
name: string
city: string
@@ -296,11 +563,10 @@ interface FormState {
address_street_name: string
address_street_type: string
address_house_number: string
plus_code: string
phone: string
email: string
website: string
tagsInput: string
tags: string[]
}
const form = reactive<FormState>({
@@ -310,11 +576,55 @@ const form = reactive<FormState>({
address_street_name: '',
address_street_type: '',
address_house_number: '',
plus_code: '',
phone: '',
email: '',
website: '',
tagsInput: '',
tags: [],
})
// ── Category State (4-Level) ──
const categoryTreeLoading = ref(false)
const categoryTree = ref<CategoryTreeNode[]>([])
const selectedCategoryIds = ref<number[]>([])
const selectedTags = ref<SelectedTag[]>([])
const autocompleteQuery = ref('')
const autocompleteResults = ref<AutocompleteItem[]>([])
let autocompleteTimer: ReturnType<typeof setTimeout> | null = null
// ── Computed: Level 0 categories ──
const level0Categories = computed(() => {
return categoryTree.value.filter(c => c.level === 0)
})
// ── Computed: Vehicle-specific Level 1 categories ──
// Shows Level 1 children of CHECKED Level 0 parents only.
const vehicleLevel1Categories = computed(() => {
const checkedL0Ids = new Set(selectedCategoryIds.value)
const result: CategoryTreeNode[] = []
for (const l0 of level0Categories.value) {
if (l0.children.length > 0 && checkedL0Ids.has(l0.id)) {
for (const l1 of l0.children) {
// Avoid duplicates if same L1 appears under multiple L0
if (!result.some(r => r.id === l1.id)) {
result.push(l1)
}
}
}
}
return result
})
// ── Computed: Universal Level 1 categories ──
// Level 1 categories that are root nodes (parent_id IS NULL) in the tree.
// These are independent of any vehicle type and always visible.
// Examples: "Üzemanyag és Töltőállomás", "Étel és Ital", "Szállás"
const universalLevel1Categories = computed(() => {
return categoryTree.value.filter(c => c.level === 1)
})
// ── Load category tree on mount ──
onMounted(async () => {
await loadCategoryTree()
})
// ── Watch provider changes → populate form ──
@@ -332,33 +642,125 @@ watch(
form.phone = provider.contact_phone || ''
form.email = provider.contact_email || ''
form.website = provider.website || ''
form.tagsInput = ''
form.tags = provider.tags && provider.tags.length > 0
? [...provider.tags]
: provider.specialization
? [...provider.specialization]
: []
}
},
{ immediate: true }
)
// ── Tag management ──
watch(
() => form.tagsInput,
(val) => {
if (val.endsWith(',') || val.endsWith(';')) {
const newTag = val.slice(0, -1).trim()
if (newTag && !form.tags.includes(newTag)) {
form.tags.push(newTag)
}
form.tagsInput = ''
}
// ── Load Category Tree from API ──
async function loadCategoryTree() {
categoryTreeLoading.value = true
try {
const response = await api.get('providers/categories/tree')
categoryTree.value = response.data || []
} catch (err) {
console.error('[ProviderEditModal] Failed to load category tree:', err)
} finally {
categoryTreeLoading.value = false
}
)
}
// ── Toggle category selection (Block A) ──
function toggleCategory(categoryId: number) {
const idx = selectedCategoryIds.value.indexOf(categoryId)
if (idx >= 0) {
selectedCategoryIds.value.splice(idx, 1)
} else {
selectedCategoryIds.value.push(categoryId)
}
}
// ── Autocomplete input handler (Block B) ──
function onAutocompleteInput() {
if (autocompleteTimer) {
clearTimeout(autocompleteTimer)
}
const query = autocompleteQuery.value.trim()
if (query.length < 2) {
autocompleteResults.value = []
return
}
// Debounce: 300ms
autocompleteTimer = setTimeout(async () => {
try {
const response = await api.get('providers/categories/autocomplete', {
params: { q: query },
})
// Filter out already selected tags
const selectedKeys = new Set(selectedTags.value.map(t => t.key))
autocompleteResults.value = (response.data || []).filter(
(item: AutocompleteItem) => !selectedKeys.has(item.key)
)
} catch (err) {
console.error('[ProviderEditModal] Autocomplete error:', err)
autocompleteResults.value = []
}
}, 300)
}
// ── Select autocomplete item ──
function selectAutocompleteItem(item: AutocompleteItem) {
selectedTags.value.push({
id: item.id,
key: item.key,
name_hu: item.name_hu,
level: item.level,
})
autocompleteQuery.value = ''
autocompleteResults.value = []
}
// ── Enter key: create new tag if not found ──
function onAutocompleteEnter() {
const query = autocompleteQuery.value.trim()
if (!query) return
// Check if already selected
if (selectedTags.value.some(t => t.key === query.toLowerCase().replace(/\s+/g, '_'))) {
autocompleteQuery.value = ''
return
}
// Check if it matches any autocomplete result
const match = autocompleteResults.value.find(
item => (item.name_hu || '').toLowerCase() === query.toLowerCase()
)
if (match) {
selectAutocompleteItem(match)
return
}
// Create new tag (user-created, not yet in DB)
selectedTags.value.push({
id: null, // null = not yet in DB
key: query.toLowerCase().replace(/\s+/g, '_'),
name_hu: query,
level: 3,
})
autocompleteQuery.value = ''
autocompleteResults.value = []
}
// ── Backspace on empty input: remove last tag ──
function onAutocompleteBackspace() {
if (autocompleteQuery.value === '' && selectedTags.value.length > 0) {
selectedTags.value.pop()
}
}
// ── Blur: close autocomplete dropdown ──
function onAutocompleteBlur() {
// Delay to allow click on dropdown items
setTimeout(() => {
autocompleteResults.value = []
}, 200)
}
// ── Remove tag ──
function removeTag(index: number) {
form.tags.splice(index, 1)
selectedTags.value.splice(index, 1)
}
// ── Save handler (async — calls backend PUT /providers/{id}) ──
@@ -366,27 +768,58 @@ async function handleSave() {
if (!form.name.trim() || !props.provider) return
isSaving.value = true
gamificationToast.value = null
try {
// Separate existing tag IDs from new tag names
const existingCategoryIds = selectedTags.value
.filter(t => t.id !== null)
.map(t => t.id as number)
const newTagNames = selectedTags.value
.filter(t => t.id === null)
.map(t => t.name_hu || t.key)
// Combine Block A + Block B category IDs
const allCategoryIds = [...selectedCategoryIds.value, ...existingCategoryIds]
// P1 CRITICAL ALIGN: Atomizált címmezők a payload-ban
const body = {
const body: Record<string, any> = {
name: form.name.trim(),
city: form.city.trim() || null,
address_zip: form.zip.trim() || null,
address_street_name: form.address_street_name.trim() || null,
address_street_type: form.address_street_type.trim() || null,
address_house_number: form.address_house_number.trim() || null,
plus_code: form.plus_code.trim() || null,
contact_phone: form.phone.trim() || null,
contact_email: form.email.trim() || null,
website: form.website.trim() || null,
tags: form.tags.length > 0 ? [...form.tags] : null,
tags: null,
category_ids: allCategoryIds.length > 0 ? allCategoryIds : null,
new_tags: newTagNames.length > 0 ? newTagNames : null,
}
await api.put(`providers/${props.provider.id}`, body)
const response = await api.put(`providers/${props.provider.id}`, body)
const earnedPoints = response.data?.earned_points
// Show gamification toast if points were awarded
if (earnedPoints !== undefined && earnedPoints !== null) {
if (earnedPoints >= 0) {
gamificationToast.value = {
type: 'success',
message: t('gamification.points_awarded', { points: earnedPoints }),
}
} else {
gamificationToast.value = {
type: 'penalty',
message: t('gamification.penalty_applied', { points: Math.abs(earnedPoints) }),
}
}
}
// Emit success so parent can refresh search results
emit('saved', { id: props.provider.id })
emit('close')
} catch (err: any) {
console.error('[ProviderEditModal] Save error:', err)
// Keep modal open on error so user can retry

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ export default {
finance: 'Finance & Costs',
diagnostic: 'Live Diagnostics',
addVehicle: '+ Add New Vehicle',
service: 'Service',
},
header: {
welcome: 'Welcome',
@@ -1242,6 +1243,11 @@ export default {
filterAutoMento: '🚛 Tow Truck',
filterBenzinkut: '⛽ Gas Station',
filterAutoKozmetika: '✨ Car Detailing',
// Category filter (FEATURE 2026-06-17)
categoryPlaceholder: 'Search category (e.g. AC, tire)',
categoryFilterTitle: 'Category filter',
categoryRemove: 'Remove',
categoryNoResults: 'No results',
// SOS Tab
sosTabTitle: 'SOS Rescue',
sosPlaceholder: '🚧 This feature is under development. You will be able to request emergency roadside assistance here soon!',
@@ -1299,21 +1305,60 @@ export default {
duplicateWarningShort: 'Similar provider found nearby: {name}. Are you sure you want to add a new one?',
// Edit Modal
editTitle: 'Edit Provider Data',
tabBasic: 'Basic Data',
tabServices: 'Services',
addressSection: 'Address Details',
contactSection: 'Contact Details',
streetNameLabel: 'Street Name',
streetNameOptional: 'optional',
streetNamePlaceholder: 'e.g. Egressy',
streetTypeLabel: 'Street Type',
streetTypePlaceholder: 'Select type...',
houseNumberLabel: 'House Number',
houseNumberPlaceholder: 'e.g. 4',
phoneLabel: 'Phone',
phoneOptional: 'optional',
phonePlaceholder: 'e.g. +36 20 123 4567',
emailLabel: 'Email',
emailOptional: 'optional',
emailPlaceholder: 'e.g. info@provider.hu',
websiteLabel: 'Website',
websiteOptional: 'optional',
websitePlaceholder: 'e.g. https://provider.hu',
tagsLabel: 'Service Tags',
tagsPlaceholder: 'Add tags (comma-separated)',
level0Label: 'Vehicle Type',
level1Label: 'Industry / Main Group',
editInfo: 'Your edits will be reviewed and may earn gamification points!',
// Plus Code
plusCodeLabel: 'Plus Code',
plusCodeOptional: 'optional',
plusCodePlaceholder: 'e.g. 8FVC9X8V+XV',
// Detail Modal
detailTitle: 'Provider Details',
detailCategories: 'Categories',
detailContact: 'Contact',
detailLocation: 'Location',
detailNavigate: 'Navigate',
detailNoCategories: 'No categories assigned',
detailNoPhone: 'No phone',
detailNoEmail: 'No email',
detailNoWebsite: 'No website',
detailNoPlusCode: 'No Plus Code',
},
// ── Categories (Hybrid Category UI) ────────────────────────────────
categories: {
block_a_title: 'Basic Categories (Vehicle & Industry)',
block_b_title: 'Specific Tasks & Tags',
search_placeholder: 'Search for a service, or type a new one (Enter)...',
no_results_create: 'No results. Press Enter to create a new tag.',
},
// ── Gamification ───────────────────────────────────────────────────
gamification: {
points_awarded: 'Saved successfully! You earned {points} XP!',
penalty_applied: 'Saved. Due to your restriction level, you earned {points} XP.',
},
admin: {

View File

@@ -30,6 +30,7 @@ export default {
finance: 'Pénzügyek és Költségek',
diagnostic: 'Élő Diagnosztika',
addVehicle: '+ Új Jármű Hozzáadása',
service: 'Szervíz',
},
header: {
welcome: 'Üdvözlünk',
@@ -1242,6 +1243,11 @@ export default {
filterAutoMento: '🚛 Autómentő',
filterBenzinkut: '⛽ Benzinkút',
filterAutoKozmetika: '✨ Autókozmetika',
// Category filter (FEATURE 2026-06-17)
categoryPlaceholder: 'Kategória keresése (pl. klíma, gumi)',
categoryFilterTitle: 'Kategória szűrő',
categoryRemove: 'Eltávolítás',
categoryNoResults: 'Nincs találat',
// SOS Tab
sosTabTitle: 'SOS Mentés',
sosPlaceholder: '🚧 Ez a funkció fejlesztés alatt áll. Hamarosan kérhető lesz itt az azonnali úti segítség!',
@@ -1299,21 +1305,60 @@ export default {
duplicateWarningShort: 'Hasonló szolgáltató található a környéken: {name}. Biztosan újat szeretnél rögzíteni?',
// Edit Modal
editTitle: 'Szolgáltató adatok szerkesztése',
tabBasic: 'Alapadatok',
tabServices: 'Szolgáltatások',
addressSection: 'Cím adatok',
contactSection: 'Elérhetőségek',
streetNameLabel: 'Utca neve',
streetNameOptional: 'opcionális',
streetNamePlaceholder: 'Pl. Egressy',
streetTypeLabel: 'Közterület jellege',
streetTypePlaceholder: 'Válassz típust...',
houseNumberLabel: 'Házszám',
houseNumberPlaceholder: 'Pl. 4',
phoneLabel: 'Telefonszám',
phoneOptional: 'opcionális',
phonePlaceholder: 'Pl. +36 20 123 4567',
emailLabel: 'Email',
emailOptional: 'opcionális',
emailPlaceholder: 'Pl. info@szolgaltato.hu',
websiteLabel: 'Weboldal',
websiteOptional: 'opcionális',
websitePlaceholder: 'Pl. https://szolgaltato.hu',
tagsLabel: 'Szolgáltatás Címkék',
tagsPlaceholder: 'Címkék hozzáadása (vesszővel elválasztva)',
level0Label: 'Járműtípus',
level1Label: 'Iparág / Főcsoport',
editInfo: 'A szerkesztéseid ellenőrzésre kerülnek, és gamifikációs pontokat szerezhetsz!',
// Plus Code
plusCodeLabel: 'Plus kód',
plusCodeOptional: 'opcionális',
plusCodePlaceholder: 'Pl. 8FVC9X8V+XV',
// Detail Modal
detailTitle: 'Szolgáltató adatai',
detailCategories: 'Kategóriák',
detailContact: 'Elérhetőségek',
detailLocation: 'Helyszín',
detailNavigate: 'Navigáció',
detailNoCategories: 'Nincs kategória hozzárendelve',
detailNoPhone: 'Nincs telefonszám',
detailNoEmail: 'Nincs email',
detailNoWebsite: 'Nincs weboldal',
detailNoPlusCode: 'Nincs Plus kód',
},
// ── Categories (Hybrid Category UI) ────────────────────────────────
categories: {
block_a_title: 'Alap kategóriák (Jármű és Iparág)',
block_b_title: 'Specifikus feladatok és címkék',
search_placeholder: 'Keress szolgáltatást, vagy írj be újat (Enter)...',
no_results_create: 'Nincs találat. Nyomj Entert az új címke létrehozásához.',
},
// ── Gamification ───────────────────────────────────────────────────
gamification: {
points_awarded: 'Sikeres mentés! Szereztél {points} XP-t!',
penalty_applied: 'Mentve. A korlátozásod miatt {points} XP-t kaptál.',
},
admin: {

View File

@@ -66,7 +66,7 @@
{{ t('menu.finance') }}
</button>
<!-- Szerviz kereső -->
<!-- Szerviz kereső (belső) -->
<button
@click="navigateToCard('service')"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
@@ -77,6 +77,17 @@
</svg>
{{ t('menu.features') }}
</button>
<!-- Szervíz (külső oldal) -->
<button
@click="openExternalServiceFinder"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{{ t('menu.service') }}
</button>
</div>
</div>
</Transition>
@@ -133,6 +144,12 @@ function navigateToCard(cardId: string) {
}
}
// ── Open external Service Finder page in new tab ───────────────────
function openExternalServiceFinder() {
isHamburgerOpen.value = false
window.open('https://app.servicefinder.hu/dashboard/service-finder', '_blank', 'noopener,noreferrer')
}
// ── Close hamburger on outside click ──────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement

View File

@@ -21,4 +21,10 @@ export interface OrganizationItem {
* Set via PATCH /organizations/{org_id}/visual-settings.
*/
visual_settings?: Record<string, any> | null
/**
* The current user's role in this organization (e.g. OWNER, ADMIN, FLEET_MANAGER, etc.).
* Returned by GET /api/v1/organizations/my since the 2026-06-17 bugfix.
* Used by the frontend to decide whether to show service_provider orgs in the garage switcher.
*/
user_role?: string
}

View File

@@ -164,37 +164,38 @@
</div>
<!--
Card 3: 🔧 Service Finder (3-Way Indítópult)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
</div>
<!-- Content: 3 simple buttons stacked vertically -->
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
<button
@click="router.push('/dashboard/service-finder?mode=planned')"
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🔍 {{ t('serviceFinder.plannedMaintenance') }}
</button>
<button
@click="router.push('/dashboard/service-finder?mode=sos')"
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🚨 SOS {{ t('serviceFinder.sosTitle') }}
</button>
<button
@click="isSfQuickAddOpen = true"
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
{{ t('provider.quickAddTitle') }}
</button>
</div>
</div>
Card 3: 🔧 Service Finder (3-Way Indítópult)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
@click="openExternalServiceFinder"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
</div>
<!-- Content: 3 simple buttons stacked vertically -->
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
<button
@click.stop="openExternalServiceFinder"
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🔍 {{ t('serviceFinder.plannedMaintenance') }}
</button>
<button
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
>
🚨 SOS {{ t('serviceFinder.sosTitle') }}
</button>
<button
@click.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>
</div>
</div>
<!--
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 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.
*/

View File

@@ -66,6 +66,90 @@
</button>
</div>
<!-- Category Filter (Autocomplete + Chips) -->
<div class="mx-auto mt-4 max-w-2xl">
<!-- Autocomplete input -->
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<input
v-model="categoryQuery"
type="text"
:placeholder="t('serviceFinder.categoryPlaceholder')"
class="w-full rounded-xl border-2 border-white/20 bg-white/10 py-3 pl-12 pr-10 text-sm text-white placeholder-slate-400 backdrop-blur-sm transition-all focus:border-sf-accent focus:bg-white/15 focus:outline-none focus:ring-4 focus:ring-sf-accent/20"
@input="onCategoryInput"
@keydown.enter="selectFirstSuggestion"
@keydown.escape="categorySuggestions = []"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrev"
/>
<!-- Clear button -->
<button
v-if="categoryQuery"
@click="clearCategoryInput"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-white transition-colors cursor-pointer"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Autocomplete dropdown -->
<div
v-if="categorySuggestions.length > 0"
class="absolute z-50 mt-1 w-full max-w-md rounded-xl border border-white/20 bg-slate-800 shadow-2xl backdrop-blur-xl"
style="max-width: calc(100% - 2rem);"
>
<div
v-for="(suggestion, idx) in categorySuggestions"
:key="suggestion.id"
@click="selectCategory(suggestion)"
@mouseenter="categoryHighlightIndex = idx"
class="flex cursor-pointer items-center gap-3 px-4 py-3 text-sm text-slate-200 transition-colors"
:class="{
'bg-sf-accent/20 text-white': idx === categoryHighlightIndex,
'hover:bg-white/5': idx !== categoryHighlightIndex,
'rounded-t-xl': idx === 0,
'rounded-b-xl': idx === categorySuggestions.length - 1,
}"
>
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-sf-accent/20 text-xs font-bold text-sf-accent">
{{ suggestion.level }}
</span>
<div class="flex-1">
<span class="font-medium">{{ categoryLabel(suggestion) }}</span>
<span v-if="suggestion.path" class="ml-2 text-xs text-slate-500">#{{ suggestion.path }}</span>
</div>
<span class="text-xs text-slate-500">L{{ suggestion.level }}</span>
</div>
</div>
<!-- Selected category chips -->
<div v-if="selectedCategories.length > 0" class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-slate-400">{{ t('serviceFinder.categoryFilterTitle') }}:</span>
<span
v-for="cat in selectedCategories"
:key="cat.id"
class="inline-flex items-center gap-1.5 rounded-full bg-sf-accent/20 px-3 py-1.5 text-xs font-medium text-white"
>
{{ categoryLabel(cat) }}
<button
@click="removeCategory(cat)"
class="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-300 transition-colors hover:bg-white/20 hover:text-white cursor-pointer"
:title="t('serviceFinder.categoryRemove')"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
</div>
</div>
<!-- Quick filter chips -->
<div class="mt-6 flex flex-wrap items-center justify-center gap-2">
<span class="text-xs font-medium text-slate-400">{{ t('serviceFinder.quickFilters') }}</span>
@@ -240,6 +324,32 @@
</span>
</div>
<!-- Contact info: phone + website (P0 UI Card Cleanup 2026-06-17) -->
<div class="mb-3 flex flex-wrap items-center gap-3 text-sm">
<a
v-if="provider.contact_phone"
:href="'tel:' + provider.contact_phone"
class="inline-flex items-center gap-1.5 text-slate-500 hover:text-sf-accent transition-colors"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span>{{ provider.contact_phone }}</span>
</a>
<a
v-if="provider.website"
:href="provider.website.startsWith('http') ? provider.website : 'https://' + provider.website"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-slate-500 hover:text-sf-accent transition-colors"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<span>{{ provider.website.replace(/^https?:\/\//, '') }}</span>
</a>
</div>
<!-- Specialization chips / tags -->
<div
v-if="provider.specialization && provider.specialization.length > 0"
@@ -260,10 +370,36 @@
</span>
</div>
<!-- Category badge ( 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">
<span
v-if="provider.category"
v-if="provider.categories && provider.categories.length > 0"
class="inline-flex items-center rounded-md bg-sf-accent/10 px-2.5 py-1 text-xs font-semibold text-sf-accent"
>
{{ providerCategoryLabel(provider.categories[0]) }}
</span>
<span
v-else-if="provider.category"
class="inline-flex items-center rounded-md bg-sf-accent/10 px-2.5 py-1 text-xs font-semibold text-sf-accent"
>
{{ provider.category }}
@@ -397,17 +533,35 @@ import api from '@/api/axios'
import ProviderDetailModal from '@/components/provider/ProviderDetailModal.vue'
import ProviderEditModal from '@/components/provider/ProviderEditModal.vue'
const { t } = useI18n()
const { t, locale } = useI18n()
// ── Types matching backend ProviderSearchResult ──
// @see backend/app/schemas/provider.py:21-33
interface CategoryInfo {
id: number
name_hu: string | null
name_en: string | null
level: number
key: string
}
interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
categories: CategoryInfo[]
city: string | null
address: string | null
address_zip: string | null
address_street_name: string | null
address_street_type: string | null
address_house_number: string | null
plus_code: string | null
contact_phone: string | null
contact_email: string | null
website: string | null
tags: string[]
source: 'verified_org' | 'staged_data' | 'crowd_added'
is_verified: boolean
rating: number | null
@@ -430,12 +584,54 @@ const quickFilters = computed(() => [
{ key: 'autókozmetika', label: t('serviceFinder.filterAutoKozmetika') },
])
/**
* Locale-aware category label.
* Returns name_hu if locale is 'hu', name_en otherwise.
* Falls back to the other language if the preferred one is null.
*/
function categoryLabel(cat: CategorySuggestion): string {
if (locale.value === 'hu') {
return cat.name_hu || cat.name_en || cat.key
}
return cat.name_en || cat.name_hu || cat.key
}
/**
* FEATURE (2026-06-17): Locale-aware category label for provider cards.
* Uses the same logic as categoryLabel but for CategoryInfo type.
*/
function providerCategoryLabel(cat: CategoryInfo): string {
if (locale.value === 'hu') {
return cat.name_hu || cat.name_en || cat.key
}
return cat.name_en || cat.name_hu || cat.key
}
const router = useRouter()
const route = useRoute()
// ── Active Tab (from route.query.mode or default 'planned') ──
const activeTab = ref<'planned' | 'sos'>('planned')
// ── Category Filter State (FEATURE 2026-06-17) ──
// Matching backend CategoryAutocompleteItem schema
// @see backend/app/schemas/provider.py:41-55
interface CategorySuggestion {
id: number
key: string
name_hu: string | null
name_en: string | null
level: number
path: string | null
parent_id: number | null
}
const categoryQuery = ref('')
const categorySuggestions = ref<CategorySuggestion[]>([])
const categoryHighlightIndex = ref(-1)
const selectedCategories = ref<CategorySuggestion[]>([])
let categoryDebounceTimer: ReturnType<typeof setTimeout> | null = null
// ── Reactive state ──
const searchQuery = ref('')
const searchCity = ref('')
@@ -464,21 +660,28 @@ onMounted(() => {
}
})
// ── Search logic (passes both q and city) ──
// ── Helper: build common search params (includes category_ids) ──
function buildSearchParams(offset: number): Record<string, any> {
const params: Record<string, any> = {
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
if (selectedCategories.value.length > 0) {
params.category_ids = selectedCategories.value.map(c => c.id).join(',')
}
return params
}
// ── Search logic (passes q, city, and category_ids) ──
async function handleSearch() {
isSearching.value = true
hasSearched.value = true
currentPage.value = 1
try {
const offset = 0
const params: Record<string, any> = {
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
const params = buildSearchParams(0)
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
results.value = res.data.results || []
totalResults.value = res.data.total || 0
@@ -506,13 +709,7 @@ async function goToPage(page: number) {
try {
const offset = (page - 1) * perPage.value
const params: Record<string, any> = {
limit: perPage.value,
offset,
}
if (searchQuery.value.trim()) params.q = searchQuery.value.trim()
if (searchCity.value.trim()) params.city = searchCity.value.trim()
const params = buildSearchParams(offset)
const res = await api.get<ProviderSearchResponse>('providers/search', { params })
results.value = res.data.results || []
totalResults.value = res.data.total || 0
@@ -523,6 +720,82 @@ async function goToPage(page: number) {
}
}
// ── Category Autocomplete (FEATURE 2026-06-17) ──
async function onCategoryInput() {
// Clear previous suggestions if query is too short
if (!categoryQuery.value || categoryQuery.value.trim().length < 2) {
categorySuggestions.value = []
categoryHighlightIndex.value = -1
return
}
// Debounce: wait 250ms after last keystroke
if (categoryDebounceTimer) clearTimeout(categoryDebounceTimer)
categoryDebounceTimer = setTimeout(async () => {
try {
const q = categoryQuery.value.trim()
if (q.length < 2) {
categorySuggestions.value = []
categoryHighlightIndex.value = -1
return
}
const res = await api.get<CategorySuggestion[]>('providers/categories/autocomplete', {
params: { q },
})
categorySuggestions.value = res.data || []
categoryHighlightIndex.value = categorySuggestions.value.length > 0 ? 0 : -1
} catch (err) {
console.error('[ServiceFinderView] Category autocomplete error:', err)
categorySuggestions.value = []
categoryHighlightIndex.value = -1
}
}, 250)
}
function selectCategory(cat: CategorySuggestion) {
// Prevent duplicates
if (selectedCategories.value.some(c => c.id === cat.id)) {
categoryQuery.value = ''
categorySuggestions.value = []
categoryHighlightIndex.value = -1
return
}
selectedCategories.value.push({ ...cat })
categoryQuery.value = ''
categorySuggestions.value = []
categoryHighlightIndex.value = -1
// Immediately re-trigger search with the new category filter
handleSearch()
}
function removeCategory(cat: CategorySuggestion) {
selectedCategories.value = selectedCategories.value.filter(c => c.id !== cat.id)
// Immediately refresh results when removing a chip
handleSearch()
}
function clearCategoryInput() {
categoryQuery.value = ''
categorySuggestions.value = []
categoryHighlightIndex.value = -1
}
function selectFirstSuggestion() {
if (categorySuggestions.value.length > 0 && categoryHighlightIndex.value >= 0) {
selectCategory(categorySuggestions.value[categoryHighlightIndex.value])
}
}
function highlightNext() {
if (categorySuggestions.value.length === 0) return
categoryHighlightIndex.value = (categoryHighlightIndex.value + 1) % categorySuggestions.value.length
}
function highlightPrev() {
if (categorySuggestions.value.length === 0) return
categoryHighlightIndex.value = (categoryHighlightIndex.value - 1 + categorySuggestions.value.length) % categorySuggestions.value.length
}
// ── Detail Modal ──
function openDetail(provider: ProviderSearchResult) {
selectedProvider.value = provider

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
A szolgáltató cégek adatainak rögzítésében és szerkesztésében fellépő hibák javítása, valamint az adatok részletes rögzíthetőségének biztosítása.
A `PUT /api/v1/providers/{id}` végpont által dobott 500-as hiba kijavítása, amikor a felhasználó egy olyan szolgáltató adatait szerkeszti, amely még **nincs** átmigrálva az `Organization` táblába (csak `ServiceStaging`-ben létezik).
## 📋 Problémák Összefoglalása
## 🔍 Root Cause Analysis
### 1. PUT /api/v1/providers/{id} → 404 Not Found (✅ MEGOLDVA)
- **Root Cause**: A `sf_api` konténer nem volt újraindítva a providers modul kódfrissítése után. A `pre_start.sh` fájlban `uvicorn` `--reload` flag nélkül fut.
- **Javítás**: `docker compose restart sf_api` (végrehajtva)
- **Verifikáció**: PUT /providers/58 → 200 OK
### Hibajelenség
`PUT /api/v1/providers/4859``500 Internal Server Error`
```
value too long for type character varying(12)
```
### 2. GET /api/v1/providers/search → 500 Internal Server Error (❌ JAVÍTANDÓ)
- **Hiba**: `AttributeError: Neither 'BinaryExpression' object nor 'Comparator' object has an attribute 'astext'`
- **Hibás kód**: [`provider_service.py:207`](../backend/app/services/provider_service.py:207)
```python
(Organization.external_integration_config["source"].astext == "crowdsourced", literal("crowd_added")),
```
- **Root Cause**: Az [`external_integration_config`](../backend/app/models/marketplace/organization.py:109) `JSON` típusú oszlop. A `["source"]` subscript `BinaryExpression`-t ad vissza, amelyen NINCS `.astext`.
- **Javítás**: `cast` használata:
```python
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
```
### Kiváltó ok
A provider ID=4859 a `marketplace.service_staging` táblában létezik, de **nincs** még `fleet.organizations` rekordja.
### 3. Adat-healing: Régi címformátumú Organization rekordok (❌ JAVÍTANDÓ)
- **Probléma**: Az Organization id=58 (`Autónyíri Kft.`) adatai a régi formátumban:
- `street_name = "Egressy u. 4."` (nem atomizált)
- `address_street_name = NULL`, `address_street_type = NULL`, `address_house_number = NULL`
- `zip = NULL`
- **Következmény**: A frontend DetailModal üres címet mutat.
- **Javítás**: Adat-healing script a `street_name` mezőből atomizált komponensek kinyerésére.
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:
### 4. Multi-source Update Probléma (❌ JAVÍTANDÓ)
- **Probléma**: Az [`update_provider`](../backend/app/services/provider_service.py:477) CSAK `Organization`-ben keres. A search UNION-nal dolgozza fel a szolgáltatókat 3 forrásból (Organization, ServiceStaging, ServiceProvider).
- **Javítás**: Multi-source update logika.
### 5. Szerver Restart Workflow Hiánya (❌ JAVÍTANDÓ)
- **Probléma**: A [`pre_start.sh`](../backend/app/scripts/pre_start.sh) `--reload` nélkül indul.
- **Javítás**: `--reload` flag fejlesztői módban.
---
## 🗺️ Érintett Fájlok
| Fájl | Változtatás | Prioritás |
|------|-------------|-----------|
| [`provider_service.py:207`](../backend/app/services/provider_service.py:207) | `.astext` → `cast()` | **KRITIKUS** |
| [`provider_service.py:477-562`](../backend/app/services/provider_service.py:477) | Multi-source update | **MAGAS** |
| Új: `heal_provider_addresses.py` | Adat-healing script | **MAGAS** |
| [`pre_start.sh`](../backend/app/scripts/pre_start.sh) | `--reload` dev módban | **ALACSONY** |
---
## 🛠️ Részletes Javítási Terv
### 1. `.astext` → `cast()` javítás
**Fájl**: [`provider_service.py`](../backend/app/services/provider_service.py:207)
**Jelenlegi:**
```python
(Organization.external_integration_config["source"].astext == "crowdsourced", literal("crowd_added")),
folder_slug = f"sp-{staging.id}-{uuid.uuid4().hex[:6]}",
```
**Javított:**
- `"sp-"` = 3 karakter
- `"4859"` = 4 karakter (provider_id hossza)
- `"-"` = 1 karakter
- `"4d45d3"` = 6 karakter (uuid hex)
- **Összesen: 14 karakter**
Az [`Organization`](backend/app/models/marketplace/organization.py:75) modellben a `folder_slug` mező:
```python
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
folder_slug: Mapped[str] = mapped_column(String(12), unique=True, index=True)
```
**Csak 12 karaktert enged!** → PostgreSQL `StringDataRightTruncationError`.
**Ugyanez a hiba** a crowd-sourced migrációs ágon is (line 820):
```python
folder_slug = f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}",
```
### 2. Multi-source Update Logika
### Összehasonlítás
- **`quick_add_provider`** (line 501-503): `hashlib.md5(...).hexdigest()[:12]` → pontosan 12 karakter → **MŰKÖDIK**
- **`update_provider` migration** (line 786, 820): `"sp-{id}-{hex}"` / `"cr-{id}-{hex}"`**TÚL HOSSZÚ**
**Fájl**: [`provider_service.py:477`](../backend/app/services/provider_service.py:477)
## 📋 Javítási Terv
Az `update_provider` ellenőrizze mindhárom forrást:
1. `db.get(Organization, provider_id)` → meglévő logika
2. `db.get(ServiceStaging, provider_id)` → migrálás Organization-be
3. `db.get(ServiceProvider, provider_id)` → migrálás Organization-be
4. Ha egyikben sem → ValueError
### 1. Model fix: [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:75)
**Változtatás:** `folder_slug` oszlop méretének növelése `String(12)``String(24)`
### 3. Adat-healing Script
Indoklás:
- A jelenlegi 12 karakter túl szűk
- A `quick_add_provider` pontosan 12 karaktert használ → kompatibilis
- A migrációs slug-ok (pl. `sp-{id}-{hex6}`) elférnek
- A `unique=True` megszorítás megmarad
**Fájl**: `backend/app/scripts/heal_provider_addresses.py`
### 2. Service fix: [`backend/app/services/provider_service.py`](backend/app/services/provider_service.py:786)
**Változtatás:** A migrációs `folder_slug` generálás módosítása konzisztens `hashlib.md5` alapú generálásra.
1. Lekérdezni Organization rekordokat, ahol `org_type='service_provider'` ÉS `address_street_name IS NULL` ÉS `street_name IS NOT NULL`
2. Regex: `r'^([^\d]+?)\s+(u\.|utca|út|tér|köz|sor|körút|liget|part|fasor|sétány|park|híd|sugárút|rakpart|dűlő|telep|szőlő)\s*(.*)$'`
3. Frissíteni az atomizált mezőket
4. Naplózás
**Staging ág (line 786):**
```python
# EZT:
folder_slug = f"sp-{staging.id}-{uuid.uuid4().hex[:6]}"
# HELYETTE:
folder_slug = hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12]
```
---
**Crowd ág (line 820):**
```python
# EZT:
folder_slug = f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}"
# HELYETTE:
folder_slug = hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12]
```
## 🧪 Tesztelési Terv
Indoklás:
- Konzisztens a `quick_add_provider` által használt módszerrel
- Mindig pontosan 12 karakter → garantáltan elfér a `String(12)` mezőben is
- Elég nagy entrópia az egyediséghez (`hashlib.md5(...)` → 32 hex, `[:12]` → 48 bit)
### Teszt 1: search működés
### 3. Adatbázis szinkronizáció
Mivel az oszlop mérete változik (`String(12)``String(24)`), futtatni kell:
```bash
docker compose exec sf_api python3 -c "
import httpx, asyncio
async def t():
async with httpx.AsyncClient(base_url='http://localhost:8000') as c:
r = await c.post('/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'})
t = r.json()['access_token']
r2 = await c.get('/api/v1/providers/search', params={'q': 'Dunakeszi', 'limit': 5}, headers={'Authorization': f'Bearer {t}'})
print(f'Search: {r2.status_code}')
print(r2.text[:500])
asyncio.run(t())
"
docker exec sf_api python -m app.scripts.sync_engine
```
### Teszt 2: PUT működés
```bash
docker compose exec sf_api python3 -c "
import httpx, asyncio
async def t():
async with httpx.AsyncClient(base_url='http://localhost:8000') as c:
r = await c.post('/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'})
t = r.json()['access_token']
r2 = await c.put('/api/v1/providers/58',
json={'address_zip': '2120', 'city': 'Dunakeszi', 'name': 'Autónyíri Kft.'},
headers={'Authorization': f'Bearer {t}'})
print(f'PUT: {r2.status_code}')
print(r2.text)
asyncio.run(t())
"
```
## ✅ Elfogadási kritériumok
1. `PUT /api/v1/providers/4859` sikeresen lefut (200 OK)
2. A migrált Organization `folder_slug` pontosan 12 karakter hosszú
3. Meglévő provider-ek (`quick_add`-ból) továbbra is működnek
4. A `folder_slug` `unique` megszorítás nem sérül
5. A logokban nincs `StringDataRightTruncationError`

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