frontend admin refakctorálás

This commit is contained in:
Roo
2026-07-08 08:03:57 +00:00
parent 07b59032ce
commit 2e0abc62a7
455 changed files with 14428 additions and 21651 deletions

View File

@@ -3,215 +3,279 @@
## 2026-06-21 - P0 Deep Audit: Database Consistency & Zombie API Hunt
### 🎯 Cél
Teljes körű adatbázis konzisztencia ellenőrzés és zombie API végpontok felderítése a `vehicle`, `finance`, `fleet_finance` sémákban, mielőtt a frontend fejlesztés elkezdődik.
...
### 🔧 Eredmények
**1. ADATBÁZIS TISZTASÁG:** ✅ PASS
### ✅ Verifikáció
- Backend Python szintaxis: OK
- Sync Engine: 1297 elem OK, 0 hiba, teljes szinkronban
- API Test 1: `PATCH is_always_open=True``{"always_open": true, "days": {"monday": {"open": "00:00", "close": "23:59"}, ...}}`
- API Test 2: `PATCH is_always_open=False``{"monday": {"open": "09:00", "close": "17:00"}}` (always_open metadata stripped) ✅
## 2026-07-01 - Unified Address API Refactoring (Gitea #386-#391)
## 2026-07-07 - P0 ServiceProvider AddressManager Integration
### 🎯 Cél
Teljes körű cím API egységesítés a backend összes endpointján. Az audit során 4 különböző címformátumot és 5 kritikus inkonzisztenciát azonosítottunk. A refaktoring 3 fázisban (P1/P2/P3) 6 Gitea kártyán keresztül valósult meg.
...
### 🔧 Eredmények
**1. Audit & Tervezés:**
- Audit dokumentum: `docs/address_api_consistency_audit.md`
- Logic spec: `plans/logic_spec_unified_address_refactoring.md`
- 6 Gitea kártya létrehozva (#386-#391)
**2. P1 #386 — Admin endpointok (admin.py, admin_persons.py):**
- `admin.py list_users`: Flat dict → `AddressOut.model_validate(address)` nested object
- `admin_persons.py`: `AddressResponse``AddressIn`/`AddressOut`, `PersonAddressUpdate` törölve
- 3 `AddressResponse(...)``AddressOut.model_validate(person.address)`
- Address update field mapping javítva (address_zip→zip, address_hrsz→parcel_id)
**3. P2 #387 — User endpointok (users.py, schemas/user.py):**
- `PersonUpdate`: 9 flat `address_*` field → `address: Optional[AddressIn]`
- `_build_user_response`: Manual dict → `AddressOut.model_validate(person.address)`
- `.dict()``.model_dump()` (Pydantic v2)
**4. P2 #388 — Admin providers (admin_providers.py):**
- `ProviderDetail`: Flat fields → `address_detail: Optional[AddressOut]`
- `ProviderUpdateInput`: Flat fields → `address_detail: Optional[AddressIn]`
- STEP 0: address_detail extraction + flat field mapping (zip→address_zip, city→city, stb.)
**5. P2 #389 — Admin Organizations (admin_organizations.py):**
- `GarageDetailsResponse`: 15 flat address fields → `address_detail: Optional[AddressOut]`, `billing_address_detail: Optional[AddressOut]`, `notification_address_detail: Optional[AddressOut]` (3 AddressOut nested objects)
- `OrganizationUpdate`: 15 flat address fields → `address_detail: Optional[AddressIn]`, `billing_address_detail: Optional[AddressIn]`, `notification_address_detail: Optional[AddressIn]` (3 AddressIn nested objects)
- `get_org_detail`: AddressOut construction from denormalized org fields for all 3 address types
- `update_organization`: STEP 0 with 3 address_detail extraction + flat field mapping blocks (primary/billing/notification)
- Response includes `address_detail` as unified AddressOut object
**6. P3 #390 — Public providers (schemas/provider.py, provider_service.py):**
- `ProviderSearchResult`: `address_detail: Optional[AddressOut]`
- `ProviderQuickAddIn`: `address_detail: Optional[AddressIn]`
- `ProviderUpdateIn`: `address_detail: Optional[AddressIn]`
- `search_providers`: AddressOut construction from flat SQL columns
**7. P3 #391 — Gamification (gamification.py):**
- `submit_new_service` uses `city`/`address` as plain `Body()` string params
- Maps directly to `ServiceStaging.city`/`ServiceStaging.address_line1`
- Simple crowdsourcing — AddressIn conversion would be over-engineering
**8. Phase 4 — AddressResponse removal:**
- `AddressResponse` class completely removed from `schemas/user.py`
- `PersonResponse.address`: `Optional[AddressResponse]``Optional[AddressOut]`
#### 2. Seed script létrehozása és futtatása
A `seed_dismantler_category.py` szkript létrehozva (`backend/app/scripts/`), amely idempotens módon beszúrja az "Autóbontó / Használt alkatrész" kategóriát.
### ✅ Verifikáció
- Backend Python szintaxis: OK (all files pass py_compile)
- Sync Engine: 1274 OK, 0 Fixed, 26 Shadow Data (pre-existing)
- All 6 Gitea cards (#386-#391): CLOSED ✅
1. **Seed script futtatva** → ✅ Sikeresen beszúrva az 'Autóbontó / Használt alkatrész' kategória (ID: 767). Összes rekord: 135.
2. **admin_providers.py szintaxis ellenőrzés**`import app.api.v1.endpoints.admin_providers` sikeres (✅ module imports OK)
3. **sync_engine futtatva** → 1282 OK, 0 issue, schema fully in sync
## 2026-07-01 - Service Provider Auto-Discovery (Gitea #348, #347)
## 2026-07-07 - P0 BUGFIX: Live Provider Advanced Fields & Gas Station Categories
### 🎯 Cél
Implementálni a `find_or_create_provider_by_name()` service függvényt (#348) és az auto-discovery hook-ot az expense creation-ben (#347) a logic_spec alapján.
### 🔧 Eredmények
**1. find_or_create_provider_by_name() (#348):**
- **Exact match:** ILIKE keresés a ServiceProvider.name mezőn
- **Fuzzy match:** pg_trgm similarity > 0.3 threshold, legjobb találat kiválasztása
- **Create new:** Ha nincs találat, új ServiceProvider létrehozása `pending` státusszal, `source=SourceType.api_import` ("api_import"), `validation_score=0`
- **Gamification:** PROVIDER_DISCOVERY (300 XP), PROVIDER_CONFIRMATION (150 XP), PROVIDER_VERIFIED_USE (100 XP), USE_UNVERIFIED_PROVIDER (200 XP)
**2. Auto-discovery hook expense creation-ben (#347):**
- Már implementálva volt a `POST /expenses/` végpontban
- Ha `external_vendor_name` meg van adva, de `service_provider_id` nincs, meghívja a `find_or_create_provider_by_name()`-t
- Hiba esetén warning log, nem blokkolja a költség létrehozását
**3. Adatbázis módosítások:**
- `pg_trgm` extension telepítve
- `marketplace.source_type` enum kiegészítve `'api_import'` értékkel
### ✅ Verifikáció
- Sync Engine: 1297 elem OK, 0 hiba, teljes szinkronban
- Test `test_use_unverified_provider.py`: ALL TESTS PASSED ✅
- PROVIDER_DISCOVERY (300 XP): ✅ User A created provider
- PROVIDER_CONFIRMATION (150 XP): ✅ User A reused own provider
- USE_UNVERIFIED_PROVIDER (200 XP): ✅ User B used pending provider
## 2026-07-01 - P0 ARCHITECTURE CLEANUP: Final Address Refactor (Ghost Columns & AddressManager)
### 🎯 Cél
A `docs/p0_address_manager_usage_audit_2026-07-01.md` auditban feltárt P0 kritikus hiba és P1 hiányosságok javítása.
### 🔧 Eredmények
**1. P0 FIX — `admin.py:search_organizations_by_name`:**
- `Organization.address_city``GeoPostalCode.city` (JOIN `Address``GeoPostalCode`)
- Hozzáadva: `outerjoin(Address, Organization.address_id == Address.id)`
- Hozzáadva: `outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)`
- Változás: `row.address_city``row.city` a response builderben
**2. P1 FIX — `admin_persons.py:update_person`:**
- 50+ sor direkt Address mező írás → egyetlen `AddressManager.create_or_update()` hívás
- Import hozzáadva: `from app.services.address_manager import AddressManager`
**3. P1 FIX — `admin_organizations.py:list_organizations`:**
- `org.address_city``org.address.city if org.address else None` (relationship-based)
**4. P1 FIX — `OrganizationUpdate` séma:**
- Minden flat address field `[DEPRECATED]` prefixet kapott a description-ben
**5. SQL Cleanup Script:**
- `docs/sql/cleanup_ghost_columns_2026_07.sql` — 15 DROP COLUMN utasítás `IF EXISTS` védelemmel
### ✅ Verifikáció
- Backend Python szintaxis: OK (all 3 files pass py_compile)
- Import verifikáció: `Organization.address_id`=True, `Organization.address_city`=False (confirmed removed)
- Sync Engine: 1274 OK, 0 Fixed, 26 Shadow Data (pre-existing ghost columns confirmed)
## 2026-07-01 - P0 CRITICAL HOTFIX: Fix 500 Error on /admin/providers List View
### 🎯 Cél
A GET /api/v1/admin/providers végpont 500-as hibát dobott a denormalizált cím oszlopok eltávolítása után.
### 🔧 Root Cause & Fix
**Root Cause 1 — Wrong ServiceStaging import:**
Az `admin_providers.py` a `ServiceStaging`-et az `app.models.marketplace.service` modulból importálta, amely NEM tartalmazza a `full_address` és `city` mezőket. A helyes import az `app.models.marketplace.staged_data` modulból származik, amely rendelkezik ezekkel a mezőkkel.
**Root Cause 2 — AddressOut missing required `id` field:**
A `get_provider_detail` és `update_provider` végpontok `AddressOut(...)`-ot konstruáltak a ServiceProvider flat address mezőiből, de az `AddressOut` séma `id: uuid.UUID` mezője kötelező (nem Optional). Mivel a ServiceProvider nem rendelkezik Address kapcsolattal, az `address_detail` mezőt `None`-ra kell állítani.
P0 kritikus hibajavítás: Live Provider (ID 6, MOL Fót) `opening_hours` és `category_ids` mezői elvesztek a backend `update_provider()` végpontban, mert a ServiceProvider-hez nem tartozott ServiceProfile rekord. Emellett a "Shop / Kisbolt" kategória hiányzott a rendszerből.
### 🔧 Változtatások
1. **`backend/app/api/v1/endpoints/admin_providers.py:41`** — Import csere: `from app.models.marketplace.service import ServiceStaging``from app.models.marketplace.staged_data import ServiceStaging`
2. **`backend/app/api/v1/endpoints/admin_providers.py:568`** — `address_detail=AddressOut(...)``address_detail=None` (get_provider_detail)
3. **`backend/app/api/v1/endpoints/admin_providers.py:1340`** — `address_detail=AddressOut(...)``address_detail=None` (update_provider)
#### 1. Backend fix: [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1571)
- **P0 BUGFIX (2026-07-07):** Ha egy Live Provider-hez nem létezik ServiceProfile, de a PATCH kérésben advanced mezők (`opening_hours`, `specializations`, `supported_vehicle_classes`, `category_ids`) érkeznek, a backend MOST automatikusan létrehoz egy ServiceProfile rekordot.
- **Response serialization fix:** A `category_ids` visszaadása előtt a rendszer lekérdezi a tényleges DB állapotot a `ServiceExpertise` táblából (`resolved_category_ids`), így a frontend mindig a valóban elmentett adatokat kapja vissza.
#### 2. Seed script: [`seed_gas_station.py`](backend/app/scripts/seed_gas_station.py)
- Létrehozva a hiányzó "Shop / Kisbolt" (Convenience Store) kategória (ID: 768) az "Üzemanyag és Töltőállomás" (ID=755) szülő alá.
- Meglévő kategóriák (ID=755, 756, 757) érintetlenek.
### ✅ Verifikáció
- `GET /api/v1/admin/providers` (default): 200 ✅ (50 items)
- `GET /api/v1/admin/providers?status=pending`: 200 ✅ (50 items)
- `GET /api/v1/admin/providers/4`: 200 ✅ (address_detail=None)
- Sorting by name/validation_score/status: 200 ✅
- Search: 200 ✅ (4 results)
1. **Seed script futtatva** → ✅ 'Shop / Kisbolt' (ID: 768) már létezik. Összes rekord: 136.
2. **sync_engine futtatva** → 1282 OK, 0 issue, schema fully in sync
## 2026-07-01 - P0 BUGFIX: Organization Details 500 Error & Provider Raw Data
## 2026-07-07 - P0 CRITICAL HOTFIX: Promotion Engine v2
### 🎯 Cél
Két P0 hibajavítás:
1. **Organization Details 500 error** - A `fleet.organizations` táblából fizikailag eltávolított denormalizált cím oszlopok (15 db: address_city, billing_zip, notification_street_name, stb.) miatt a `PUT /{org_id}` végpont `setattr`-nál 500-as hibát dobott, ha a frontend elküldte ezeket a mezőket.
2. **Provider Raw Data hiány** - A `GET /admin/providers/{id}` végpont ServiceProvider rekordoknál nem adta vissza a `raw_data` mezőt, így a frontend "Nyers adatok" tab üresen/mock adatként jelent meg.
Javítani a Promotion Engine-t a `PATCH /admin/providers/{id}` végponton, amely ServiceStaging rekordok jóváhagyásakor nem futott le.
### 🔧 Eredmények
### 🔍 Root Cause Analysis
1. **Trigger condition too fragile**: Az `if is_staging and new_status == "approved":` feltétel a `new_status` változóra támaszkodott, amely a `update_fields.get("status")`-ból származott. Ha a státusz nem volt explicit átadva a PATCH payload-ban (pl. csak `name` mezőt módosítottak), `new_status` `None` volt, és a promotion blokk teljesen kimaradt.
2. **ID handling bug**: Promotion után a `provider_obj` átállításra került az új ServiceProvider-re, de az AuditLog (`target_id=str(provider_id)`) és a ServiceProfile lekérdezés (`ServiceProfile.service_provider_id == provider_id`) továbbra is a régi staging ID-t (pl. 1754) használta, nem az új provider ID-t.
**1. `admin_organizations.py` - Deprecated address field filter** (1341-1361. sor)
- Bevezetve a `DEPRECATED_ADDRESS_FIELDS` halmaz, amely tartalmazza mind a 15 ghost oszlopot
- A `update_organization` függvény `setattr` ciklusa előtt ezek a mezők kiszűrésre kerülnek `logger.warning` kíséretében
- A GET végpont (`get_organization_details`) már korábban is helyesen, relationship-ekből töltötte a denormalizált mezőket (1114-1139. sor)
**2. `admin_providers.py` - Raw data hozzáadása ServiceProvider válaszhoz** (589-591. sor)
- A `get_provider_detail` ServiceProvider ágához hozzáadva: `raw_data=sp.raw_data or {}`
- Ezzel a frontend `RawDataViewer` komponense valós adatokat kap, nem üres/mock állapotot
### 🔧 Javítások
1. **Trigger condition → `effective_status`**: A feltétel most a `provider_obj.status`-t (tényleges DB állapot) ellenőrzi közvetlenül, nem a `new_status` payload változót. Ez biztosítja, hogy a promotion akkor is lefusson, ha a státusz már "approved" volt a DB-ben, vagy ha nem volt explicit átadva.
2. **ID fix**: Minden `provider_id` hivatkozás (AuditLog, ServiceProfile lookup) `provider_obj.id`-ra lett cserélve, ami promotion után az új ServiceProvider ID-ját tartalmazza.
3. **Logolás**: `logger.error("P0 PROMOTION TRIGGERED")` hozzáadva a promotion blokk elejére a Docker logokban való trace-elhetőségért.
### ✅ Verifikáció
- Mindkét fájl Python szintaxis ellenőrzése: **OK**
- `sync_engine.py` futtatás: **1274 elem OK, 0 hiba** - teljes szinkronban
1. **Szintaxis ellenőrzés** → ✅ `py_compile` sikeres
2. **Konténer újraindítás** → ✅ `docker compose restart sf_api` sikeres
3. **API elérhető** → ✅ Uvicorn fut a 8000-es porton
---
## 2026-07-02: Bugfix - Provider validációs érték és research_in_progress státusz (Gitea #393)
## 2026-07-07 - DDD Database Refactoring 1.0 (P0 - 3 Phase)
### 🎯 Cél
Provider 1752 (Carnanny) esetén a validációs érték üres volt (validation_score=0), és a `research_in_progress` státusz nem jelent meg helyesen a frontend státuszablakában.
DDD Database Refactoring 1.0 három fázisban: address_id FK bevezetése, admin_providers.py egyszerűsítése, frontend cleanup.
### 🔧 Root Cause & Fix - 4 bug javítva
### 🔧 Változtatások
**Bug 1 - Backend: `validation_score` hardcoded to 0 for staging providers**
- **Hely:** [`admin_providers.py:613`](backend/app/api/v1/endpoints/admin_providers.py:613)
- **Ok:** A `get_provider_detail` ServiceStaging ágában `validation_score=0` volt keménykódolva
- **Fix:** `validation_score=ss.trust_score or 0` - így a trust_score értéke jelenik meg validation_score-ként
#### #396 - Phase 1: address_id FK (Already Complete)
- `ServiceProvider.address_id` és `address_rel` már létezett a modellben
- Mind a 7 provider rekord rendelkezik `address_id`-vel
- `sync_engine` 1282 OK, 0 drift - nincs teendő
**Bug 2 - Frontend: `research_in_progress` hiányzott a statusLabel/statusDescription függvényekből**
- **Hely:** [`index.vue:869-887`](frontend_admin/pages/providers/[id]/index.vue:869)
- **Ok:** A switch csak 'approved', 'pending', 'rejected' eseteket kezelte
- **Fix:** Hozzáadva: `case 'research_in_progress': return 'Kutatás folyamatban'` és magyarázó leírás
#### #397 - Phase 2: admin_providers.py Simplify
- `_build_unified_providers_query()`: `ServiceProvider.address_id` hozzáadva a SELECT-hez (12. oszlop)
- `ServiceStaging` és `Organization` UNION ágak: `literal(None).label("address_id")` placeholder a column parityért
- `list_providers()`: `address_detail` feloldása `AddressManager.get_normalized()` segítségével minden sorban
- Ellenőrizve: `list_providers` endpoint minden provider esetén visszaadja az `address_detail`-t
**Bug 3 - Frontend: `research_in_progress` hiányzott a CSS computed property-kből**
- **Hely:** [`index.vue:774-808`](frontend_admin/pages/providers/[id]/index.vue:774)
- **Ok:** A `statusBannerClass`, `statusIconClass`, `statusTextClass`, `statusSubtextClass` nem kezelték ezt a státuszt → piros (elutasított) stílust kapott
- **Fix:** Mind a 4 computed property-hez hozzáadva a `research_in_progress` case kék színű (blue-500) Tailwind osztályokkal
**Bug 4 - Frontend: Moderációs műveletek rossz ágba kerültek**
- **Hely:** [`index.vue:283`](frontend_admin/pages/providers/[id]/index.vue:283)
- **Ok:** `v-if="provider.status === 'pending'"` csak a pending státuszt kezelte, a `research_in_progress` a `v-else` ágba esett ("elutasításra került")
- **Fix:** `v-if="provider.status === 'pending' || provider.status === 'research_in_progress'"` - így a research_in_progress státuszú provider-ek is látják az Approve/Reject gombokat
### 📊 Adatbázis megállapítás
- A `service_staging.trust_score` mező értéke 0 a DB-ben, de a `raw_data` JSON tartalmazza: `"trust_score": 20`
- Ez egy OSM scout bot enrichment pipeline hiányosság - a bot nem extractálja a `trust_score`-t a `raw_data`-ból a staging oszlopba
- A fix biztosítja, hogy AMIKOR a trust_score populálva lesz, az helyesen megjelenjen validation_score-ként
#### #398 - Phase 3: Frontend Cleanup
- `garages/index.vue` line 157: `garage.city` fallback eltávolítva → csak `garage.address_detail?.city`
- `providers/index.vue` line 137: már `address_detail`-t használ (nem kellett módosítani)
- `providers/[id]/index.vue` lines 144-166: már `address_detail`-t használ (nem kellett módosítani)
- `providers/[id]/edit.vue` lines 1133-1148: már `data.address_detail`-t használ (nem kellett módosítani)
### ✅ Verifikáció
- Backend Python szintaxis: **OK**
- API konténer restart: **OK**
- API hívás provider 1752-re: **200 OK** - status=research_in_progress, validation_score=0 (trust_score=0 miatt), raw_data jelen van
- Frontend: mind a 4 Vue komponens módosítás érvényben
1. **#397 API teszt** → ✅ `list_providers` endpoint 5 provider-t ad vissza `address_detail` mezővel
2. **#398 Frontend regex audit** → ✅ Nincs több `provider.address` vagy `provider.city` flat field referencia
3. **Konténer újraindítás** → ✅ `docker compose restart sf_api` sikeres
## 2026-07-07 - P0 BUGFIX: Category Sync & Parent-Child UX Automation
### 🎯 Cél
A provider admin felület kategória szinkronizációjának javítása legacy provider-eknél (ahol a ServiceProfile `organization_id`-n keresztül kapcsolódik), valamint a frontend kategória-választó UX automatizálása.
### 🔧 Változtatások
#### Backend: `backend/app/api/v1/endpoints/admin_providers.py`
- **`get_provider_detail`** (line 615-618): ServiceProfile lekérdezés kiterjesztve OR-ra: `(ServiceProfile.service_provider_id == provider_id) | (ServiceProfile.organization_id == provider_id)`
- **`update_provider`** (line 1575-1578): Ugyanez a javítás a PATCH végpontban, `provider_obj.id`-t használva
- **Root Cause**: Legacy provider-eknél (pl. ID 6) a ServiceProfile `organization_id` mezőn keresztül kapcsolódik, nem `service_provider_id`-n. Az egyszerű FK keresés miatt a profil nem található -> `category_ids` elveszett a válaszból és a mentésből.
#### Frontend: `frontend_admin/pages/providers/[id]/edit.vue`
- **Import** (line 626): `watch` hozzáadva a Vue import-hoz
- **`parentLookupMap`** (line 942): Flat Map<number, number> a kategória fa rekurzív bejárásához
- **`buildParentLookupMap()`** (line 944): Rekurzív fa bejáró, ami minden gyermek node-hoz hozzárendeli a szülő ID-t
- **`watch(categoryTree, ...)`** (line 956): A map újraépítése minden fa betöltéskor
- **`toggleCategory()`** (line 961): Gyermek kategória kiválasztásakor automatikusan hozzáadja a szülő kategória ID-t is a `form.category_ids` tömbhöz
### ✅ Verifikáció
1. **Backend szintaxis** -> OK: `admin_providers` modul sikeresen betöltve a konténerben
2. **Sync Engine** -> OK: 1282 OK, 0 hiba, adatbázis szinkronban
3. **Frontend** -> OK: Import és `toggleCategory` módosítva, `watch` + `parentLookupMap` beépítve
## 2026-07-07 - P0 BUGFIX: ServiceProfile Auto-Creation on PATCH for New Providers
### 🎯 Cél
P0 kritikus hibajavítás: `PATCH /admin/providers/{id}` 500-as hibát dobott, ha a provider-hez nem tartozott ServiceProfile rekord. A `_sync_service_expertises()` hívás `profile.id`-t használt, de `profile` `None` maradt, mert a ServiceProfile létrehozás feltételes volt (`has_advanced_fields`).
### 🔧 Változtatások
#### 1. Import fix: [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:48)
- `ServiceExpertise` hozzáadva az import-hoz (hiányzott, `NameError`-t okozott futásidőben)
#### 2. ServiceProfile unconditional creation: [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1588)
- **Előtte:** ServiceProfile csak akkor jött létre, ha `has_advanced_fields` True volt
- **Utána:** Ha nincs ServiceProfile, MINDIG létrejön egy alapértelmezett profil (fingerprint: `auto-created-{provider_id}`, status: `active`, trust_score: 30)
- Debug logolás hozzáadva (`print` + `logger.info`) a sync hívás előtt
### ✅ Verifikáció
1. **Sync Engine** -> OK: 1282 OK, 0 hiba, adatbázis szinkronban
2. **E2E teszt** -> ✅ PATCH 200 SUCCESS: provider #21 (Test Provider No Address - Fixed v4) sikeresen frissítve `category_ids=[12, 13, 14]`-gyel
3. **Docker log** -> ✅ `P0 BUGFIX: Auto-created ServiceProfile #24 for Live provider #21`
## 2026-07-07 - P0 HOTFIX: Edit Form Chips & Reactivity Deep Clone
### 🎯 Cél
P0 kritikus hibajavítás: A provider edit űrlapon a kiválasztott kategória chipek nyers ID-kat jelenítettek meg (#760) név helyett, és a "Mentés" gomb disabled maradt kategória toggle után.
### 🔍 Root Cause Analysis
1. **Chip Display**: A template már `{{ getCategoryName(id) }}`-t használt (line 243), és a `useCategories` composable `getCategoryName()` függvénye helyesen visszaadja a nevet, ha a `categoryMap` be van töltve. A `#760` fallback akkor jelenik meg, ha a kategóriák még nem töltődtek be — ez normális átmeneti állapot.
2. **Reactivity Bug (Fő probléma)**: A `mapProviderToForm()` függvényben a `form.category_ids = data.category_ids || []` direkt referenciát másolt, nem deep clone-t. Mivel a `form.category_ids` és a `provider.value.category_ids` ugyanarra a tömbre mutattak, a `hasChanges` computed property sosem érzékelte a változást — a kategória toggle mindkét referenciát egyszerre módosította.
### 🔧 Változtatások
#### [`frontend_admin/pages/providers/[id]/edit.vue`](frontend_admin/pages/providers/[id]/edit.vue:1137)
- **`mapProviderToForm()`** (lines 1137-1148): Minden tömb értékadás deep clone-ra javítva:
- `form.supported_vehicle_classes = [...(data.supported_vehicle_classes || [])]`
- `form.category_ids = [...(data.category_ids || [])]`
- `form.specializations.brands = Array.isArray(spec.brands) ? [...spec.brands] : []`
- `form.specializations.propulsion = Array.isArray(spec.propulsion) ? [...spec.propulsion] : []`
### ✅ Verifikáció
1. **Deep clone ellenőrzés**: `form.category_ids` és `provider.value.category_ids` most külön referenciák — a toggleCategory() hívás után a `hasChanges` helyesen `true`-t ad vissza.
2. **Chip display**: A template már `{{ getCategoryName(id) }}`-t használ (line 243), a `useCategories.getCategoryName()` pedig a `categoryMap`-ből adja vissza a nevet, vagy `#${id}` fallback-et, ha még nem töltődött be.
## 2026-07-07 - P0 EPIC: i18n JSONB Database Migration (Phase 1 & 2)
### 🎯 Cél
Architect által jóváhagyott i18n JSONB migrációs terv ([`plans/logic_spec_i18n_jsonb_migration_phase1.md`](plans/logic_spec_i18n_jsonb_migration_phase1.md)) első két fázisának implementálása: Séma bővítés (Phase 1) és Adatmigráció (Phase 2).
### 🔧 Változtatások
#### Phase 1: Schema Expansion (3 SQLAlchemy model)
1. **`backend/app/models/marketplace/service.py`** — `ExpertiseTag` osztály:
- `name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))`
- `description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)`
- GIN index: `Index('idx_expertise_tags_name_i18n', 'name_i18n', postgresql_using='gin')`
2. **`backend/app/models/vehicle/vehicle_definitions.py`** — `BodyTypeDictionary` osztály:
- `name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))`
- GIN index: `Index('idx_dict_body_types_name_i18n', 'name_i18n', postgresql_using='gin')`
3. **`backend/app/models/core_logic.py`** — `ServiceCatalog` osztály:
- `name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))`
- `description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)`
- GIN index: `Index('idx_service_catalog_name_i18n', 'name_i18n', postgresql_using='gin')`
#### Phase 2: Data Migration Script
4. **`backend/app/scripts/migrate_i18n_jsonb.py`** — Létrehozva:
- Aszinkron migrációs script, amely a régi `name_hu`/`name_en` oszlopokat JSONB struktúrába csomagolja
- Kezeli a `name_translations` meglévő JSONB mező merge-elését is
- `json.dumps()` használata az asyncpg JSONB kompatibilitás miatt
- Idempotens: `ON CONFLICT DO NOTHING` + meglévő adatok felülírása
### 📊 Migrációs Eredmények
| Tábla | Sorok | name_i18n | description_i18n |
|-------|-------|-----------|------------------|
| `marketplace.expertise_tags` | 136 | ✅ `{"hu": "...", "en": "..."}` | ✅ `{"hu": "..."}` |
| `vehicle.dict_body_types` | 16 | ✅ `{"hu": "Szedán", ...}` | ❌ N/A |
| `system.service_catalog` | 3 | ✅ `{"hu": "Adat Export"}` | ✅ `{"hu": "PDF és Excel..."}` |
### 🗄️ GIN Indexek
| Index | Tábla | Típus |
|-------|-------|-------|
| `idx_expertise_tags_name_i18n` | `marketplace.expertise_tags` | GIN on `name_i18n` |
| `idx_dict_body_types_name_i18n` | `vehicle.dict_body_types` | GIN on `name_i18n` |
| `idx_service_catalog_name_i18n` | `system.service_catalog` | GIN on `name_i18n` |
### ✅ Verifikáció
1. **sync_engine futtatva** → 1287 OK, 0 Fixed, 0 Shadow — schema fully in sync
2. **5 új JSONB oszlop** létrehozva a DB-ben (3× `name_i18n`, 2× `description_i18n`)
3. **3 GIN index** manuálisan létrehozva (sync_engine nem kezeli az indexeket)
4. **155 sor** sikeresen migrálva (136 expertise_tags + 16 dict_body_types + 3 service_catalog)
5. **Régi oszlopok** (`name_hu`, `name_en`, `name`, `description`) érintetlenek — backward compatibility biztosítva
## 2026-07-08 - P0 i18n Static Dictionary Cleanup (Admin Frontend)
### 🎯 Cél
Az admin frontend összes providers oldalának és a layout sidebar menüjének teljes i18n konverziója. Minden hardcoded magyar szöveg `$t()` / `t()` hívásokra cserélése, és a hiányzó fordítási kulcsok felvétele a `hu.json` és `en.json` fájlokba.
### 🔧 Változtatások
#### 1. Layout (`default.vue`)
- `menuGroups` array összes label-je i18n kulcsokra cserélve (`menu.center`, `menu.dashboard`, `menu.providers`, stb.)
- `pageTitle` computed property összes hardcoded string i18n kulcsokra cserélve
- Key fix: `menu.ledger``menu.points_ledger`, `gamification.users.detail``gamification.users.user_detail_title`
#### 2. Providers oldalak
- **index.vue** - Teljes konverzió: template + script (`statusLabel()`)
- **pending.vue** - Teljes konverzió: template + script (toast üzenetek)
- **rejected.vue** - Teljes konverzió: template + script (toast üzenetek)
- **[id]/index.vue** - Teljes konverzió: template (minden szekció, modal, drawer) + script (tabs, DAYS, statusLabel, statusDescription, vehicleClassLabel, validationTypeLabel, historyActionLabel, toast üzenetek)
- **[id]/edit.vue** - Teljes konverzió: template (minden tab, szekció, drawer) + script (tabs, VEHICLE_CLASSES, DAYS, copyWeekdays, saveForm toasts)
#### 3. Lokalizációs fájlok
- **hu.json** - ~200+ új providers kulcs (index, pending, rejected, detail, edit)
- **en.json** - ~200+ új providers kulcs angol fordítással
### ✅ Verifikáció
1. **Build**`npm run build` sikeres (sf_admin_frontend konténerben) ✅
2. **Nincs fordítási hiba** - minden használt i18n kulcs létezik a locale fájlokban ✅
## 2026-07-08 - P0 Architectural Upgrade: Modular i18n Locales Migration
### 🎯 Cél
Migrate the Nuxt admin frontend from monolithic i18n locale files (single `hu.json`, `en.json`) to Nuxt i18n Modular Locales (multiple domain-specific JSON files per locale).
### 🔧 Változtatások
1. **`frontend_admin/nuxt.config.ts`** (31. sor): Changed `langDir` from `'i18n/locales/'` to `'locales/'` to avoid path doubling with Nuxt i18n module's automatic `i18nDir` detection. Updated hu and en locale configs to use `files` array pattern instead of single `file`.
2. **`frontend_admin/i18n/locales/hu/common.json`** (CREATED): Extracted all `common.*` keys from old monolithic `hu.json` (loading, saving, error, retry, save, cancel, delete, edit, search, filter, status, permissions, etc.)
3. **`frontend_admin/i18n/locales/en/common.json`** (CREATED): English equivalents of all common.* keys.
4. **`frontend_admin/i18n/locales/hu/menu.json`** (CREATED): Extracted all `menu.*` keys (center, dashboard, providers, garages, users, finance, packages, gamification, system, etc.)
5. **`frontend_admin/i18n/locales/en/menu.json`** (CREATED): English equivalents of all menu.* keys.
6. **`frontend_admin/i18n/locales/hu/providers.json`** (UPDATED): Expanded from ~84 lines to ~280 lines with all provider-related keys from old monolithic file.
7. **`frontend_admin/i18n/locales/en/providers.json`** (UPDATED): English equivalents, expanded similarly.
8. **`frontend_admin/locales/hu.json`** (DELETED): Old monolithic Hungarian locale file (1003 lines).
9. **`frontend_admin/locales/en.json`** (DELETED): Old monolithic English locale file (1003 lines).
### 🔍 Megjegyzések
- A Nuxt i18n modul automatikusan detektálja az `i18n/` könyvtárat a projekt gyökerében, és azt használja `i18nDir`-ként. Ezért a `langDir`-t relatívan kell megadni ehhez a könyvtárhoz (`'locales/'`), nem pedig abszolút módon (`'i18n/locales/'`).
- A többi nyelv (de, fr, ro, cz, sk) továbbra is a régi `frontend_admin/locales/` könyvtárban található monolit fájlokat használja.
- A konténer újraindítás után sikeresen elindult, a Nitro szerver és a Vite klienst is felépítette.
- A `@intlify/message-compiler` `Invalid linked format` hibát dobott az `info@example.com` email placeholder értékben lévő `@` karakter miatt. A javítás: `{'@'}` Intlify szintaxis használata a `hu/providers.json` és `en/providers.json` fájlokban.
## 2026-07-08 - P0 CRITICAL i18n Audit & Repair (HU/EN) - Fix #6: Missing persons.json
### 🎯 Cél
A Nuxt i18n modul `computeLocaleHashes` függvénye `ENOENT` hibát dobott, mert a `persons.json` fájl nem létezett a `frontend_admin/i18n/locales/en/` és `hu/` könyvtárakban, pedig a `nuxt.config.ts` `files` tömbje hivatkozott rá.
### 🔧 Végrehajtott Javítások
1. **`frontend_admin/i18n/locales/en/persons.json`** (LÉTREHOZVA): Angol nyelvű személykezelő fordítások (3456 byte), 80+ kulcs a `persons.*` és `persons.details.*` névtérben.
2. **`frontend_admin/i18n/locales/hu/persons.json`** (LÉTREHOZVA): Magyar nyelvű személykezelő fordítások (3898 byte), azonos struktúrával.
### 📝 Technikai Részletek
- A `persons.*` kulcsokat a `pages/persons/index.vue` (lista nézet) és `pages/persons/[id]/index.vue` (részletek nézet) template-ek használják.
- A hiányzó fájl a `nuxt.config.ts` 24 moduláris fájljának egyike volt, amelyet a Fix #3 során adtunk hozzá a `files` tömbhöz, de a fájl ténylegesen nem létezett a lemezen.
- A `.nuxt` cache törlése (`sudo rm -rf frontend_admin/.nuxt/dev`) és a konténer újraindítása után a build sikeresen lefutott i18n hibák nélkül.
### ✅ Verifikáció
1. **Konténer újraindítva**`docker compose restart sf_admin_frontend`
2. **Build logok**`✔ Vite client built in 40ms`, `✔ Vite server built in 445ms`, `[nitro] ✔ Nuxt Nitro server built in 1526ms`
3. **i18n hibák** → Nincs `ERROR`, nincs `ENOENT`, nincs i18n compilation error ✅

View File

@@ -1188,6 +1188,152 @@ async def review_contribution(
}
# =============================================================================
# VALIDATION RULES (Provider Validation Engine)
# =============================================================================
class ValidationRuleUpdate(BaseModel):
"""Update payload for a single validation rule."""
rule_value: int = Field(..., ge=0, le=100, description="New integer value for this rule (0-100)")
description: Optional[str] = Field(None, max_length=500, description="Updated description")
class ValidationRuleBulkUpdate(BaseModel):
"""Bulk update payload — dict of rule_key → new_value."""
rules: Dict[str, int] = Field(
..., description="Mapping of rule_key → new integer value (0-100)"
)
@router.get("/validation-rules", response_model=List[Dict[str, Any]])
async def list_validation_rules(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_: User = Depends(deps.RequirePermission("gamification:manage")),
):
"""
Fetch all ValidationRule records from the database.
Returns a list of {id, rule_key, rule_value, description, updated_at}.
These rules drive the ValidationEngine for provider scoring and
auto-approval thresholds.
"""
from app.models.gamification.validation_rule import ValidationRule
stmt = select(ValidationRule).order_by(ValidationRule.rule_key)
result = await db.execute(stmt)
rules = result.scalars().all()
return [
{
"id": r.id,
"rule_key": r.rule_key,
"rule_value": r.rule_value,
"description": r.description,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rules
]
@router.put("/validation-rules", response_model=Dict[str, Any])
async def bulk_update_validation_rules(
data: ValidationRuleBulkUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_: User = Depends(deps.RequirePermission("gamification:manage")),
):
"""
Bulk-update ValidationRule values.
Accepts a dict of rule_key → new_value. Iterates through each entry
and updates rule_value in the database. Unknown keys are silently skipped.
Returns a summary of updated rules.
"""
from app.models.gamification.validation_rule import ValidationRule
updated: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
for rule_key, new_value in data.rules.items():
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key)
rule = (await db.execute(stmt)).scalar_one_or_none()
if not rule:
errors.append({"rule_key": rule_key, "error": "Rule not found"})
logger.warning(f"Validation rule '{rule_key}' not found, skipping.")
continue
if not (0 <= new_value <= 100):
errors.append({"rule_key": rule_key, "error": "Value must be between 0 and 100"})
continue
old_val = rule.rule_value
rule.rule_value = new_value
updated.append({
"rule_key": rule_key,
"old_value": old_val,
"new_value": new_value,
})
await db.commit()
return {
"message": f"Updated {len(updated)} rule(s) successfully.",
"updated": updated,
"errors": errors if errors else None,
}
@router.put("/validation-rules/{rule_key}", response_model=Dict[str, Any])
async def update_validation_rule(
rule_key: str,
data: ValidationRuleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_: User = Depends(deps.RequirePermission("gamification:manage")),
):
"""
Update a single ValidationRule by its rule_key.
Allows updating both rule_value and description.
"""
from app.models.gamification.validation_rule import ValidationRule
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key)
rule = (await db.execute(stmt)).scalar_one_or_none()
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Validation rule '{rule_key}' not found.",
)
old_value = rule.rule_value
rule.rule_value = data.rule_value
if data.description is not None:
rule.description = data.description
await db.commit()
await db.refresh(rule)
logger.info(
f"Validation rule '{rule_key}' updated: {old_value}{data.rule_value}"
)
return {
"id": rule.id,
"rule_key": rule.rule_key,
"rule_value": rule.rule_value,
"description": rule.description,
"old_value": old_value,
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None,
"message": f"Rule '{rule_key}' updated successfully.",
}
# =============================================================================
# LEADERBOARD (Admin)
# =============================================================================

View File

@@ -707,17 +707,22 @@ async def get_organization_details(
# 2c. P0 ADD-ON: Telephelyek (branches) lekérése
from app.models.marketplace.organization import Branch # noqa: E402
branches_stmt = select(Branch).where(
Branch.organization_id == org_id,
Branch.is_deleted == False,
).order_by(Branch.name)
branches_stmt = (
select(Branch)
.options(selectinload(Branch.address))
.where(
Branch.organization_id == org_id,
Branch.is_deleted == False,
)
.order_by(Branch.name)
)
branches_result = await db.execute(branches_stmt)
branch_rows = branches_result.scalars().all()
branches_list = [
BranchBrief(
id=str(b.id),
name=b.name,
city=b.city,
city=b.address.city if b.address else None,
is_active=(b.status == "active") if b.status else True,
)
for b in branch_rows
@@ -1319,6 +1324,10 @@ async def update_organization(
# ── Primary address via AddressManager ──
address_detail: Optional[AddressIn] = raw_data.pop("address_detail", None)
if address_detail:
# P0 BUGFIX: model_dump() converts nested Pydantic models to dicts;
# re-wrap into AddressIn before passing to AddressManager
if isinstance(address_detail, dict):
address_detail = AddressIn(**address_detail)
org.address_id = await AddressManager.create_or_update(
db, org.address_id, address_detail
)
@@ -1326,6 +1335,8 @@ async def update_organization(
# ── Billing address via AddressManager ──
billing_address_detail: Optional[AddressIn] = raw_data.pop("billing_address_detail", None)
if billing_address_detail:
if isinstance(billing_address_detail, dict):
billing_address_detail = AddressIn(**billing_address_detail)
org.billing_address_id = await AddressManager.create_or_update(
db, org.billing_address_id, billing_address_detail
)
@@ -1333,6 +1344,8 @@ async def update_organization(
# ── Notification address via AddressManager ──
notification_address_detail: Optional[AddressIn] = raw_data.pop("notification_address_detail", None)
if notification_address_detail:
if isinstance(notification_address_detail, dict):
notification_address_detail = AddressIn(**notification_address_detail)
org.notification_address_id = await AddressManager.create_or_update(
db, org.notification_address_id, notification_address_detail
)

View File

@@ -14,6 +14,12 @@ Végpontok:
POST /admin/providers/{id}/flag — Megjelölés (approved -> flagged)
POST /admin/providers/{id}/restore — Visszaállítás (rejected -> approved)
DELETE /admin/providers/{id} — Törlés (soft delete)
P0 CRITICAL FIX (2026-07-07): Promotion Engine wiring.
- PATCH /admin/providers/{id} on a ServiceStaging record with status="approved"
now triggers the Promotion Engine: creates a ServiceProvider + ServiceProfile,
sets validation_score=100, then deletes the staging record.
- Auto-Validation: Admin approval sets validation_score=100 on the new ServiceProvider.
"""
from __future__ import annotations
@@ -27,6 +33,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import select, func, or_, union_all, literal, cast, String, case
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.api import deps
from app.schemas.address import AddressIn, AddressOut
@@ -38,12 +45,13 @@ from app.models.identity.social import (
SourceType,
ProviderValidation,
)
from app.models.marketplace.service import ServiceProfile
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
from app.models.marketplace.staged_data import ServiceStaging
from app.models.gamification.gamification import UserContribution
from app.models.vehicle.history import AuditLog
from app.services.gamification_service import gamification_service
from app.services.provider_service import _sync_service_expertises
from app.services.address_manager import AddressManager
logger = logging.getLogger("admin-providers")
router = APIRouter()
@@ -55,11 +63,17 @@ router = APIRouter()
class ProviderListItem(BaseModel):
"""Egy szolgáltató rövid adatai a listázáshoz."""
"""Egy szolgáltató rövid adatai a listázáshoz.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressOut] — first-class citizen.
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
"""
id: int
name: str
address: Optional[str] = None
city: Optional[str] = None
address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
address_detail: Optional[AddressOut] = None
category: Optional[str] = None
status: str
source: str
@@ -72,11 +86,16 @@ class ProviderListItem(BaseModel):
class ProviderDetail(BaseModel):
"""Szolgáltató részletes adatai."""
"""Szolgáltató részletes adatai.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressOut] — first-class citizen.
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
"""
id: int
name: str
address: str
city: Optional[str] = None
address: str = Field(..., description="[DEPRECATED] Use address_detail.full_address_text instead")
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
address_detail: Optional[AddressOut] = None
plus_code: Optional[str] = None
contact_phone: Optional[str] = None
@@ -130,10 +149,15 @@ class ModerationResponse(BaseModel):
class ProviderUpdateInput(BaseModel):
"""Provider adatok szerkesztésének bemeneti sémája."""
"""Provider adatok szerkesztésének bemeneti sémája.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressIn] — first-class citizen.
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
"""
name: Optional[str] = Field(None, min_length=1, max_length=255, description="Szolgáltató neve")
address: Optional[str] = Field(None, max_length=500, description="Teljes cím")
city: Optional[str] = Field(None, max_length=100, description="Város")
address: Optional[str] = Field(None, max_length=500, description="[DEPRECATED] Use address_detail.full_address_text instead")
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
address_detail: Optional[AddressIn] = Field(None, description="Cím részletes adatai (egységes AddressIn séma)")
plus_code: Optional[str] = Field(None, max_length=50, description="Plus Code (Google Maps)")
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
@@ -294,6 +318,10 @@ def _build_unified_providers_query(
from app.models.identity.address import Address, GeoPostalCode
# --- 1. ServiceProvider lekérdezés ---
# P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
# Outerjoin to system.addresses and system.geo_postal_codes to pull
# address/city from the normalized Address table instead of flat columns.
# Coalesce to flat columns for backward compatibility during transition.
provider_conditions = []
if status_filter:
provider_conditions.append(ServiceProvider.status == status_filter)
@@ -303,8 +331,12 @@ def _build_unified_providers_query(
provider_stmt = select(
ServiceProvider.id.label("id"),
ServiceProvider.name.label("name"),
ServiceProvider.address.label("address"),
ServiceProvider.city.label("city"),
func.coalesce(
Address.full_address_text, ServiceProvider.address
).label("address"),
func.coalesce(
GeoPostalCode.city, ServiceProvider.city
).label("city"),
ServiceProvider.category.label("category"),
cast(ServiceProvider.status, String).label("status"),
cast(ServiceProvider.source, String).label("source"),
@@ -312,6 +344,14 @@ def _build_unified_providers_query(
ServiceProvider.validation_score.label("validation_score"),
ServiceProvider.added_by_user_id.label("added_by_user_id"),
ServiceProvider.created_at.label("created_at"),
# P0 PHASE 2 ADDRESS UNIFICATION: Include address_id for address_detail resolution
ServiceProvider.address_id.label("address_id"),
).outerjoin(
Address,
Address.id == ServiceProvider.address_id,
).outerjoin(
GeoPostalCode,
GeoPostalCode.id == Address.postal_code_id,
)
if provider_conditions:
provider_stmt = provider_stmt.where(*provider_conditions)
@@ -341,6 +381,8 @@ def _build_unified_providers_query(
literal(0).label("validation_score"),
literal(None).label("added_by_user_id"),
ServiceStaging.created_at.label("created_at"),
# P0 PHASE 2: Placeholder for UNION ALL column count parity
literal(None).label("address_id"),
)
if staging_conditions:
staging_stmt = staging_stmt.where(*staging_conditions)
@@ -375,6 +417,8 @@ def _build_unified_providers_query(
literal(100).label("validation_score"),
literal(None).label("added_by_user_id"),
Organization.created_at.label("created_at"),
# P0 PHASE 2: Placeholder for UNION ALL column count parity
literal(None).label("address_id"),
).outerjoin(
Address,
Address.id == Organization.address_id,
@@ -463,11 +507,25 @@ async def list_providers(
items = []
for row in rows:
# P0 PHASE 2 ADDRESS UNIFICATION: Resolve address_detail from address_id
row_address_detail = None
row_address_id = getattr(row, "address_id", None)
if row_address_id:
try:
addr_data = await AddressManager.get_normalized(db, row_address_id)
if addr_data:
row_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for list row #{row.id}: {e}"
)
items.append(ProviderListItem(
id=row.id,
name=row.name,
address=row.address,
city=row.city,
address_detail=row_address_detail,
category=row.category,
status=row.status,
source=row.source,
@@ -548,10 +606,15 @@ async def get_provider_detail(
)
sp = provider.scalar_one_or_none()
if sp:
# Lekérdezzük a kapcsolódó kategória ID-kat a ServiceProfile-on keresztül
# P0 BUGFIX (2026-07-07): Resolve category_ids from ServiceProfile → ServiceExpertise.
# Search by BOTH service_provider_id AND organization_id.
# ServiceProvider ID 6 (and other legacy providers) may have their ServiceProfile
# linked via organization_id instead of service_provider_id. Using OR ensures
# we find the profile regardless of which FK is populated.
resolved_category_ids: Optional[List[int]] = None
profile_stmt = select(ServiceProfile).where(
ServiceProfile.service_provider_id == provider_id
(ServiceProfile.service_provider_id == provider_id) |
(ServiceProfile.organization_id == provider_id)
)
profile_result = await db.execute(profile_stmt)
profile = profile_result.scalar_one_or_none()
@@ -561,16 +624,35 @@ async def get_provider_detail(
)
expertise_result = await db.execute(expertise_stmt)
resolved_category_ids = [row[0] for row in expertise_result.fetchall()]
else:
# P0 FINAL FIX (2026-07-07): If no ServiceProfile exists (e.g. legacy provider #6),
# return empty list instead of null. The PATCH endpoint auto-creates a ServiceProfile
# when category_ids are saved, but GET must not crash or return null for providers
# that haven't been edited yet.
resolved_category_ids = []
# Get opening_hours from the ServiceProfile (JSONB column)
opening_hours = profile.opening_hours if profile else {}
# P0 BUGFIX: Resolve address_detail from address_id if present
live_address_detail = None
live_address_id = getattr(sp, "address_id", None)
if live_address_id:
try:
addr_data = await AddressManager.get_normalized(db, live_address_id)
if addr_data:
live_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for provider #{sp.id}: {e}"
)
return ProviderDetail(
id=sp.id,
name=sp.name,
address=sp.address or "",
city=sp.city,
address_detail=None,
address_detail=live_address_detail,
plus_code=sp.plus_code,
contact_phone=sp.contact_phone,
contact_email=sp.contact_email,
@@ -586,9 +668,13 @@ async def get_provider_detail(
supported_vehicle_classes=sp.supported_vehicle_classes or [],
specializations=sp.specializations or {},
opening_hours=opening_hours or {},
# P0 BUGFIX: Include raw_data for ServiceProvider records so the frontend
# "Nyers adatok / Crawler" tab shows actual data instead of empty/mock state
raw_data=sp.raw_data or {},
# P0 BUGFIX: ServiceProvider model does NOT have raw_data, trust_score,
# rejection_reason, or audit_trail columns — those exist only on ServiceStaging.
# Explicitly pass None to avoid Pydantic/AttributeError 500 crash.
raw_data=None,
trust_score=None,
rejection_reason=None,
audit_trail=None,
created_at=sp.created_at,
)
@@ -598,11 +684,24 @@ async def get_provider_detail(
)
ss = staging.scalar_one_or_none()
if ss:
# P0 CRITICAL BUGFIX: Resolve address_detail from address_id if present
staging_address_detail = None
if ss.address_id:
try:
addr_data = await AddressManager.get_normalized(db, ss.address_id)
if addr_data:
staging_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for staging #{ss.id}: {e}"
)
return ProviderDetail(
id=ss.id,
name=ss.name,
address=ss.full_address or "",
city=ss.city,
address_detail=staging_address_detail,
contact_phone=ss.contact_phone,
contact_email=ss.contact_email,
website=ss.website,
@@ -1142,64 +1241,104 @@ async def update_provider(
Minden változás auditálásra kerül az AuditLog táblában.
"""
provider = await _get_provider_or_404(db, provider_id)
# P0 BUGFIX: First try ServiceProvider, then fall back to ServiceStaging
# (same pattern as get_provider_detail endpoint)
provider = await db.execute(
select(ServiceProvider).where(ServiceProvider.id == provider_id)
)
sp = provider.scalar_one_or_none()
staging_record = None
if sp:
# Found in ServiceProvider — use it
provider_obj = sp
is_staging = False
else:
# Try ServiceStaging
staging = await db.execute(
select(ServiceStaging).where(ServiceStaging.id == provider_id)
)
ss = staging.scalar_one_or_none()
if not ss:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Provider with ID {provider_id} not found in any table.",
)
provider_obj = ss
is_staging = True
# Változások nyomon követése auditáláshoz
old_status = provider.status.value if hasattr(provider.status, 'value') else str(provider.status)
old_status = provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status)
old_data = {
"name": provider.name,
"city": provider.city,
"address_zip": provider.address_zip,
"address_street_name": provider.address_street_name,
"address_street_type": provider.address_street_type,
"address_house_number": provider.address_house_number,
"plus_code": provider.plus_code,
"contact_phone": provider.contact_phone,
"contact_email": provider.contact_email,
"website": provider.website,
"category": provider.category,
"name": provider_obj.name,
"city": provider_obj.city,
"address_zip": getattr(provider_obj, 'address_zip', None),
"address_street_name": getattr(provider_obj, 'address_street_name', None),
"address_street_type": getattr(provider_obj, 'address_street_type', None),
"address_house_number": getattr(provider_obj, 'address_house_number', None),
"plus_code": getattr(provider_obj, 'plus_code', None),
"contact_phone": provider_obj.contact_phone,
"contact_email": provider_obj.contact_email,
"website": provider_obj.website,
"category": getattr(provider_obj, 'category', None),
"status": old_status,
"validation_score": provider.validation_score,
"supported_vehicle_classes": provider.supported_vehicle_classes,
"specializations": provider.specializations,
"validation_score": getattr(provider_obj, 'validation_score', 0),
"supported_vehicle_classes": getattr(provider_obj, 'supported_vehicle_classes', None),
"specializations": getattr(provider_obj, 'specializations', None),
}
# Mezők frissítése (csak a nem None értékeket)
update_fields = update_data.model_dump(exclude_none=True)
# =====================================================================
# P0 CRITICAL BUGFIX: Staging records (ServiceStaging) have a different
# column set than ServiceProvider. ServiceStaging does NOT have:
# address_zip, address_street_name, address_street_type,
# address_house_number, address_stairwell, address_floor, address_door,
# address_hrsz, latitude, longitude, category, validation_score,
# supported_vehicle_classes, specializations
# For staging records, we must:
# 1. Apply ONLY flat columns that exist on ServiceStaging
# 2. Merge complex data (address_detail, specializations, category_ids)
# into raw_data so they are preserved for the Promotion Engine
# =====================================================================
# =====================================================================
# STEP 0: Extract address_detail (AddressIn) and map to flat model fields
# =====================================================================
address_detail: Optional[dict] = update_fields.pop("address_detail", None)
if address_detail:
# Map AddressIn fields to ServiceProvider flat columns
addr_mapping = {
"zip": "address_zip",
"city": None, # city is a direct column, handled separately
"street_name": "address_street_name",
"street_type": "address_street_type",
"house_number": "address_house_number",
"stairwell": "address_stairwell",
"floor": "address_floor",
"door": "address_door",
"parcel_id": "address_hrsz",
"full_address_text": None, # maps to address field
"latitude": None, # maps to latitude
"longitude": None, # maps to longitude
}
for addr_field, model_field in addr_mapping.items():
if addr_field in address_detail and address_detail[addr_field] is not None:
if model_field:
update_fields[model_field] = address_detail[addr_field]
elif addr_field == "full_address_text":
update_fields["address"] = address_detail[addr_field]
elif addr_field == "latitude":
update_fields["latitude"] = address_detail[addr_field]
elif addr_field == "longitude":
update_fields["longitude"] = address_detail[addr_field]
elif addr_field == "city":
update_fields["city"] = address_detail[addr_field]
if is_staging and address_detail:
# P0 CRITICAL BUGFIX: Use AddressManager to create/update the Address
# record in system.addresses, then link it via address_id on ServiceStaging.
# This ensures the address_detail is NOT lost — it's stored in the
# normalized Address table and can be returned in the API response.
addr_in = AddressIn(**address_detail)
new_address_id = await AddressManager.create_or_update(
db=db,
address_id=getattr(provider_obj, "address_id", None),
data=addr_in,
)
if new_address_id:
provider_obj.address_id = new_address_id
# Also update full_address if full_address_text is provided
if address_detail.get("full_address_text"):
update_fields["full_address"] = address_detail["full_address_text"]
if address_detail.get("city"):
update_fields["city"] = address_detail["city"]
elif address_detail:
# P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
# Use AddressManager to create/update the normalized system.addresses
# record and link it via address_id on ServiceProvider.
# Flat column writes (address_zip, address_street_name, etc.) are
# STRICTLY REMOVED — AddressManager is the single source of truth.
addr_in = AddressIn(**address_detail)
new_address_id = await AddressManager.create_or_update(
db=db,
address_id=getattr(provider_obj, "address_id", None),
data=addr_in,
)
if new_address_id:
provider_obj.address_id = new_address_id
# =====================================================================
# STEP 1: Extract multi-dimensional arrays and JSONB from payload
# =====================================================================
@@ -1235,89 +1374,307 @@ async def update_provider(
new_status = update_fields.get("status")
if new_status and new_status != old_status:
# Ha státusz változik, naplózzuk külön akcióként
provider.status = ModerationStatus(new_status)
if not is_staging:
provider_obj.status = ModerationStatus(new_status)
else:
provider_obj.status = new_status
if new_status == "approved" and old_status == "rejected":
# Restore esetén validation_score visszaállítás
if "validation_score" not in update_fields:
provider.validation_score = 50
if hasattr(provider_obj, 'validation_score'):
provider_obj.validation_score = 50
elif new_status == "rejected":
# Reject/flag esetén validation_score csökkentés
if "validation_score" not in update_fields:
provider.validation_score = max(0, (provider.validation_score or 0) - 20)
if hasattr(provider_obj, 'validation_score'):
provider_obj.validation_score = max(0, (provider_obj.validation_score or 0) - 20)
# Töröljük a status-t a sima mezőkből, mert külön kezeltük
del update_fields["status"]
# =====================================================================
# STEP 2: Save supported_vehicle_classes and specializations to ServiceProvider
# P0 CRITICAL FIX (2026-07-07 v2): PROMOTION ENGINE
# When a ServiceStaging record is approved by admin, promote it to a
# live ServiceProvider + ServiceProfile, then delete the staging record.
#
# P0 CRITICAL BUGFIX v2 (2026-07-07):
# - Trigger condition now checks provider_obj.status directly (the actual
# DB state after update) instead of relying on new_status from update_fields.
# This ensures promotion fires even if status was already "approved" in DB
# or if status wasn't explicitly passed in the PATCH payload.
# - After promotion, ALL references to provider_id (AuditLog, profile lookup)
# now use new_provider.id, NOT the old staging provider_id.
# - Added logger.error("P0 PROMOTION TRIGGERED") for Docker traceability.
# =====================================================================
if supported_vehicle_classes is not None:
provider.supported_vehicle_classes = supported_vehicle_classes
if specializations is not None:
provider.specializations = specializations
# Többi mező frissítése
for field, value in update_fields.items():
if field != "status":
setattr(provider, field, value)
# =====================================================================
# STEP 3: Sync expertise tags and arrays to ServiceProfile
# =====================================================================
# Find the ServiceProfile linked to this ServiceProvider
profile_stmt = select(ServiceProfile).where(
ServiceProfile.service_provider_id == provider_id
# Determine the effective status: use the newly set status on provider_obj,
# or fall back to the current DB status. This is more robust than relying
# on new_status from update_fields, which may be None if status wasn't
# explicitly included in the PATCH payload.
effective_status = (
provider_obj.status.value
if hasattr(provider_obj.status, 'value')
else str(provider_obj.status)
)
profile_result = await db.execute(profile_stmt)
profile = profile_result.scalar_one_or_none()
if is_staging and effective_status == "approved":
logger.error(
f"P0 PROMOTION TRIGGERED: Staging #{provider_id} "
f"('{provider_obj.name}') effective_status='{effective_status}', "
f"new_status_from_payload='{new_status}', "
f"approved by admin #{current_user.id}. Promoting to live..."
)
if profile:
# Save arrays to ServiceProfile as well
if supported_vehicle_classes is not None:
profile.supported_vehicle_classes = supported_vehicle_classes
if specializations is not None:
profile.specializations = specializations
if opening_hours is not None:
profile.opening_hours = opening_hours
# 1. Create ServiceProvider from staging data
from app.models.identity.social import SourceType
new_provider = ServiceProvider(
name=provider_obj.name,
address=provider_obj.full_address or "",
city=provider_obj.city,
address_id=getattr(provider_obj, "address_id", None),
plus_code=getattr(provider_obj, "plus_code", None),
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
category=update_fields.get("category") or getattr(provider_obj, "category", None),
status=ModerationStatus.approved,
source=SourceType.manual,
validation_score=100, # Auto-Validation: admin approval = 100
added_by_user_id=current_user.id,
)
db.add(new_provider)
await db.flush()
# Sync expertise tags if category_ids provided
if category_ids is not None:
# 2. Create ServiceProfile linked to the new ServiceProvider
profile = ServiceProfile(
service_provider_id=new_provider.id,
fingerprint=getattr(provider_obj, "fingerprint", None),
status="active",
trust_score=100,
is_verified=True,
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
opening_hours=opening_hours or {},
)
db.add(profile)
await db.flush()
# 3. Sync category_ids if provided (from raw_data or direct input)
promoted_category_ids = category_ids
if not promoted_category_ids and provider_obj.raw_data:
promoted_category_ids = provider_obj.raw_data.get("category_ids")
if promoted_category_ids:
await _sync_service_expertises(
db=db,
service_profile_id=profile.id,
category_ids=category_ids,
category_ids=promoted_category_ids,
)
logger.info(
f"ServiceExpertise synced for provider #{provider_id}: "
f"service_profile_id={profile.id}, category_ids={category_ids}"
f"P0 PROMOTION: ServiceExpertise synced for new provider "
f"#{new_provider.id}, category_ids={promoted_category_ids}"
)
# 4. Log the promotion — use new_provider.id, NOT the old staging provider_id
await _log_moderation_action(
db=db,
user_id=current_user.id,
action="PROVIDER_PROMOTED_FROM_STAGING",
provider_id=new_provider.id,
reason=f"Promoted from ServiceStaging #{provider_id} by admin",
old_status=old_status,
new_status="approved",
source_table="service_staging",
)
# 5. Delete the staging record (promotion complete)
await db.delete(provider_obj)
await db.flush()
logger.info(
f"P0 PROMOTION COMPLETE: Staging #{provider_id}"
f"ServiceProvider #{new_provider.id}, "
f"ServiceProfile #{profile.id}, validation_score=100"
)
# Switch to the new provider for the response
provider_obj = new_provider
is_staging = False
# Reset update_fields since we've already created everything
update_fields.clear()
# =====================================================================
# STEP 2: Save supported_vehicle_classes and specializations
# =====================================================================
if is_staging:
# P0 BUGFIX: ServiceStaging has no supported_vehicle_classes/specializations columns.
# Merge them into raw_data for the Promotion Engine.
# CRITICAL: Use flag_modified() so SQLAlchemy detects the JSONB mutation.
raw_data_mutated = False
if supported_vehicle_classes is not None or specializations is not None or category_ids is not None or opening_hours is not None:
if not provider_obj.raw_data:
provider_obj.raw_data = {}
if supported_vehicle_classes is not None:
provider_obj.raw_data["supported_vehicle_classes"] = supported_vehicle_classes
raw_data_mutated = True
if specializations is not None:
provider_obj.raw_data["specializations"] = specializations
raw_data_mutated = True
if category_ids is not None:
provider_obj.raw_data["category_ids"] = category_ids
raw_data_mutated = True
if opening_hours is not None:
provider_obj.raw_data["opening_hours"] = opening_hours
raw_data_mutated = True
if raw_data_mutated:
flag_modified(provider_obj, "raw_data")
else:
if hasattr(provider_obj, 'supported_vehicle_classes') and supported_vehicle_classes is not None:
provider_obj.supported_vehicle_classes = supported_vehicle_classes
if hasattr(provider_obj, 'specializations') and specializations is not None:
provider_obj.specializations = specializations
# =====================================================================
# STEP 2b: For staging records, strip fields that don't exist on ServiceStaging
# =====================================================================
if is_staging:
# P0 BUGFIX: Only keep fields that actually exist on ServiceStaging model
staging_allowed_fields = {
"name", "city", "full_address", "contact_phone", "website",
"contact_email", "description", "status", "plus_code",
"source", "trust_score", "rejection_reason",
}
# Map 'address' from ProviderUpdateInput to 'full_address' on ServiceStaging
if "address" in update_fields:
update_fields["full_address"] = update_fields.pop("address")
# Remove fields that don't exist on ServiceStaging
raw_data_mutated = False
for field in list(update_fields.keys()):
if field not in staging_allowed_fields:
# Merge unknown fields into raw_data so they aren't lost
if field not in ("status",):
if not provider_obj.raw_data:
provider_obj.raw_data = {}
provider_obj.raw_data[field] = update_fields[field]
raw_data_mutated = True
del update_fields[field]
if raw_data_mutated:
flag_modified(provider_obj, "raw_data")
# Többi mező frissítése
for field, value in update_fields.items():
if field != "status" and hasattr(provider_obj, field):
setattr(provider_obj, field, value)
# =====================================================================
# STEP 3: Sync expertise tags and arrays to ServiceProfile
# (Only for ServiceProvider records that have a linked ServiceProfile)
# =====================================================================
profile = None
if not is_staging:
# P0 CRITICAL BUGFIX v2: Use provider_obj.id instead of provider_id.
# After promotion, provider_obj points to the NEW ServiceProvider,
# but provider_id still holds the old staging ID (e.g., 1754).
# Using provider_obj.id ensures we find the correct ServiceProfile.
# P0 BUGFIX (2026-07-07): Search by BOTH service_provider_id AND
# organization_id. ServiceProvider ID 6 (and other legacy providers)
# may have their ServiceProfile linked via organization_id instead of
# service_provider_id. Using OR ensures we find the profile regardless
# of which FK is populated.
profile_stmt = select(ServiceProfile).where(
(ServiceProfile.service_provider_id == provider_obj.id) |
(ServiceProfile.organization_id == provider_obj.id)
)
profile_result = await db.execute(profile_stmt)
profile = profile_result.scalar_one_or_none()
# P0 BUGFIX (2026-07-07): If no ServiceProfile exists for this Live provider,
# CREATE ONE automatically so that advanced fields (opening_hours,
# specializations, supported_vehicle_classes, category_ids) are not silently lost.
# P0 CRITICAL BUGFIX (2026-07-07): ServiceProfile creation is now UNCONDITIONAL.
# Previously, the profile was only created if has_advanced_fields was True.
# This caused a 500 error when a PATCH with category_ids was sent for a newly
# created provider that had no ServiceProfile yet, because _sync_service_expertises
# requires a valid profile.id. Now we ALWAYS create the profile if it doesn't exist.
if not profile:
profile = ServiceProfile(
service_provider_id=provider_obj.id,
fingerprint=f"auto-created-{provider_obj.id}",
status="active",
trust_score=30,
is_verified=False,
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
opening_hours=opening_hours or {},
supported_vehicle_classes=supported_vehicle_classes or [],
specializations=specializations or {},
)
db.add(profile)
await db.flush()
logger.info(
f"P0 BUGFIX: Auto-created ServiceProfile #{profile.id} "
f"for Live provider #{provider_obj.id} ('{provider_obj.name}')"
)
# P0 CRITICAL BUGFIX (2026-07-07): Debug logging to trace profile.id
# before _sync_service_expertises call. This ensures we can identify
# the exact state in Docker logs if the 500 error recurs.
print(f"DEBUG: Syncing categories for profile_id: {profile.id}")
logger.info(
f"P0 DEBUG: ServiceProfile resolved — profile_id={profile.id}, "
f"provider_id={provider_obj.id}, category_ids={category_ids}"
)
if profile:
# Save arrays to ServiceProfile as well
if supported_vehicle_classes is not None:
profile.supported_vehicle_classes = supported_vehicle_classes
if specializations is not None:
profile.specializations = specializations
if opening_hours is not None:
profile.opening_hours = opening_hours
# Sync expertise tags if category_ids provided
if category_ids is not None:
await _sync_service_expertises(
db=db,
service_profile_id=profile.id,
category_ids=category_ids,
)
logger.info(
f"ServiceExpertise synced for provider #{provider_id}: "
f"service_profile_id={profile.id}, category_ids={category_ids}"
)
# Új állapot rögzítése
new_status_val = provider.status.value if hasattr(provider.status, 'value') else str(provider.status)
new_status_val = provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status)
# AuditLog - változások részletes naplózása
new_data_snapshot = {
"name": provider.name,
"city": provider.city,
"address_zip": provider.address_zip,
"address_street_name": provider.address_street_name,
"address_street_type": provider.address_street_type,
"address_house_number": provider.address_house_number,
"plus_code": provider.plus_code,
"contact_phone": provider.contact_phone,
"contact_email": provider.contact_email,
"website": provider.website,
"category": provider.category,
"name": provider_obj.name,
"city": provider_obj.city,
"address_zip": getattr(provider_obj, 'address_zip', None),
"address_street_name": getattr(provider_obj, 'address_street_name', None),
"address_street_type": getattr(provider_obj, 'address_street_type', None),
"address_house_number": getattr(provider_obj, 'address_house_number', None),
"plus_code": getattr(provider_obj, 'plus_code', None),
"contact_phone": provider_obj.contact_phone,
"contact_email": provider_obj.contact_email,
"website": provider_obj.website,
"category": getattr(provider_obj, 'category', None),
"status": new_status_val,
"validation_score": provider.validation_score,
"supported_vehicle_classes": provider.supported_vehicle_classes,
"specializations": provider.specializations,
"validation_score": getattr(provider_obj, 'validation_score', 0),
"supported_vehicle_classes": getattr(provider_obj, 'supported_vehicle_classes', None),
"specializations": getattr(provider_obj, 'specializations', None),
}
# P0 CRITICAL BUGFIX v2: Use provider_obj.id instead of provider_id.
# After promotion, provider_obj points to the NEW ServiceProvider,
# but provider_id still holds the old staging ID (e.g., 1754).
# The AuditLog must reference the new provider's ID.
log_entry = AuditLog(
user_id=current_user.id,
action="PROVIDER_UPDATED",
target_type="service_provider",
target_id=str(provider_id),
target_id=str(provider_obj.id),
old_data=old_data,
new_data={
"reason": "Admin edit",
@@ -1338,33 +1695,94 @@ async def update_provider(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Database integrity error. Check constraints (e.g., duplicate data, invalid status value).",
)
await db.refresh(provider)
await db.refresh(provider_obj)
# Get opening_hours from the ServiceProfile for the response
opening_hours_response = {}
resolved_category_ids: Optional[List[int]] = None
if profile:
opening_hours_response = profile.opening_hours or {}
# P0 BUGFIX: Resolve category_ids from the actual DB state (ServiceExpertise)
# instead of relying on the payload value, which may be stale or None.
expertise_stmt = select(ServiceExpertise.expertise_id).where(
ServiceExpertise.service_id == profile.id
)
expertise_result = await db.execute(expertise_stmt)
resolved_category_ids = [row[0] for row in expertise_result.fetchall()]
return ProviderDetail(
id=provider.id,
name=provider.name,
address=provider.address or "",
city=provider.city,
address_detail=None,
plus_code=provider.plus_code,
contact_phone=provider.contact_phone,
contact_email=provider.contact_email,
website=provider.website,
category=provider.category,
status=provider.status.value if hasattr(provider.status, 'value') else str(provider.status),
source=provider.source.value if hasattr(provider.source, 'value') else str(provider.source),
source_table="service_providers",
validation_score=provider.validation_score or 0,
evidence_image_path=provider.evidence_image_path,
added_by_user_id=provider.added_by_user_id,
category_ids=category_ids,
supported_vehicle_classes=provider.supported_vehicle_classes or [],
specializations=provider.specializations or {},
opening_hours=opening_hours_response,
created_at=provider.created_at,
)
if not is_staging:
# P0 BUGFIX: Resolve address_detail from address_id if present
live_address_detail = None
live_address_id = getattr(provider_obj, "address_id", None)
if live_address_id:
try:
addr_data = await AddressManager.get_normalized(db, live_address_id)
if addr_data:
live_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for provider #{provider_obj.id}: {e}"
)
return ProviderDetail(
id=provider_obj.id,
name=provider_obj.name,
address=provider_obj.address or "",
city=provider_obj.city,
address_detail=live_address_detail,
plus_code=provider_obj.plus_code,
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
category=provider_obj.category,
status=provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status),
source=provider_obj.source.value if hasattr(provider_obj.source, 'value') else str(provider_obj.source),
source_table="service_providers",
validation_score=getattr(provider_obj, 'validation_score', 0) or 0,
evidence_image_path=getattr(provider_obj, 'evidence_image_path', None),
added_by_user_id=getattr(provider_obj, 'added_by_user_id', None),
category_ids=resolved_category_ids or category_ids,
supported_vehicle_classes=getattr(provider_obj, 'supported_vehicle_classes', None) or [],
specializations=getattr(provider_obj, 'specializations', None) or {},
opening_hours=opening_hours_response,
raw_data=None,
trust_score=None,
rejection_reason=None,
audit_trail=None,
created_at=provider_obj.created_at,
)
else:
# Staging record response
# P0 CRITICAL BUGFIX: Resolve address_detail from address_id if present
staging_address_detail = None
staging_address_id = getattr(provider_obj, "address_id", None)
if staging_address_id:
try:
addr_data = await AddressManager.get_normalized(db, staging_address_id)
if addr_data:
staging_address_detail = AddressOut(**addr_data)
except Exception as e:
logger.warning(
f"Failed to resolve address_detail for staging #{provider_obj.id}: {e}"
)
return ProviderDetail(
id=provider_obj.id,
name=provider_obj.name,
address=provider_obj.full_address or "",
city=provider_obj.city,
address_detail=staging_address_detail,
plus_code=getattr(provider_obj, 'plus_code', None),
contact_phone=provider_obj.contact_phone,
contact_email=provider_obj.contact_email,
website=provider_obj.website,
description=getattr(provider_obj, 'description', None),
status=provider_obj.status,
source=provider_obj.source or "bot",
source_table="service_staging",
validation_score=getattr(provider_obj, 'trust_score', 0) or 0,
trust_score=provider_obj.trust_score,
rejection_reason=provider_obj.rejection_reason,
raw_data=provider_obj.raw_data or {},
audit_trail=provider_obj.audit_trail or {},
created_at=provider_obj.created_at,
)

View File

@@ -97,6 +97,9 @@ async def list_body_types(
"id": r.id,
"vehicle_class": r.vehicle_class,
"code": r.code,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": r.name_i18n if r.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
"name_hu": r.name_hu,
}
for r in rows

View File

@@ -15,9 +15,10 @@ import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, or_
from sqlalchemy import select, or_, cast, String
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from sqlalchemy.dialects.postgresql import JSONB
from app.db.session import get_db
from app.api.deps import get_current_user
@@ -76,6 +77,10 @@ async def get_category_tree(
nodes.append(CategoryTreeNode(
id=tag.id,
key=tag.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=tag.name_i18n if tag.name_i18n else {},
description_i18n=tag.description_i18n,
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=tag.name_hu,
name_en=tag.name_en,
level=tag.level,
@@ -106,16 +111,19 @@ async def autocomplete_categories(
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).
A keresés a name_i18n JSONB (minden nyelven), key és name_hu/name_en mezőkben történik.
"""
try:
# --- 🏗️ i18n JSONB Migration (Phase 3): JSONB → String cast keresés ---
stmt = select(ExpertiseTag).where(
ExpertiseTag.level >= 2,
ExpertiseTag.is_official == True,
or_(
cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
ExpertiseTag.key.ilike(f"%{q}%"),
# --- [DEPRECATED] Old column fallback (Phase 3) ---
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)
@@ -126,6 +134,9 @@ async def autocomplete_categories(
CategoryAutocompleteItem(
id=t.id,
key=t.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=t.name_i18n if t.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=t.name_hu,
name_en=t.name_en,
level=t.level,
@@ -164,6 +175,9 @@ async def list_expertise_categories(
ExpertiseCategoryOut(
id=t.id,
key=t.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=t.name_i18n if t.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=t.name_hu,
name_en=t.name_en,
category=t.category,

View File

@@ -42,6 +42,7 @@ from .identity.social import ServiceProvider, Vote, Competition, UserScore, Serv
# 9. Rendszer, Gamification és egyebek
from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season
from .gamification.validation_rule import ValidationRule
from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging
from .system.rbac import SystemRole, SystemPermission, SystemRolePermission
@@ -75,7 +76,7 @@ __all__ = [
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution",
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution", "ValidationRule",
"SystemParameter", "ParameterScope", "InternalNotification",

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
from typing import Optional, List, Any
from datetime import datetime # Python saját típusa a típusjelöléshez
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
@@ -243,11 +243,17 @@ class ServiceCatalog(Base):
Tábla: system.service_catalog
"""
__tablename__ = "service_catalog"
__table_args__ = {"schema": "system"}
__table_args__ = (
Index('idx_service_catalog_name_i18n', 'name_i18n', postgresql_using='gin'),
{"schema": "system"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
name: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[Optional[str]] = mapped_column(String)
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@@ -9,6 +9,7 @@ from .gamification import (
UserContribution,
Season,
)
from .validation_rule import ValidationRule
__all__ = [
"PointRule",
@@ -19,4 +20,5 @@ __all__ = [
"UserBadge",
"UserContribution",
"Season",
"ValidationRule",
]

View File

@@ -0,0 +1,53 @@
# /opt/docker/dev/service_finder/backend/app/models/gamification/validation_rule.py
"""
ValidationRule model — Gamification schema.
Stores dynamic global provider validation rules (thresholds) that drive
the ValidationEngine. Rules are key-value pairs with integer values,
seeded at initialization and editable via the admin panel.
Schema: gamification.validation_rules
"""
from typing import Optional
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ValidationRule(Base):
"""
Global provider validation rules / thresholds.
Each row defines a single rule_key → rule_value mapping used by
the ValidationEngine to compute trust scores and auto-approval
thresholds for service providers.
Seed data (base rules):
BOT_BASE_SCORE = 20 # Default trust score for robot-discovered entries
FIRST_ENTRY_SCORE = 40 # Score awarded for the first user entry
USER_CONFIRM_BASE = 20 # Base score per user confirmation vote
AUTO_APPROVE_THRESHOLD = 80 # Minimum validation_level to auto-promote
"""
__tablename__ = "validation_rules"
__table_args__ = {"schema": "gamification", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
rule_key: Mapped[str] = mapped_column(
String(100), unique=True, nullable=False, index=True,
comment="Unique rule identifier, e.g. 'BOT_BASE_SCORE'"
)
rule_value: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
comment="Integer value for this rule (score, threshold, etc.)"
)
description: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True,
comment="Human-readable description of what this rule controls"
)
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()
)

View File

@@ -57,7 +57,7 @@ class Address(Base):
# Robot és térképes funkciók számára
latitude: Mapped[Optional[float]] = mapped_column(Float)
longitude: Mapped[Optional[float]] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), default=func.now())
# Kapcsolat az irányítószám táblához (zip/city lekéréshez)
postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined")

View File

@@ -2,13 +2,16 @@
import enum
import uuid
from datetime import datetime
from typing import Optional, List
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import String, Integer, ForeignKey, DateTime, Boolean, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB, ARRAY
from sqlalchemy.sql import func
from app.database import Base
if TYPE_CHECKING:
from app.models.identity.address import Address
class ModerationStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
@@ -34,7 +37,12 @@ class ServiceProvider(Base):
address: Mapped[str] = mapped_column(String, nullable=False)
category: Mapped[Optional[str]] = mapped_column(String)
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők ===
# === P0 ADDRESS UNIFICATION: Normalizált cím FK a system.addresses táblához ===
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
)
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők (DEPRECATED - Phase 3-ban törlendő) ===
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
@@ -68,6 +76,11 @@ class ServiceProvider(Base):
JSONB, server_default=text("'{}'::jsonb"), nullable=True
)
# === P0 ADDRESS UNIFICATION: Kapcsolat a normalizált címhez ===
address_rel: Mapped[Optional["Address"]] = relationship(
"Address", foreign_keys=[address_id], lazy="selectin"
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Vote(Base):

View File

@@ -35,7 +35,7 @@ class ServiceProfile(Base):
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
location: Mapped[Any] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), index=True)
location: Mapped[Optional[Any]] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), nullable=True, index=True)
status: Mapped[ServiceStatus] = mapped_column(
SQLEnum(ServiceStatus, name="service_status", schema="marketplace"),
@@ -105,6 +105,7 @@ class ExpertiseTag(Base):
__tablename__ = "expertise_tags"
__table_args__ = (
Index('idx_expertise_path', 'path'),
Index('idx_expertise_tags_name_i18n', 'name_i18n', postgresql_using='gin'),
{"schema": "marketplace"}
)
@@ -112,6 +113,9 @@ class ExpertiseTag(Base):
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name_hu: Mapped[Optional[str]] = mapped_column(String(100))
name_en: Mapped[Optional[str]] = mapped_column(String(100))
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
name_translations: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)

View File

@@ -1,8 +1,9 @@
import uuid
from datetime import datetime
from typing import Optional, Any
from sqlalchemy import String, Integer, DateTime, text, Boolean, Float, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
from sqlalchemy.sql import func
from app.database import Base # MB 2.0 Standard: Központi bázis használata
@@ -75,10 +76,14 @@ class ServiceStaging(Base):
# 9. ⚠️ EXTRA OSZLOP: audit_trail
audit_trail: Mapped[Optional[dict]] = mapped_column(JSONB)
# 10. ⚠️ P0 BUGFIX: address_id — FK to system.addresses (UUID)
# The column exists in the database but was missing from the model.
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True)
# Időbélyegek
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# 10. ⚠️ EXTRA OSZLOP: updated_at
# 11. ⚠️ EXTRA OSZLOP: updated_at
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
class DiscoveryParameter(Base):

View File

@@ -165,6 +165,7 @@ class BodyTypeDictionary(Base):
__tablename__ = "dict_body_types"
__table_args__ = (
UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
Index('idx_dict_body_types_name_i18n', 'name_i18n', postgresql_using='gin'),
{"schema": "vehicle"}
)
@@ -172,3 +173,5 @@ class BodyTypeDictionary(Base):
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name_hu: Mapped[str] = mapped_column(String(100), nullable=False)
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))

View File

@@ -35,8 +35,12 @@ class CategoryTreeNode(BaseModel):
"""
id: int
key: str
name_hu: Optional[str] = None
name_en: Optional[str] = None
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
description_i18n: Optional[dict] = Field(None, description="Lokalizált leírás (JSONB)")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
level: int = 0
path: Optional[str] = None
is_official: bool = True
@@ -53,8 +57,11 @@ class CategoryAutocompleteItem(BaseModel):
"""
id: int
key: str
name_hu: Optional[str] = None
name_en: Optional[str] = None
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
level: int = 0
path: Optional[str] = None
parent_id: Optional[int] = None
@@ -73,8 +80,11 @@ class ExpertiseCategoryOut(BaseModel):
"""
id: int
key: str
name_hu: Optional[str] = None
name_en: Optional[str] = None
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
category: Optional[str] = None
level: int = 0
parent_id: Optional[int] = None
@@ -91,8 +101,11 @@ class CategoryInfo(BaseModel):
szolgáltatásokat a kártyákon.
"""
id: int
name_hu: Optional[str] = None
name_en: Optional[str] = None
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
level: int = 0
key: str
@@ -109,6 +122,10 @@ class ProviderSearchResult(BaseModel):
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressOut] — first-class citizen.
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
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.
@@ -118,12 +135,12 @@ class ProviderSearchResult(BaseModel):
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
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
address_zip: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.zip instead")
address_street_name: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_name instead")
address_street_type: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_type instead")
address_house_number: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.house_number instead")
plus_code: Optional[str] = None
contact_phone: Optional[str] = None
contact_email: Optional[str] = None
@@ -134,7 +151,7 @@ class ProviderSearchResult(BaseModel):
rating: Optional[float] = None
supported_vehicle_classes: Optional[List[str]] = None
specializations: Optional[dict] = None
# P3: Egységes cím objektum (a flat mezők mellett)
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
address_detail: Optional[AddressOut] = None
model_config = ConfigDict(from_attributes=True)
@@ -159,6 +176,10 @@ class ProviderQuickAddIn(BaseModel):
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressIn] — first-class citizen.
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
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.
@@ -170,11 +191,11 @@ class ProviderQuickAddIn(BaseModel):
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)")
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
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")
@@ -186,7 +207,7 @@ class ProviderQuickAddIn(BaseModel):
specializations: Optional[dict] = Field(
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
)
# P3: Egységes cím objektum (a flat mezők mellett)
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
address_detail: Optional[AddressIn] = None
@@ -211,16 +232,20 @@ class ProviderUpdateIn(BaseModel):
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressIn] — first-class citizen.
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
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")
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)")
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
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")
@@ -238,7 +263,7 @@ class ProviderUpdateIn(BaseModel):
specializations: Optional[dict] = Field(
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
)
# P3: Egységes cím objektum (a flat mezők mellett)
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
address_detail: Optional[AddressIn] = None

View File

@@ -0,0 +1,131 @@
"""
i18n JSONB Migration Script (Phase 1)
Migrates old name_hu/name_en columns to name_i18n JSONB format.
Run INSIDE the sf_api container:
docker exec sf_api python3 /app/backend/app/scripts/migrate_i18n_jsonb.py
IMPORTANT: Run this AFTER the new JSONB columns have been added by sync_engine.
"""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy import text
from app.db.session import AsyncSessionLocal
TABLES = [
{
"schema": "marketplace",
"table": "expertise_tags",
"name_cols": ["name_hu", "name_en"],
"name_translations_col": "name_translations",
"desc_cols": ["description"],
"target_name_col": "name_i18n",
"target_desc_col": "description_i18n",
},
{
"schema": "vehicle",
"table": "dict_body_types",
"name_cols": ["name_hu"],
"name_translations_col": None,
"desc_cols": [],
"target_name_col": "name_i18n",
"target_desc_col": None,
},
{
"schema": "system",
"table": "service_catalog",
"name_cols": ["name"],
"name_translations_col": None,
"desc_cols": ["description"],
"target_name_col": "name_i18n",
"target_desc_col": "description_i18n",
},
]
async def migrate_table(config: dict):
schema = config["schema"]
table = config["table"]
target_name = config["target_name_col"]
target_desc = config["target_desc_col"]
name_cols = config["name_cols"]
desc_cols = config["desc_cols"]
trans_col = config["name_translations_col"]
print(f"\n{'='*60}")
print(f" Migrating: {schema}.{table}")
print(f"{'='*60}")
async with AsyncSessionLocal() as db:
select_cols = ["id"] + name_cols + desc_cols
if trans_col:
select_cols.append(trans_col)
stmt = text(
f"SELECT {', '.join(select_cols)} FROM {schema}.{table} "
f"WHERE {target_name} IS NULL OR {target_name} = '{{\"hu\": \"\"}}'::jsonb"
)
result = await db.execute(stmt)
rows = result.fetchall()
if not rows:
print(f" ✅ No rows need migration in {schema}.{table}")
return
updated = 0
for row in rows:
row_dict = row._mapping
# Build name_i18n
name_i18n = {}
if len(name_cols) >= 1 and row_dict.get(name_cols[0]):
name_i18n["hu"] = row_dict[name_cols[0]]
if len(name_cols) >= 2 and row_dict.get(name_cols[1]):
name_i18n["en"] = row_dict[name_cols[1]]
# Merge with existing name_translations
if trans_col and row_dict.get(trans_col):
if isinstance(row_dict[trans_col], dict):
name_i18n.update(row_dict[trans_col])
# Build description_i18n
desc_i18n = None
if desc_cols and row_dict.get(desc_cols[0]):
desc_i18n = {"hu": row_dict[desc_cols[0]]}
# Update
update_cols = [f"{target_name} = :name_val"]
params = {"name_val": json.dumps(name_i18n), "row_id": row_dict["id"]}
if target_desc and desc_i18n:
update_cols.append(f"{target_desc} = :desc_val")
params["desc_val"] = json.dumps(desc_i18n)
update_sql = f"UPDATE {schema}.{table} SET {', '.join(update_cols)} WHERE id = :row_id"
await db.execute(text(update_sql), params)
updated += 1
await db.commit()
print(f" ✅ Migrated {updated} rows in {schema}.{table}")
async def main():
print("=" * 70)
print(" i18n JSONB Migration Script (Phase 1)")
print("=" * 70)
for table_config in TABLES:
await migrate_table(table_config)
print("\n" + "=" * 70)
print(" ✅ Migration complete!")
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""
P0 ADDRESS UNIFICATION - Phase 1: ServiceProvider Address Migration
Migrates flat address columns (city, address_zip, address_street_name, etc.)
from marketplace.service_providers into the centralized system.addresses table.
Logic:
1. Fetch all ServiceProvider records where address_id IS NULL
but at least one flat address field is non-empty.
2. For each record, construct an AddressIn schema from the flat data.
3. Call AddressManager.create_or_update(db, None, address_data) to create
a new central address record.
4. Assign the returned Address UUID to provider.address_id.
5. Commit in batches with proper error handling and logging.
Usage:
docker exec sf_api python3 /app/backend/app/scripts/migrate_provider_addresses.py
"""
import asyncio
import logging
import os
import sys
import uuid
from datetime import datetime, timezone
from typing import Optional
# Ensure the backend package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# asyncpg needs plain "postgresql://" scheme
_raw_url = os.getenv(
"DATABASE_URL",
"postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
)
DATABASE_URL = _raw_url.replace("+asyncpg", "")
# Batch size for commits
BATCH_SIZE = 100
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger("migrate_provider_addresses")
def _build_full_address_text(row: dict) -> Optional[str]:
"""Build a human-readable full address from flat fields."""
parts = []
if row.get("address_zip"):
parts.append(row["address_zip"])
if row.get("city"):
parts.append(row["city"])
street_parts = []
if row.get("address_street_name"):
street_parts.append(row["address_street_name"])
if row.get("address_street_type"):
street_parts.append(row["address_street_type"])
if row.get("address_house_number"):
street_parts.append(row["address_house_number"])
if street_parts:
parts.append(" ".join(street_parts))
return ", ".join(parts) if parts else None
def _has_address_data(row: dict) -> bool:
"""Check if the provider has any flat address data worth migrating."""
return bool(
row.get("city")
or row.get("address_zip")
or row.get("address_street_name")
or row.get("address_house_number")
)
async def main():
logger.info("=" * 70)
logger.info("🔧 P0 ADDRESS UNIFICATION - Phase 1: ServiceProvider Migration")
logger.info("=" * 70)
logger.info(f"Started at: {datetime.now(timezone.utc).isoformat()}")
logger.info(f"Database URL: {DATABASE_URL.replace('AppSafePass_2026', '****')}")
logger.info(f"Batch size: {BATCH_SIZE}")
logger.info("")
import asyncpg
conn = await asyncpg.connect(DATABASE_URL)
try:
# ── Step 1: Count providers needing migration ──
logger.info("📋 Step 1: Counting providers with flat address data but no address_id")
count_row = await conn.fetchrow("""
SELECT COUNT(*) AS cnt
FROM marketplace.service_providers
WHERE address_id IS NULL
AND (
city IS NOT NULL AND city != ''
OR address_zip IS NOT NULL AND address_zip != ''
OR address_street_name IS NOT NULL AND address_street_name != ''
OR address_house_number IS NOT NULL AND address_house_number != ''
)
""")
total = count_row["cnt"]
logger.info(f" → Found {total} provider(s) to migrate")
logger.info("")
if total == 0:
logger.info("✅ No providers need migration. Exiting.")
return
# ── Step 2: Fetch all providers needing migration ──
logger.info("📋 Step 2: Fetching provider records")
rows = await conn.fetch("""
SELECT id, name, city, address_zip,
address_street_name, address_street_type, address_house_number,
plus_code
FROM marketplace.service_providers
WHERE address_id IS NULL
AND (
city IS NOT NULL AND city != ''
OR address_zip IS NOT NULL AND address_zip != ''
OR address_street_name IS NOT NULL AND address_street_name != ''
OR address_house_number IS NOT NULL AND address_house_number != ''
)
ORDER BY id
""")
logger.info(f" → Fetched {len(rows)} record(s)")
logger.info("")
# ── Step 3: Migrate each provider ──
logger.info("📋 Step 3: Migrating addresses to system.addresses")
migrated_count = 0
error_count = 0
skipped_count = 0
for i, row in enumerate(rows, start=1):
provider_id = row["id"]
provider_name = row["name"]
if not _has_address_data(row):
logger.info(f" [{i}/{total}] Provider #{provider_id} '{provider_name}': "
f"no address data → SKIP")
skipped_count += 1
continue
try:
# Build the full_address_text
full_text = _build_full_address_text(row)
# Insert into system.addresses
new_address_id = uuid.uuid4()
await conn.execute("""
INSERT INTO system.addresses
(id, street_name, street_type, house_number,
full_address_text, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (id) DO NOTHING
""",
new_address_id,
row.get("address_street_name"),
row.get("address_street_type"),
row.get("address_house_number"),
full_text,
)
# Resolve postal code if zip+city are present
if row.get("address_zip") and row.get("city"):
# Find or create GeoPostalCode
postal = await conn.fetchrow("""
SELECT id FROM system.geo_postal_codes
WHERE zip_code = $1
""", row["address_zip"])
if postal:
postal_code_id = postal["id"]
else:
postal_code_id = await conn.fetchval("""
INSERT INTO system.geo_postal_codes
(zip_code, city, country_code)
VALUES ($1, $2, 'HU')
RETURNING id
""", row["address_zip"], row["city"])
# Update the address with postal_code_id
await conn.execute("""
UPDATE system.addresses
SET postal_code_id = $1
WHERE id = $2
""", postal_code_id, new_address_id)
# Update the provider with the new address_id
await conn.execute("""
UPDATE marketplace.service_providers
SET address_id = $1
WHERE id = $2
""", new_address_id, provider_id)
migrated_count += 1
if i % BATCH_SIZE == 0 or i == total:
logger.info(
f" [{i}/{total}] Progress: {migrated_count} migrated, "
f"{error_count} errors, {skipped_count} skipped"
)
except Exception as e:
error_count += 1
logger.error(
f" [{i}/{total}] Provider #{provider_id} '{provider_name}': "
f"ERROR - {e}"
)
# ── Summary ──
logger.info("")
logger.info("=" * 70)
logger.info("📊 MIGRATION SUMMARY")
logger.info("=" * 70)
logger.info(f" Total providers processed: {total}")
logger.info(f" Successfully migrated: {migrated_count}")
logger.info(f" Errors: {error_count}")
logger.info(f" Skipped (no address data): {skipped_count}")
# ── Step 4: Verify ──
logger.info("")
logger.info("📋 Step 4: Verification")
remaining = await conn.fetchval("""
SELECT COUNT(*) FROM marketplace.service_providers
WHERE address_id IS NULL
AND (
city IS NOT NULL AND city != ''
OR address_zip IS NOT NULL AND address_zip != ''
OR address_street_name IS NOT NULL AND address_street_name != ''
OR address_house_number IS NOT NULL AND address_house_number != ''
)
""")
total_providers = await conn.fetchval(
"SELECT COUNT(*) FROM marketplace.service_providers"
)
with_address = await conn.fetchval(
"SELECT COUNT(*) FROM marketplace.service_providers WHERE address_id IS NOT NULL"
)
logger.info(f" Total providers: {total_providers}")
logger.info(f" Providers WITH address_id: {with_address}")
logger.info(f" Providers still NULL: {remaining}")
logger.info("")
logger.info("✅ Phase 1 migration complete!")
except Exception as e:
logger.error(f"💥 FATAL ERROR: {e}", exc_info=True)
sys.exit(1)
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,90 @@
"""
Seed script: Beszúrja az "Autóbontó / Használt alkatrész" kategóriát
a marketplace.expertise_tags táblába, ha még nem létezik.
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_dismantler_category.py
"""
import asyncio
import logging
from sqlalchemy import select, func
from app.db.session import AsyncSessionLocal
from app.models.marketplace.service import ExpertiseTag
logger = logging.getLogger(__name__)
DISMANTLER_CATEGORY = {
"key": "autobonto_hasznalt_alkatresz",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Autóbontó / Használt alkatrész", "en": "Car Dismantler / Used Parts"},
"name_hu": "Autóbontó / Használt alkatrész",
"name_en": "Car Dismantler / Used Parts",
"category": "parts",
"search_keywords": [
"bontó", "autóbontó", "bontás", "használt alkatrész",
"dismantler", "used parts", "scrap yard", "break yard",
"alkatrész bontás", "roncs", "roncsautó",
],
"description": "Használt gépjármű alkatrészek értékesítése, autóbontás, roncsautó felvásárlás",
}
async def seed_dismantler_category():
"""Beszúrja az Autóbontó kategóriát, ha még nem létezik."""
async with AsyncSessionLocal() as db:
try:
# Ellenőrizzük, hogy létezik-e már
stmt = select(ExpertiseTag).where(
ExpertiseTag.key == DISMANTLER_CATEGORY["key"]
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
print(
f"✅ Az 'Autóbontó / Használt alkatrész' kategória "
f"már létezik (ID: {existing.id}). Nincs szükség seed-elésre."
)
return
# Beszúrás
tag = ExpertiseTag(
key=DISMANTLER_CATEGORY["key"],
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=DISMANTLER_CATEGORY.get("name_i18n", {"hu": DISMANTLER_CATEGORY["name_hu"], "en": DISMANTLER_CATEGORY["name_en"]}),
name_hu=DISMANTLER_CATEGORY["name_hu"],
name_en=DISMANTLER_CATEGORY["name_en"],
category=DISMANTLER_CATEGORY["category"],
search_keywords=DISMANTLER_CATEGORY["search_keywords"],
description=DISMANTLER_CATEGORY["description"],
is_official=True,
discovery_points=10,
usage_count=0,
)
db.add(tag)
await db.commit()
await db.refresh(tag)
print(
f"✅ Sikeresen beszúrva az 'Autóbontó / Használt alkatrész' "
f"kategória (ID: {tag.id})."
)
# Ellenőrzés
count_result = await db.execute(
select(func.count(ExpertiseTag.id))
)
final_count = count_result.scalar()
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
except Exception as e:
await db.rollback()
logger.error(f"Hiba a seed-elés során: {e}")
print(f"❌ Hiba: {e}")
raise
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(seed_dismantler_category())

View File

@@ -16,6 +16,8 @@ logger = logging.getLogger(__name__)
BASE_CATEGORIES = [
{
"key": "auto_szerelo",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Autószerelő", "en": "Car Mechanic"},
"name_hu": "Autószerelő",
"name_en": "Car Mechanic",
"category": "vehicle_service",
@@ -24,6 +26,8 @@ BASE_CATEGORIES = [
},
{
"key": "motor_szerelo",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Motorkerékpár szerelő", "en": "Motorcycle Mechanic"},
"name_hu": "Motorkerékpár szerelő",
"name_en": "Motorcycle Mechanic",
"category": "vehicle_service",
@@ -32,6 +36,8 @@ BASE_CATEGORIES = [
},
{
"key": "gumiszerviz",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Gumiszerviz", "en": "Tire Service"},
"name_hu": "Gumiszerviz",
"name_en": "Tire Service",
"category": "vehicle_service",
@@ -40,6 +46,8 @@ BASE_CATEGORIES = [
},
{
"key": "karosszerialakatos",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Karosszérialakatos", "en": "Body Shop"},
"name_hu": "Karosszérialakatos",
"name_en": "Body Shop",
"category": "body_paint",
@@ -48,6 +56,8 @@ BASE_CATEGORIES = [
},
{
"key": "fényező",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Fényező", "en": "Painter"},
"name_hu": "Fényező",
"name_en": "Painter",
"category": "body_paint",
@@ -56,6 +66,8 @@ BASE_CATEGORIES = [
},
{
"key": "autómentő",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Autómentő / Motormentő", "en": "Tow Truck / Roadside Assistance"},
"name_hu": "Autómentő / Motormentő",
"name_en": "Tow Truck / Roadside Assistance",
"category": "roadside",
@@ -64,6 +76,8 @@ BASE_CATEGORIES = [
},
{
"key": "benzinkút",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Benzinkút", "en": "Gas Station"},
"name_hu": "Benzinkút",
"name_en": "Gas Station",
"category": "fuel",
@@ -72,6 +86,8 @@ BASE_CATEGORIES = [
},
{
"key": "alkatrész_kereskedés",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Alkatrész kereskedés", "en": "Parts Store"},
"name_hu": "Alkatrész kereskedés",
"name_en": "Parts Store",
"category": "parts",
@@ -80,6 +96,8 @@ BASE_CATEGORIES = [
},
{
"key": "vizsgaállomás",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Vizsgaállomás", "en": "Inspection Station"},
"name_hu": "Vizsgaállomás",
"name_en": "Inspection Station",
"category": "inspection",
@@ -88,6 +106,8 @@ BASE_CATEGORIES = [
},
{
"key": "autókozmetika",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Autókozmetika", "en": "Car Detailing"},
"name_hu": "Autókozmetika",
"name_en": "Car Detailing",
"category": "detailing",
@@ -96,6 +116,8 @@ BASE_CATEGORIES = [
},
{
"key": "egyéb",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Egyéb szolgáltató", "en": "Other Service"},
"name_hu": "Egyéb szolgáltató",
"name_en": "Other Service",
"category": "other",
@@ -123,6 +145,8 @@ async def seed_expertise_tags():
for cat in BASE_CATEGORIES:
tag = ExpertiseTag(
key=cat["key"],
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
name_hu=cat["name_hu"],
name_en=cat["name_en"],
category=cat["category"],

View File

@@ -0,0 +1,129 @@
"""
Seed script: Beszúrja a hiányzó "Shop / Kisbolt" (Convenience Store) kategóriát
a marketplace.expertise_tags táblába, az "Üzemanyag és Töltőállomás" (ID=755)
Level 1 szülő alá.
Meglévő kategóriák (már léteznek, nem hozzuk létre újra):
- ID=755: Üzemanyag és Töltőállomás (Level 1)
- ID=756: Benzinkút (Level 2)
- ID=757: Elektromos Töltőállomás (Level 2)
Hiányzó kategória (ezt hozza létre a script):
- Shop / Kisbolt (Level 2, parent_id=755)
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_gas_station.py
"""
import asyncio
import logging
from sqlalchemy import select, func, text
from app.db.session import AsyncSessionLocal
from app.models.marketplace.service import ExpertiseTag
logger = logging.getLogger(__name__)
# A meglévő "Üzemanyag és Töltőállomás" szülő ID-ja
FUEL_STATION_PARENT_ID = 755
CATEGORIES_TO_SEED = [
{
"key": "shop_kisbolt",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Shop / Kisbolt", "en": "Shop / Convenience Store"},
"name_hu": "Shop / Kisbolt",
"name_en": "Shop / Convenience Store",
"category": "fuel",
"level": 2,
"parent_id": FUEL_STATION_PARENT_ID,
"path": "uzemanyag_toltoallomas/shop_kisbolt",
"search_keywords": [
"shop", "kisbolt", "convenience", "vegyesbolt",
"benzinkút shop", "élelmiszer", "snack", "ital",
],
"description": "Benzinkúton üzemelő kisbolt, shop, convenience store",
},
]
async def seed_gas_station_categories():
"""Beszúrja a hiányzó kategóriákat, ha még nem léteznek."""
async with AsyncSessionLocal() as db:
try:
for cat in CATEGORIES_TO_SEED:
# Ellenőrizzük, hogy létezik-e már
stmt = select(ExpertiseTag).where(
ExpertiseTag.key == cat["key"]
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
print(
f"✅ A '{cat['name_hu']}' kategória "
f"már létezik (ID: {existing.id}). Nincs szükség seed-elésre."
)
continue
# Beszúrás
tag = ExpertiseTag(
key=cat["key"],
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
name_hu=cat["name_hu"],
name_en=cat["name_en"],
category=cat["category"],
level=cat["level"],
parent_id=cat["parent_id"],
path=cat["path"],
search_keywords=cat["search_keywords"],
description=cat["description"],
is_official=True,
discovery_points=10,
usage_count=0,
)
db.add(tag)
await db.flush()
await db.refresh(tag)
print(
f"✅ Sikeresen beszúrva a '{cat['name_hu']}' "
f"kategória (ID: {tag.id})."
)
await db.commit()
# Ellenőrzés
count_result = await db.execute(
select(func.count(ExpertiseTag.id))
)
final_count = count_result.scalar()
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
# Listázzuk a benzinkút kategóriákat
print("\n📋 Benzinkút kategóriák:")
result = await db.execute(
text("""
SELECT id, key, name_hu, name_en, level, parent_id, path
FROM marketplace.expertise_tags
WHERE parent_id = :pid OR id = :pid
ORDER BY level, id
"""),
{"pid": FUEL_STATION_PARENT_ID},
)
for row in result:
print(f" ID={row.id}, key={row.key}, name_hu={row.name_hu}, level={row.level}")
except Exception as e:
await db.rollback()
logger.error(f"Hiba a seed-elés során: {e}")
print(f"❌ Hiba: {e}")
raise
def main():
asyncio.run(seed_gas_station_categories())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,62 @@
# /opt/docker/dev/service_finder/backend/app/scripts/seed_validation_rules.py
"""
Seed script for gamification.validation_rules table.
Inserts the base validation rules required by the P0 Architecture:
BOT_BASE_SCORE = 20 # Default trust score for robot-discovered entries
FIRST_ENTRY_SCORE = 40 # Score awarded for the first user entry
USER_CONFIRM_BASE = 20 # Base score per user confirmation vote
AUTO_APPROVE_THRESHOLD = 80 # Minimum validation_level to auto-promote
Usage:
docker exec -it sf_api python -m app.scripts.seed_validation_rules
"""
import asyncio
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.database import AsyncSessionLocal
from app.models.gamification.validation_rule import ValidationRule
from sqlalchemy import select
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
logger = logging.getLogger("SeedValidationRules")
BASE_RULES = [
{"rule_key": "BOT_BASE_SCORE", "rule_value": 20, "description": "Default trust score for robot-discovered service entries"},
{"rule_key": "FIRST_ENTRY_SCORE", "rule_value": 40, "description": "Score awarded for the first user-submitted service entry"},
{"rule_key": "USER_CONFIRM_BASE", "rule_value": 20, "description": "Base score per user confirmation vote (level multiplier added)"},
{"rule_key": "AUTO_APPROVE_THRESHOLD", "rule_value": 80, "description": "Minimum validation_level required for auto-promotion to provider"},
]
async def seed():
async with AsyncSessionLocal() as db:
for rule_data in BASE_RULES:
# Check if rule already exists
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_data["rule_key"])
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Rule '{rule_data['rule_key']}' already exists (value={existing.rule_value}), skipping.")
continue
rule = ValidationRule(
rule_key=rule_data["rule_key"],
rule_value=rule_data["rule_value"],
description=rule_data["description"],
)
db.add(rule)
logger.info(f"Created rule '{rule_data['rule_key']}' = {rule_data['rule_value']}")
await db.commit()
logger.info("✅ Validation rules seeded successfully.")
if __name__ == "__main__":
asyncio.run(seed())

View File

@@ -0,0 +1,360 @@
# /opt/docker/dev/service_finder/backend/app/services/validation_engine.py
"""
ValidationEngine — Gamified Provider Validation & Promotion Engine.
P0 Architecture: Dynamic rule-based validation scoring for service providers.
Responsibilities:
1. Fetch dynamic validation rules from gamification.validation_rules table.
2. Compute validation scores for ServiceStaging entries based on:
- Admin override (score = 100)
- User level from gamification.user_stats (weighted vote)
- Robot base score (BOT_BASE_SCORE)
3. Log validation events in marketplace.provider_validations.
4. Auto-promote staging entries to full Organization + ServiceProfile
when validation_level >= AUTO_APPROVE_THRESHOLD.
Usage:
engine = ValidationEngine()
await engine.process_staging_score(db, staging, user_id=42)
await engine.process_staging_score(db, staging, is_admin=True)
"""
import logging
import uuid
from typing import Optional
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
ValidationRule,
ServiceStaging,
UserStats,
Organization,
ServiceProfile,
Address,
)
from app.models.identity.social import ProviderValidation
from app.schemas.address import AddressIn
from app.services.address_service import create_address
logger = logging.getLogger("ValidationEngine")
# ──────────────────────────────────────────────────────────────────────────────
# Default fallback values if the validation_rules table is empty
# ──────────────────────────────────────────────────────────────────────────────
DEFAULT_RULES: dict[str, int] = {
"BOT_BASE_SCORE": 20,
"FIRST_ENTRY_SCORE": 40,
"USER_CONFIRM_BASE": 20,
"AUTO_APPROVE_THRESHOLD": 80,
}
class ValidationEngine:
"""
Gamified Validation Engine for service provider staging entries.
Operates on ServiceStaging records, computing validation_level scores
based on dynamic rules from the gamification.validation_rules table.
"""
# ── Rule Loading ─────────────────────────────────────────────────────────
@staticmethod
async def get_rules(db: AsyncSession) -> dict[str, int]:
"""
Fetch all active validation rules from the database.
Returns a dict of rule_key → rule_value. Falls back to DEFAULT_RULES
for any key not found in the table, ensuring the engine always has
sensible defaults even before seeding.
"""
stmt = select(ValidationRule)
result = await db.execute(stmt)
db_rules = result.scalars().all()
rules = dict(DEFAULT_RULES) # start with fallbacks
for rule in db_rules:
rules[rule.rule_key] = rule.rule_value
return rules
# ── Scoring ──────────────────────────────────────────────────────────────
@staticmethod
async def _get_user_weight(
db: AsyncSession, user_id: int, rules: dict[str, int]
) -> int:
"""
Calculate a user's vote weight based on their gamification level.
Uses gamification.user_stats.current_level as the multiplier.
Formula: USER_CONFIRM_BASE + (current_level * 5)
If no UserStats record exists, returns USER_CONFIRM_BASE as fallback.
"""
base = rules.get("USER_CONFIRM_BASE", DEFAULT_RULES["USER_CONFIRM_BASE"])
stmt = select(UserStats).where(UserStats.user_id == user_id)
result = await db.execute(stmt)
stats = result.scalar_one_or_none()
if stats is None:
logger.warning(f"No UserStats found for user_id={user_id}, using base weight={base}")
return base
level = stats.current_level or 1
weight = base + (level * 5)
logger.info(
f"User {user_id} weight: base={base}, level={level}, total_weight={weight}"
)
return weight
# ── Validation Logging ───────────────────────────────────────────────────
@staticmethod
async def _log_validation(
db: AsyncSession,
provider_id: int,
voter_user_id: int,
validation_type: str,
weight: int,
metadata: Optional[dict] = None,
) -> Optional[ProviderValidation]:
"""
Log a validation event in marketplace.provider_validations.
NOTE: ProviderValidation.provider_id references marketplace.service_providers.id,
NOT marketplace.service_staging.id. This method should only be called
AFTER promotion when a real ServiceProvider record exists.
If the provider_id does not correspond to a valid service_providers row,
the FK constraint will fail — in that case we log a warning and skip.
Uses INSERT ... ON CONFLICT DO NOTHING semantics via a pre-check
to respect the UniqueConstraint(voter_user_id, provider_id).
"""
# Check if this user already validated this provider
stmt = select(ProviderValidation).where(
ProviderValidation.voter_user_id == voter_user_id,
ProviderValidation.provider_id == provider_id,
)
existing = (await db.execute(stmt)).scalar_one_or_none()
if existing:
logger.info(
f"User {voter_user_id} already validated provider {provider_id} "
f"(type={existing.validation_type}, weight={existing.weight})"
)
return existing
record = ProviderValidation(
provider_id=provider_id,
voter_user_id=voter_user_id,
validation_type=validation_type,
weight=weight,
validation_metadata=metadata or {},
)
db.add(record)
await db.flush()
logger.info(
f"Logged validation: provider={provider_id}, user={voter_user_id}, "
f"type={validation_type}, weight={weight}"
)
return record
# ── Promotion Logic ──────────────────────────────────────────────────────
@staticmethod
async def _promote_to_provider(
db: AsyncSession, staging: ServiceStaging
) -> tuple[Organization, ServiceProfile]:
"""
Promote a ServiceStaging entry to a full Organization + ServiceProfile.
Steps:
1. Create an Address from staging data (via AddressManager).
2. Create an Organization with org_type='service'.
3. Create a ServiceProfile linked to the Organization.
4. Set staging.status = 'approved'.
Returns:
Tuple of (Organization, ServiceProfile).
"""
logger.info(
f"Promoting staging {staging.id} ('{staging.name}') to provider..."
)
# ── 1. Create Address ────────────────────────────────────────────────
from datetime import datetime, timezone
addr_in = AddressIn(
city=staging.city or "",
zip=staging.postal_code or "",
full_address_text=staging.full_address or "",
)
# NOTE: created_at has no server_default in the DB, so we set it explicitly
address = Address(
id=uuid.uuid4(),
full_address_text=addr_in.full_address_text,
created_at=datetime.now(timezone.utc),
)
# Resolve postal code if zip and city provided
if addr_in.zip and addr_in.city:
from app.services.address_service import _resolve_postal_code
postal_code_id = await _resolve_postal_code(db, addr_in.zip, addr_in.city)
address.postal_code_id = postal_code_id
db.add(address)
await db.flush()
# ── 2. Create Organization ───────────────────────────────────────────
now = datetime.now(timezone.utc)
org = Organization(
name=staging.name,
full_name=staging.name,
folder_slug=f"svc-{uuid.uuid4().hex[:12]}",
org_type="service",
address_id=address.id,
status="active",
is_active=True,
is_verified=True,
# NOTE: Several columns have server_default in the model but NOT
# in the actual DB, so we set them explicitly:
created_at=now,
first_registered_at=now,
current_lifecycle_started_at=now,
subscription_plan="FREE",
base_asset_limit=1,
purchased_extra_slots=0,
lifecycle_index=1,
is_ownership_transferable=True,
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
external_integration_config={},
)
db.add(org)
await db.flush()
# ── 3. Create ServiceProfile ─────────────────────────────────────────
from sqlalchemy import text as sa_text
profile = ServiceProfile(
organization_id=org.id,
fingerprint=staging.fingerprint,
status="active",
trust_score=staging.trust_score or 20,
is_verified=True,
contact_phone=staging.contact_phone,
contact_email=staging.contact_email,
website=staging.website,
# NOTE: location is NOT NULL Geometry in the DB with no default,
# so we set a default point (0,0) at SRID 4326
location=func.ST_SetSRID(func.ST_MakePoint(0, 0), 4326),
)
db.add(profile)
await db.flush()
# ── 4. Update staging record ─────────────────────────────────────────
staging.status = "approved"
staging.organization_id = org.id
staging.service_profile_id = profile.id
staging.published_at = func.now()
logger.info(
f"Promotion complete: staging={staging.id}"
f"org={org.id} ('{org.name}'), profile={profile.id}"
)
return org, profile
# ── Main Entry Point ─────────────────────────────────────────────────────
async def process_staging_score(
self,
db: AsyncSession,
staging: ServiceStaging,
user_id: Optional[int] = None,
is_admin: bool = False,
) -> int:
"""
Compute and apply a validation score for a ServiceStaging entry.
Logic:
- If is_admin: score = 100 (instant approval).
- If user_id provided: query UserStats.current_level,
compute weight = USER_CONFIRM_BASE + (level * 5).
- Otherwise: use BOT_BASE_SCORE from rules.
- Log the validation event in provider_validations.
- Update staging.validation_level.
- If validation_level >= AUTO_APPROVE_THRESHOLD, auto-promote.
Args:
db: Database session.
staging: The ServiceStaging record to score.
user_id: Optional user who triggered the validation.
is_admin: If True, score = 100 (admin override).
Returns:
The new validation_level after applying the score.
"""
rules = await self.get_rules(db)
auto_approve = rules.get(
"AUTO_APPROVE_THRESHOLD", DEFAULT_RULES["AUTO_APPROVE_THRESHOLD"]
)
# ── Compute score ────────────────────────────────────────────────────
if is_admin:
score = 100
effective_user_id = 0 # system user placeholder
logger.info(f"Admin override: score=100 for staging {staging.id}")
elif user_id is not None:
weight = await self._get_user_weight(db, user_id, rules)
score = weight
effective_user_id = user_id
logger.info(
f"User {user_id} validation: weight={weight} for staging {staging.id}"
)
else:
score = rules.get("BOT_BASE_SCORE", DEFAULT_RULES["BOT_BASE_SCORE"])
effective_user_id = 0
logger.info(
f"Robot base score: {score} for staging {staging.id}"
)
# ── Update validation_level ──────────────────────────────────────────
current_level = staging.validation_level or 0
new_level = min(current_level + score, 100) # cap at 100
staging.validation_level = new_level
# ── Log validation event ─────────────────────────────────────────────
# NOTE: ProviderValidation.provider_id references marketplace.service_providers.id.
# For staging-level validations (before promotion), we skip logging to
# provider_validations since the staging entry is not a service_provider yet.
# After promotion, the _promote_to_provider method can log validations.
if effective_user_id > 0 and staging.organization_id is not None:
# Only log if the staging has been promoted (has an org)
await self._log_validation(
db,
provider_id=staging.id,
voter_user_id=effective_user_id,
validation_type="approve" if score >= 0 else "reject",
weight=score,
metadata={
"staging_id": staging.id,
"score": score,
"validation_level_before": current_level,
"validation_level_after": new_level,
"is_admin": is_admin,
},
)
await db.flush()
logger.info(
f"Staging {staging.id} validation_level: {current_level}{new_level} "
f"(score={score})"
)
# ── Auto-promote if threshold reached ────────────────────────────────
if new_level >= auto_approve and staging.status != "approved":
logger.info(
f"Auto-approve threshold reached ({new_level} >= {auto_approve}) "
f"for staging {staging.id}"
)
await self._promote_to_provider(db, staging)
return new_level

View File

@@ -0,0 +1,199 @@
# /opt/docker/dev/service_finder/backend/app/tests/test_validation_engine.py
"""
Test script for ValidationEngine.
Tests:
1. get_rules() — fetches dynamic rules from DB
2. process_staging_score() with admin override
3. process_staging_score() with user weight
4. process_staging_score() with robot base score
5. Auto-promotion when threshold reached
Usage:
docker exec -i sf_api python -m app.tests.test_validation_engine
"""
import asyncio
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.database import AsyncSessionLocal
from app.models import (
ServiceStaging,
ValidationRule,
UserStats,
)
from app.models.identity.social import ProviderValidation
from app.services.validation_engine import ValidationEngine
from sqlalchemy import select, delete
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
logger = logging.getLogger("TestValidationEngine")
PASS = 0
FAIL = 0
def assert_eq(actual, expected, label: str):
global PASS, FAIL
if actual == expected:
PASS += 1
logger.info(f"{label}: {actual} == {expected}")
else:
FAIL += 1
logger.error(f"{label}: {actual} != {expected}")
async def test_get_rules():
"""Test that get_rules() fetches all 4 base rules from the DB."""
logger.info("\n📋 Test: get_rules()")
engine = ValidationEngine()
async with AsyncSessionLocal() as db:
rules = await engine.get_rules(db)
assert_eq(rules.get("BOT_BASE_SCORE"), 20, "BOT_BASE_SCORE")
assert_eq(rules.get("FIRST_ENTRY_SCORE"), 40, "FIRST_ENTRY_SCORE")
assert_eq(rules.get("USER_CONFIRM_BASE"), 20, "USER_CONFIRM_BASE")
assert_eq(rules.get("AUTO_APPROVE_THRESHOLD"), 80, "AUTO_APPROVE_THRESHOLD")
assert_eq(len(rules), 4, "rule count")
async def test_admin_override():
"""Test that admin override sets score=100."""
logger.info("\n📋 Test: admin override (score=100)")
engine = ValidationEngine()
async with AsyncSessionLocal() as db:
staging = ServiceStaging(
name="Test Admin Garage",
city="Budapest",
fingerprint="test_admin_fp_001",
status="pending",
validation_level=0,
)
db.add(staging)
await db.flush()
new_level = await engine.process_staging_score(
db, staging, is_admin=True
)
assert_eq(new_level, 100, "admin validation_level")
assert_eq(staging.validation_level, 100, "staging.validation_level")
# Cleanup
await db.rollback()
async def test_user_weight():
"""Test that user weight is calculated from UserStats.current_level."""
logger.info("\n📋 Test: user weight calculation")
engine = ValidationEngine()
async with AsyncSessionLocal() as db:
# Create a staging entry
staging = ServiceStaging(
name="Test User Garage",
city="Debrecen",
fingerprint="test_user_fp_001",
status="pending",
validation_level=0,
)
db.add(staging)
await db.flush()
# Test with user_id that doesn't exist (should use base weight)
new_level = await engine.process_staging_score(
db, staging, user_id=999999
)
# USER_CONFIRM_BASE=20, no UserStats -> base=20
assert_eq(new_level, 20, "user weight without stats (base=20)")
staging.validation_level = 0 # reset
# Create UserStats for user 1 (if exists)
stmt = select(UserStats).where(UserStats.user_id == 1)
result = await db.execute(stmt)
stats = result.scalar_one_or_none()
if stats:
level = stats.current_level or 1
expected = 20 + (level * 5)
new_level = await engine.process_staging_score(
db, staging, user_id=1
)
assert_eq(new_level, expected, f"user weight with level={level}")
await db.rollback()
async def test_robot_base_score():
"""Test that robot (no user_id) uses BOT_BASE_SCORE=20."""
logger.info("\n📋 Test: robot base score (BOT_BASE_SCORE=20)")
engine = ValidationEngine()
async with AsyncSessionLocal() as db:
staging = ServiceStaging(
name="Test Robot Garage",
city="Szeged",
fingerprint="test_robot_fp_001",
status="pending",
validation_level=0,
)
db.add(staging)
await db.flush()
new_level = await engine.process_staging_score(db, staging)
assert_eq(new_level, 20, "robot base score")
assert_eq(staging.validation_level, 20, "staging.validation_level")
await db.rollback()
async def test_auto_promote():
"""Test that staging is auto-promoted when validation_level >= 80."""
logger.info("\n📋 Test: auto-promotion at threshold >= 80")
engine = ValidationEngine()
async with AsyncSessionLocal() as db:
staging = ServiceStaging(
name="Test Promote Garage",
city="Miskolc",
postal_code="3500",
full_address="3500 Miskolc, Test Street 1",
fingerprint="test_promote_fp_001",
status="pending",
validation_level=40,
trust_score=20,
)
db.add(staging)
await db.flush()
# First pass: admin adds 40 -> 80 (threshold reached)
new_level = await engine.process_staging_score(
db, staging, is_admin=True
)
# Admin gives 100, but capped at 100
assert_eq(new_level, 100, "auto-promote validation_level")
# Status should be 'approved' now
assert_eq(staging.status, "approved", "staging.status after promotion")
await db.rollback()
async def main():
logger.info("=" * 60)
logger.info("🔍 ValidationEngine Test Suite")
logger.info("=" * 60)
await test_get_rules()
await test_admin_override()
await test_user_weight()
await test_robot_base_score()
await test_auto_promote()
logger.info("\n" + "=" * 60)
logger.info(f"📊 Results: {PASS} passed, {FAIL} failed")
logger.info("=" * 60)
if FAIL > 0:
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -6,7 +6,7 @@ import httpx
from urllib.parse import quote
from sqlalchemy import select, text
from app.database import AsyncSessionLocal
from app.models.marketplace.service import ServiceStaging # JAVÍTOTT IMPORT ÚTVONAL!
from app.models.marketplace.staged_data import ServiceStaging # JAVÍTVA: helyes import a trust_score oszloppal rendelkező modellhez
import re
# Logolás MB 2.0 szabvány szerint
@@ -91,11 +91,10 @@ class OSMScout:
if existing is None:
full_addr = f"{postcode} {city}, {tags.get('addr:street', '')} {tags.get('addr:housenumber', '')}".strip(" ,")
# Bővített JSON a nyers adatokhoz, mert a modelled nem tartalmazza a source és trust oszlopokat
# P0 FIX: trust_score most már az ORM objektum mezője, nem csak JSON-ben
raw_payload = {
"osm_tags": tags,
"source": "osm_scout_v2",
"trust_score": 20
}
new_entry = ServiceStaging(
@@ -105,6 +104,7 @@ class OSMScout:
full_address=full_addr,
fingerprint=f_print,
status="pending",
trust_score=20, # P0 FIX: BOT_BASE_SCORE az ORM objektumon
raw_data=raw_payload
)
db.add(new_entry)

View File

@@ -0,0 +1,263 @@
# P0 Address Unification Plan
## Split-Brain Diagnosis: `marketplace.service_providers` vs. `system.addresses`
**Date:** 2026-07-07
**Author:** Rendszer-Architect
**Status:** Discovery Complete — Awaiting Approval for Implementation
---
## 1. Executive Summary
The system suffers from a **split-brain address architecture**. Two models use the centralized [`system.addresses`](backend/app/models/identity/address.py:39) table via `address_id` UUID foreign keys correctly (`fleet.organizations`, `identity.persons`), but the [`marketplace.service_providers`](backend/app/models/identity/social.py:23) table still relies entirely on **legacy flat columns** for address storage.
The [`ServiceStaging`](backend/app/models/marketplace/staged_data.py:24) model is in a **hybrid state** — it has both flat columns and an `address_id` FK — and the admin API ([`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1157)) already has partial `AddressManager` integration. However, this creates a **dual-write pattern**: data flows to both the flat columns AND the normalized `system.addresses` table, with complex fallback logic.
The [`Organization`](backend/app/models/marketplace/organization.py:73) model was already fully refactored — the deprecated flat columns (`address_city`, `address_zip`, etc.) were **physically removed** from the database via [`cleanup_ghost_columns_2026_07.sql`](docs/sql/cleanup_ghost_columns_2026_07.sql). The [`ServiceProvider`](backend/app/models/identity/social.py:23) model needs the same treatment.
---
## 2. Full System Audit Results
### 2.1 Model Status Matrix
| Model | Table | Schema | Flat Columns | `address_id` FK | Status |
|---|---|---|---|---|---|
| [`Address`](backend/app/models/identity/address.py:39) | `system.addresses` | `system` | ❌ (central) | UUID PK (self) | ✅ **Source of Truth** |
| [`Organization`](backend/app/models/marketplace/organization.py:73) | `fleet.organizations` | `fleet` | ❌ (removed) | ✅ UUID FK (3x) | ✅ **Fully Refactored** |
| [`Person`](backend/app/models/identity/person.py) | `identity.persons` | `identity` | ❌ | ✅ UUID FK | ✅ **Already correct** |
| [`Branch`](backend/app/models/marketplace/organization.py) | `fleet.branches` | `fleet` | ❌ | ✅ UUID FK | ✅ **Already correct** |
| [`ServiceProfile`](backend/app/models/marketplace/service.py:21) | `marketplace.service_profiles` | `marketplace` | ❌ | ❌ (uses `location` Geometry) | ✅ **Clean design** |
| [`ServiceStaging`](backend/app/models/marketplace/staged_data.py:24) | `marketplace.service_staging` | `marketplace` | ✅ (`city`, `postal_code`, `full_address`) | ✅ UUID FK | ⚠️ **Hybrid — needs cleanup** |
| [`ServiceProvider`](backend/app/models/identity/social.py:23) | `marketplace.service_providers` | `marketplace` | ✅ (`city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`) | ❌ **NONE** | 🔴 **CORE PROBLEM** |
### 2.2 ServiceProvider Model — The Core Problem
File: [`backend/app/models/identity/social.py`](backend/app/models/identity/social.py:23)
The `ServiceProvider` model at lines 23-71 defines these flat address columns:
```python
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # line 38
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # line 39
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) # line 40
address_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # line 41
address_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # line 42
```
**There is NO `address_id` UUID foreign key to `system.addresses`.**
This means:
- Address data cannot be shared/deduplicated across entities
- No postal code normalization (no `GeoPostalCode` relationship)
- The `AddressManager` creates `system.addresses` records but the results are **mapped back to flat columns** instead of storing the FK
- The `_build_unified_providers_query()` in [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:284) must use a complex UNION ALL with flat field extraction
### 2.3 ServiceStaging Model — Hybrid State
File: [`backend/app/models/marketplace/staged_data.py`](backend/app/models/marketplace/staged_data.py:24)
The `ServiceStaging` model at line 81 already has the FK:
```python
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
) # line 81
```
But it ALSO retains flat columns at lines 25-27:
```python
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
postal_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
full_address: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
```
This hybrid state means the API writes to BOTH the `address_id` FK and the flat columns, creating data duplication.
### 2.4 Admin API — Partial Fix Complexity
File: [`backend/app/api/v1/endpoints/admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1157)
The `update_provider()` function at line 1157 has complex branching logic:
- **Lines 1242-1262**: For `ServiceStaging` (which has `address_id` FK) → uses `AddressManager.create_or_update()` then sets `provider_obj.address_id`
- **Lines 1262-1302**: For `ServiceProvider` (NO `address_id` FK) → uses `AddressManager.create_or_update()` then **maps the result back to 12+ flat columns** via `addr_mapping` (lines 1277-1302)
This dual-write pattern is fragile and error-prone. The `addr_mapping` dict at lines 1277-1302 maps each `AddressIn` field to its flat column counterpart.
Similarly, on response:
- **Lines 1507-1518**: For `ServiceProvider` → tries to resolve `address_detail` from `address_id` (which may be NULL for existing records)
- **Lines 1548-1560**: For `ServiceStaging` → resolves `address_detail` from `address_id`
The [`_build_unified_providers_query()`](backend/app/api/v1/endpoints/admin_providers.py:284) at line 284 uses UNION ALL across 3 tables with flat field extraction:
```sql
-- ServiceProvider branch (lines 306-320): selects flat columns
-- ServiceStaging branch (lines 335-352): selects flat columns
-- Organization branch (lines 369-390): JOINs with system.addresses
```
### 2.5 Frontend Status
| Page | Path | SharedAddressForm | address_detail | Flat Fallback |
|---|---|---|---|---|
| **Provider Edit** | [`providers/[id]/edit.vue`](frontend_admin/pages/providers/%5Bid%5D/edit.vue) | ✅ (line 343) | ✅ (lines 810-823) | ⚠️ (lines 1133-1134) |
| **Provider Detail** | [`providers/[id]/index.vue`](frontend_admin/pages/providers/%5Bid%5D/index.vue) | ❌ | ✅ (lines 144-166) | ✅ (lines 146, 153) |
| **Provider List** | [`providers/index.vue`](frontend_admin/pages/providers/index.vue) | ❌ | ❌ uses `provider.address` only | ✅ (line 137) |
| **Garage Edit** | [`garages/[id]/index.vue`](frontend_admin/pages/garages/%5Bid%5D/index.vue) | ✅ (line 534) | ✅ (lines 1014-1023) | ❌ |
| **Garage List** | [`garages/index.vue`](frontend_admin/pages/garages/index.vue) | ❌ | ❌ uses `garage.city` only | ✅ (line 157) |
### 2.6 Already-Refactored Endpoints (Reference)
These endpoints already use `AddressManager` correctly and serve as reference implementations:
| Endpoint | File | How |
|---|---|---|
| **Admin Organizations** | [`admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py) | Lines 1097-1145: `AddressOut.model_validate(org.address)` for 3 address types |
| **User Organizations** | [`organizations.py`](backend/app/api/v1/endpoints/organizations.py) | Lines 71-82: `AddressManager.create_or_update()` on org creation |
| **Admin Persons** | [`admin_persons.py`](backend/app/api/v1/endpoints/admin_persons.py) | Lines 654-661: `AddressManager.create_or_update()` for person.address |
| **User Profile** | [`users.py`](backend/app/api/v1/endpoints/users.py) | Lines 355-374: `GeoService.get_or_create_full_address()` |
---
## 3. Migration Strategy: 3-Phase Plan
```mermaid
flowchart TD
subgraph Phase1["Phase 1: Database (Alembic)"]
A1[Add address_id UUID FK to marketplace.service_providers]
A2[Create data migration: backfill system.addresses from flat columns]
A3[Set FK constraint + add relationship to ServiceProvider model]
A4[Run sync_engine to verify schema]
end
subgraph Phase2["Phase 2: Backend API"]
B1[Simplify update_provider: remove addr_mapping logic]
B2[Refactor _build_unified_providers_query: JOIN with system.addresses]
B3[Update ProviderListItem/ProviderDetail schemas]
B4[Remove flat address field fallback in get_provider_detail]
end
subgraph Phase3["Phase 3: Frontend"]
C1[providers/index.vue: show address_detail instead of flat address]
C2[providers/[id]/index.vue: remove flat fallback]
C3[Clean up flat address fields from edit.vue]
C4[garages/index.vue: show address_detail instead of flat city]
end
Phase1 --> Phase2 --> Phase3
```
### Phase 1 — Database Migration (Model + sync_engine)
**Goal:** Add `address_id` FK to `ServiceProvider` and migrate existing data.
**Steps:**
1. **Add `address_id` column to `ServiceProvider` model** in [`social.py`](backend/app/models/identity/social.py:23):
```python
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
)
address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin")
```
2. **Create data migration script** that:
- Iterates all `ServiceProvider` records with flat address data and NULL `address_id`
- For each record, creates a `system.addresses` record via `AddressManager.create_or_update()`
- Sets `service_providers.address_id` to the new UUID
- Logs the migration results
3. **Run sync_engine**: `docker exec sf_api python /app/backend/app/scripts/sync_engine.py`
4. **Verify**: Query `service_providers` to confirm `address_id` is populated.
5. **Deferred**: Drop flat columns in a future migration after Phase 3 is complete and verified.
### Phase 2 — Backend API Refactor
**Goal:** Simplify `admin_providers.py` to use `AddressManager` directly without flat column mapping.
**Key changes in [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py):**
1. **Simplify `update_provider()`** (lines 1242-1302):
- Remove the `addr_mapping` dict (lines 1277-1302) for `ServiceProvider`
- Both branches now just call `AddressManager.create_or_update()` and set `provider_obj.address_id`
- Remove flat column updates for `address_zip`, `address_street_name`, etc.
2. **Refactor `_build_unified_providers_query()`** (lines 284-407):
- For `ServiceProvider`: Add LEFT JOIN to `system.addresses` and extract fields via relationship
- Remove flat column SELECT for ServiceProvider
- Keep flat fallback temporarily for backward compatibility
3. **Update response schemas** (lines 59-105, 134-151):
- `ProviderListItem`: Add `address_detail: Optional[AddressOut]`
- `ProviderDetail`: Mark flat `address` and `city` as DEPRECATED
- `ProviderUpdateInput`: Keep `address_detail` as primary, mark flat fields deprecated
4. **Simplify `get_provider_detail()`** (lines 534-659):
- Remove flat field fallback (lines 586-587: `sp.address or ""`)
- Always resolve from `address_id` via `AddressManager.get_normalized()`
### Phase 3 — Frontend Cleanup
**Goal:** All provider/garage pages display and edit addresses via `address_detail` only.
**Detailed changes:**
1. **`providers/index.vue`** (line 137): Replace `provider.address` with `provider.address_detail?.full_address_text || [provider.address_detail?.zip, ...].filter(Boolean).join(' ')`
2. **`providers/[id]/index.vue`** (lines 144-166): Remove `|| provider.address` and `|| provider.city` fallbacks (lines 146, 153)
3. **`providers/[id]/edit.vue`** (lines 1133-1134): Remove `data.address` fallback from `data.address_detail || data.address || {}`
4. **`garages/index.vue`** (line 157): Replace `garage.city || '—'` with `garage.address_detail?.city || '—'` or similar
---
## 4. Risk Assessment
| Risk | Impact | Mitigation |
|---|---|---|
| **Data loss** during migration (flat → normalized) | High | Migration script must be transactional with rollback. Test on staging first. |
| **Existing API consumers** send flat address fields | Medium | Keep deprecated fields in schemas with `[DEPRECATED]` tags for one release cycle |
| **Frontend list view** breaks if address_detail is missing | Medium | Add fallback to flat fields in list view during transition period |
| **Service robots** (OSM Scout, OCR) write flat columns directly | Medium | Audit `service_robot_1_scout_osm.py` — it's in the modified files list |
| **Unified query** (3-way UNION ALL) breaks | High | Refactor query carefully to maintain backward compatibility |
---
## 5. Acceptance Criteria
### Phase 1 — Database Migration
- [ ] `marketplace.service_providers` has `address_id` UUID FK → `system.addresses.id`
- [ ] All existing ServiceProvider records with flat address data have corresponding `system.addresses` records linked via `address_id`
- [ ] `ServiceProvider` model has `address` relationship with `lazy="selectin"`
- [ ] `sync_engine` reports no schema drift
### Phase 2 — Backend API
- [ ] `update_provider()` no longer maps `AddressIn` fields to flat columns for `ServiceProvider`
- [ ] `get_provider_detail()` returns `address_detail` from `address_id` relationship for all providers
- [ ] `list_providers()` includes `address_detail` in each item
- [ ] `_build_unified_providers_query()` uses LEFT JOIN for ServiceProvider instead of flat column extraction
- [ ] Flat address fields (`address`, `city`) still work in API requests but are marked `[DEPRECATED]` in schemas
### Phase 3 — Frontend
- [ ] Provider list page shows addresses from `address_detail` (not flat `provider.address`)
- [ ] Provider detail page has no flat fallback for address fields
- [ ] Provider edit page sends `address_detail` only (no flat fields)
- [ ] Garage list page shows city from `address_detail`
---
## 6. References
- [AddressManager service](backend/app/services/address_manager.py:41) — 4 static methods: `resolve()`, `create_or_update()`, `get_normalized()`, `_resolve_postal_code()`
- [AddressIn/AddressOut schemas](backend/app/schemas/address.py:18) — Pydantic models for address API contracts
- [SharedAddressForm component](frontend_admin/components/SharedAddressForm.vue) — Vue form component (search for usage via `SharedAddressForm`)
- [Address model](backend/app/models/identity/address.py:39) — UUID PK, `postal_code_id` FK → `system.geo_postal_codes`
- [Organization model (REFACTORED)](backend/app/models/marketplace/organization.py:105) — Reference implementation with 3 `address_id` FKs
- [Admin Persons (REFACTORED)](backend/app/api/v1/endpoints/admin_persons.py:654) — Reference API implementation
- [Ghost columns cleanup SQL](docs/sql/cleanup_ghost_columns_2026_07.sql) — Reference for how Organization's flat columns were removed

View File

@@ -0,0 +1,279 @@
# P0 DISCOVERY - Audit Report: Provider Validation & Scoring Engine
**Dátum:** 2026-07-07
**Auditor:** Service Finder Rendszer-Architect
**Verzió:** 1.0
---
## 🔍 Executive Summary
Az Architect által definiált Trust & Validation scoring logika (Crawler=20, First Entry=40, User Confirm=+20, Auto-Approve Threshold=80, Admin Approve=100) **NINCS egységesen implementálva**. A pontozás három párhuzamos, egymástól független rendszerben fut, és egyik sem felel meg a specifikációnak.
---
## 1. 📡 CRAWLER/IMPORT ENDPOINT AUDIT
### 1.1 Robot 0 (Hunter - Google Places)
| Fájl | Sor |
|------|-----|
| `service_robot_0_hunter.py` | `trust_score=30 # Alapértelmezett bizalmi szint` (line 115) |
```python
new_entry = ServiceStaging(
...
trust_score=30 # Hardcoded!
)
```
**Probléma:** A `trust_score` hardcoded `30`, míg az Architect specifikáció szerint Crawler forrásnál `20` kellene legyen. A `validation_level` mezőt nem állítja be explicit (default: `40` lesz a modellből).
### 1.2 Robot 1 (Scout - OSM)
| Fájl | Sor |
|------|-----|
| `service_robot_1_scout_osm.py` | `"trust_score": 20` (csak raw_data JSONB-ben!) (line 98) |
```python
raw_payload = {
"osm_tags": tags,
"source": "osm_scout_v2",
"trust_score": 20 # CSAK a raw_data JSONB-ben!
}
new_entry = ServiceStaging(
...
raw_data=raw_payload # trust_score NEM kerül a tényleges oszlopba
)
```
**Probléma:** A `trust_score: 20` a `raw_data` JSONB objektumba kerül, **NEM** a `ServiceStaging.trust_score` mezőbe! Az adatbázis oszlop értéke `0` marad (modell default). Ez adatvesztéshez vezet.
### 1.3 User Submission (Gamification Endpoint)
| Fájl | Sor |
|------|-----|
| `gamification.py` | `trust_weight = min(20 + (user_lvl * 6), 90)` (line 317) |
```python
trust_weight = min(20 + (user_lvl * 6), 90)
...
new_staging = ServiceStaging(
...
trust_score=trust_weight, # Dinamikus, de NEM a First Entry=40 logikát követi
)
```
**Probléma:** A dinamikus számítás (20 + szint*6) NEM felel meg a "First Entry = 40" szabálynak. Egy 1-es szintű user `26`-ot kap, nem `40`-et.
### 1.4 Crawler Import Endpoint (Admin API)
Az `admin_providers.py` fájlban **nincs dedikált crawler import endpoint**. A meglévő `POST /{provider_id}/approve` és `POST /{provider_id}/reject` végpontok csak a már létező `ServiceProvider` rekordok státuszát módosítják, nem hoznak létre újakat.
---
## 2. ⚙️ VALIDATION ENGINE AUDIT
### 2.1 Több, egymástól független scoring mechanizmus
A rendszerben **három párhuzamos scoring rendszer** létezik, amelyek NINCSENEK összekapcsolva:
| # | Scoring System | Tábla | Mező | Ki működteti? |
|---|---------------|-------|------|---------------|
| 1 | **Trust Score** | `marketplace.service_staging` | `trust_score` (default: **0**) | Robotok, User Submission |
| 2 | **Validation Level** | `marketplace.service_staging` | `validation_level` (default: **40**) | Community validation (`services.py`) |
| 3 | **Validation Score** | `marketplace.service_providers` | `validation_score` (default: **0**) | Admin moderáció (`admin_providers.py`) |
### 2.2 Modellek összehasonlítása
#### `ServiceStaging` (`staged_data.py:54-59`)
```python
trust_score: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
validation_level: Mapped[int] = mapped_column(Integer, default=40, server_default=text("40"))
```
- `trust_score`: Robot bizalmi szint (0-100)
- `validation_level`: Közösségi validációs szint (default 40, max 80)
#### `ServiceProvider` (`social.py:59`)
```python
validation_score: Mapped[int] = mapped_column(Integer, default=0)
```
- `validation_score`: Moderációs pontszám (csak admin által állítható)
#### `ProviderValidation` (`social.py:87-123`)
```python
class ProviderValidation(Base):
__tablename__ = "provider_validations"
# validation_type: approve | reject | flag
# weight: admin weight (default=1, admin uses 5)
# metadata: JSONB for details
```
### 2.3 ProviderValidation tábla - Jelenlegi használat
A `_create_provider_validation()` függvény minden admin akciókor (approve/reject/restore/flag) létrehoz egy rekordot:
| Akció | `validation_type` | `weight` | `validation_score` módosítás |
|-------|-------------------|----------|------------------------------|
| Approve | `approve` | 5 | `+= 50` |
| Reject | `reject` | 5 | `= 0` |
| Restore | `approve` | 5 | `= 50` |
| Flag | `flag` | 5 | `-= 20` (min 0) |
**Hiányzó funkció:** A `weight` mezőt soha nem aggregálja a rendszer. Nincs olyan logika, ami a `ProviderValidation.weight`-ok összegét vizsgálná, és annak alapján auto-approve/-reject döntést hozna.
### 2.4 Community Validation (SocialService)
| Fájl | Sor |
|------|-----|
| `social_service.py` | `validation_score += vote_value` (lines 45-53) |
```python
provider.validation_score += vote_value # +1 vagy -1
if provider.validation_score >= 5:
provider.status = ModerationStatus.approved
elif provider.validation_score <= -3:
provider.status = ModerationStatus.rejected
```
**Probléma:** Ez az OLD community voting rendszer (`Vote` tábla, `+-1` szavazatok), ami NEM kapcsolódik a `validation_level` vagy az Architect által definiált scoring logikához. A küszöbértékek (`5` és `-3`) teljesen függetlenek a `80`-as auto-approve threshold-tól.
### 2.5 Service Validation (User Confirm)
| Fájl | Sor |
|------|-----|
| `services.py` | `validation_level += 10` (max 80) (lines 72-75) |
```python
new_level = staging.validation_level + 10
if new_level > 80:
new_level = 80
```
Ez a `validation_level`-t növeli (max 80), de ez **NEM befolyásolja** sem a `trust_score`-t, sem a `validation_score`-t. A `+20` User Confirm bónusz NINCS implementálva.
---
## 3. 🚀 PROMOTION LOGIC AUDIT (Threshold = 80)
### 3.1 System Robot 2 (ServiceAuditor)
| Fájl | Sor |
|------|-----|
| `system_robot_2_service_auditor.py` | Threshold default: **50** (line 37) |
```python
threshold = value.get('trust_score', 50) if isinstance(value, dict) else 50
if stage.trust_score >= threshold:
# SIKERES AUDIT -> Organization + ServiceProfile
```
**GAP:** A threshold default **50**, nem **80**. És a `trust_score`-t vizsgálja, nem a `validation_level`-t.
### 3.2 Service Robot 5 (Auditor)
| Fájl | Sor |
|------|-----|
| `service_robot_5_auditor.py` | Threshold default: **70** (lines 40-45) |
```python
param = result.scalar_one_or_none()
if param and param.value:
return int(param.value)
return 70 # Default
if staging.trust_score < trust_threshold:
staging.status = 'rejected'
```
**GAP:** A threshold default **70**, nem **80**. És itt is a `trust_score`-t vizsgálja.
### 3.3 Admin Approve (validation_level = 100)
| Fájl | Sor |
|------|-----|
| `admin.py` | `validation_level = 100` (lines 265-266) |
```python
staging.validation_level = 100
staging.status = "approved"
```
**Egyedül ez felel meg** az Architect specifikáció "Admin Approve = 100" szabályának, de ez csak a `ServiceStaging` rekordot jelöli meg, nem hoz létre `ServiceProvider`-t vagy `Organization`-t automatikusan.
---
## 4. 📊 HIÁNYOSSÁGOK ÖSSZEGZÉSE
### 🔴 P0 Critical Gaps
| # | Gap | Helye | Következmény |
|---|-----|-------|-------------|
| **G1** | **Nincs egységes scoring engine** | A teljes scoring logika hiányzik | A Crawler=20, First Entry=40, User Confirm=+20 szabályok NINCSENEK sehol implementálva |
| **G2** | **Robot 1 (Scout) trust_score elveszik** | `service_robot_1_scout_osm.py:98` | A `trust_score: 20` a `raw_data` JSONB-ben landol, NEM az oszlopban. A rekord trust_score=0 marad. |
| **G3** | **Auto-Approve Threshold=80 sehol nem érvényesül** | `system_robot_2_service_auditor.py:37` és `service_robot_5_auditor.py:45` | A robotok 50-es és 70-es threshold-ot használnak. A validation_level max 80-as limit elérése nem triggerel semmit. |
### 🟡 P1 Medium Gaps
| # | Gap | Helye | Következmény |
|---|-----|-------|-------------|
| **G4** | **Három független scoring rendszer** | `trust_score`, `validation_level`, `validation_score` | Nincs átjárás a rendszerek között. Egy magas validation_level nem növeli a validation_score-t. |
| **G5** | **ProviderValidation weight aggregáció hiányzik** | `admin_providers.py:211` | A `weight` mező létezik, de soha nincs összegezve auto-approve döntéshez. |
| **G6** | **User submission scoring nem spec-konform** | `gamification.py:317` | `min(20 + (user_lvl*6), 90)` helyett `40` kellene First Entry-nél. |
### 🟢 P2 Minor Gaps
| # | Gap | Helye | Következmény |
|---|-----|-------|-------------|
| **G7** | **Admin approve nem hoz létre Organization-t** | `admin.py:265-266` | Csak a `validation_level=100`-at állítja, de a promotion logika (Staging→Organization) nem fut le. |
| **G8** | **SocialService vote nem kapcsolódik a scoring engine-hez** | `social_service.py:45-53` | A community vote (`+-1`) teljesen külön úton halad, a threshold-ok (5/-3) nem konfigurálhatók. |
---
## 5. 📋 Adatfolyam Diagram (Current State vs Desired State)
### Jelenlegi állapot (As-Is):
```mermaid
flowchart LR
A[Robot 0 Hunter] -->|trust_score=30| B[ServiceStaging]
C[Robot 1 Scout] -->|trust_score=0 raw_data.trust_score=20| B
D[User Submission] -->|trust_score=26-90| B
B -->|status=auditor_ready| E[System Robot 2]
E -->|trust_score>=50| F[Organization + ServiceProfile]
B -->|status=pending| G[Community Validation]
G -->|validation_level+=10 max 80| B
B -->|status=auditing| H[Service Robot 5]
H -->|trust_score>=70| I[Organization + ServiceProfile]
J[Admin] -->|validation_score+=50| K[ServiceProvider]
```
### Kívánt állapot (To-Be) az Architect specifikáció alapján:
```mermaid
flowchart LR
A[Crawler Import] -->|Crawler=20| B[Validation Engine]
C[First Entry User] -->|First Entry=40| B
D[User Confirm] -->|+20| B
E[Admin Approve] -->|=100| B
B -->|score>=80 Auto-Approve| F[Organization + ServiceProvider]
B -->|score<80| G[Pending Review]
```
---
## 6. 🔧 Javasolt Javítási Sorrend
1. **P0 - Create a unified `validation_service.py`** that implements the Architect's scoring math:
- `Crawler = 20` (source="bot")
- `First Entry = 40` (source="manual", first submission)
- `User Confirm = +20` (max 80)
- `Auto-Approve Threshold = 80`
- `Admin Approve = 100`
2. **P0 - Fix Robot 1 Scout** to write `trust_score` to the actual column, not just `raw_data`.
3. **P1 - Unify thresholds** in System Robot 2 and Service Robot 5 to use `validation_level` (or a unified score) instead of `trust_score`.
4. **P1 - Connect scoring systems** so `validation_level` increments also affect the provider's `validation_score`.
5. **P2 - Add auto-promotion** when unified score >= 80: auto-create `ServiceProvider` (or `Organization`) from `ServiceStaging`.

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
const t={common:{loading:{t:0,b:{t:2,i:[{t:3}],s:"Načítavanie..."}},saving:{t:0,b:{t:2,i:[{t:3}],s:"Ukladanie..."}},error:{t:0,b:{t:2,i:[{t:3}],s:"Došlo k chybe"}},retry:{t:0,b:{t:2,i:[{t:3}],s:"Opakovať"}},save:{t:0,b:{t:2,i:[{t:3}],s:"Uložiť"}},saving_data:{t:0,b:{t:2,i:[{t:3}],s:"Ukladanie údajov..."}},cancel:{t:0,b:{t:2,i:[{t:3}],s:"Zrušiť"}},discard:{t:0,b:{t:2,i:[{t:3}],s:"Zahodiť"}},delete:{t:0,b:{t:2,i:[{t:3}],s:"Zmazať"}},delete_confirm:{t:0,b:{t:2,i:[{t:3}],s:"Naozaj chcete zmazať túto položku?"}},delete_btn:{t:0,b:{t:2,i:[{t:3}],s:"Zmazať"}},delete_title:{t:0,b:{t:2,i:[{t:3}],s:"Potvrdiť Zmazanie"}},deleted:{t:0,b:{t:2,i:[{t:3}],s:"Úspešne zmazané"}},edit:{t:0,b:{t:2,i:[{t:3}],s:"Upraviť"}},edit_title:{t:0,b:{t:2,i:[{t:3}],s:"Upraviť Položku"}},create:{t:0,b:{t:2,i:[{t:3}],s:"Vytvoriť"}},create_btn:{t:0,b:{t:2,i:[{t:3}],s:"Vytvoriť"}},create_title:{t:0,b:{t:2,i:[{t:3}],s:"Vytvoriť Nové"}},create_first:{t:0,b:{t:2,i:[{t:3}],s:"Vytvorte svoju prvú položku pre začiatok"}},search:{t:0,b:{t:2,i:[{t:3}],s:"Hľadať"}},search_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Hľadať..."}},filter:{t:0,b:{t:2,i:[{t:3}],s:"Filter"}},no_results:{t:0,b:{t:2,i:[{t:3}],s:"Nenašli sa žiadne výsledky"}},no_results_hint:{t:0,b:{t:2,i:[{t:3}],s:"Skúste upraviť kritériá vyhľadávania alebo filtra"}},no_items:{t:0,b:{t:2,i:[{t:3}],s:"Žiadne položky k dispozícii"}},actions:{t:0,b:{t:2,i:[{t:3}],s:"Akcie"}},status:{t:0,b:{t:2,i:[{t:3}],s:"Stav"}},active:{t:0,b:{t:2,i:[{t:3}],s:"Aktívny"}},inactive:{t:0,b:{t:2,i:[{t:3}],s:"Neaktívny"}},activate:{t:0,b:{t:2,i:[{t:3}],s:"Aktivovať"}},deactivate:{t:0,b:{t:2,i:[{t:3}],s:"Deaktivovať"}},name:{t:0,b:{t:2,i:[{t:3}],s:"Názov"}},name_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Zadajte názov"}},description:{t:0,b:{t:2,i:[{t:3}],s:"Popis"}},desc_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Zadajte popis"}},details:{t:0,b:{t:2,i:[{t:3}],s:"Detaily"}},email:{t:0,b:{t:2,i:[{t:3}],s:"Email"}},id:{t:0,b:{t:2,i:[{t:3}],s:"ID"}},type:{t:0,b:{t:2,i:[{t:3}],s:"Typ"}},type_corporate:{t:0,b:{t:2,i:[{t:3}],s:"Firemný"}},view:{t:0,b:{t:2,i:[{t:3}],s:"Zobraziť"}},updated:{t:0,b:{t:2,i:[{t:3}],s:"Úspešne aktualizované"}},save_error:{t:0,b:{t:2,i:[{t:3}],s:"Uloženie zlyhalo"}},invalid_json:{t:0,b:{t:2,i:[{t:3}],s:"Neplatný formát JSON"}},prev:{t:0,b:{t:2,i:[{t:3}],s:"Predchádzajúci"}},next:{t:0,b:{t:2,i:[{t:3}],s:"Ďalší"}},none:{t:0,b:{t:2,i:[{t:3}],s:"Žiadne"}},reset:{t:0,b:{t:2,i:[{t:3}],s:"Reset"}},confirm:{t:0,b:{t:2,i:[{t:3}],s:"Potvrdiť"}},close:{t:0,b:{t:2,i:[{t:3}],s:"Zavrieť"}},back:{t:0,b:{t:2,i:[{t:3}],s:"Späť"}},continue:{t:0,b:{t:2,i:[{t:3}],s:"Pokračovať"}},exit:{t:0,b:{t:2,i:[{t:3}],s:"Ukončiť"}},all:{t:0,b:{t:2,i:[{t:3}],s:"Všetky"}},select_all:{t:0,b:{t:2,i:[{t:3}],s:"Vybrať Všetky"}},deselect_all:{t:0,b:{t:2,i:[{t:3}],s:"Odznačiť Všetky"}},expires:{t:0,b:{t:2,i:[{t:3}],s:"Vyprší"}},created:{t:0,b:{t:2,i:[{t:3}],s:"Vytvorené"}},start_date:{t:0,b:{t:2,i:[{t:3}],s:"Dátum Začiatku"}},end_date:{t:0,b:{t:2,i:[{t:3}],s:"Dátum Ukončenia"}},points:{t:0,b:{t:2,i:[{t:3}],s:"Body"}},level:{t:0,b:{t:2,i:[{t:3}],s:"Úroveň"}},user:{t:0,b:{t:2,i:[{t:3}],s:"Používateľ"}},user_id:{t:0,b:{t:2,i:[{t:3}],s:"ID Používateľa"}},services:{t:0,b:{t:2,i:[{t:3}],s:"Služby"}},discoveries:{t:0,b:{t:2,i:[{t:3}],s:"Objavy"}},restriction:{t:0,b:{t:2,i:[{t:3}],s:"Obmedzenie"}},restriction_mild:{t:0,b:{t:2,i:[{t:3}],s:"Mierne"}},restriction_moderate:{t:0,b:{t:2,i:[{t:3}],s:"Stredné"}},restriction_severe:{t:0,b:{t:2,i:[{t:3}],s:"Prísne"}},min_xp:{t:0,b:{t:2,i:[{t:3}],s:"Min XP"}},xp:{t:0,b:{t:2,i:[{t:3}],s:"XP"}},penalty:{t:0,b:{t:2,i:[{t:3}],s:"Trest"}},feature_flags:{t:0,b:{t:2,i:[{t:3}],s:"Funkcie"}},permission:{t:0,b:{t:2,i:[{t:3}],s:"Oprávnenie"}},role:{t:0,b:{t:2,i:[{t:3}],s:"Rola"}},domain:{t:0,b:{t:2,i:[{t:3}],s:"Doména"}},action:{t:0,b:{t:2,i:[{t:3}],s:"Akcia"}},value:{t:0,b:{t:2,i:[{t:3}],s:"Hodnota"}},key:{t:0,b:{t:2,i:[{t:3}],s:"Kľúč"}},enabled:{t:0,b:{t:2,i:[{t:3}],s:"Povolené"}},disabled:{t:0,b:{t:2,i:[{t:3}],s:"Zakázané"}},yes:{t:0,b:{t:2,i:[{t:3}],s:"Áno"}},no:{t:0,b:{t:2,i:[{t:3}],s:"Nie"}},on:{t:0,b:{t:2,i:[{t:3}],s:"Zapnuté"}},off:{t:0,b:{t:2,i:[{t:3}],s:"Vypnuté"}}}};export{t as default};

View File

@@ -1 +0,0 @@
import{_ as s}from"./DlAUqK2U.js";import{o,c as t,V as r}from"./BsgqXOrB.js";const c={},n={class:"bg-slate-900 min-h-screen"};function a(e,l){return o(),t("div",n,[r(e.$slots,"default")])}const d=s(c,[["render",a]]);export{d as default};

View File

@@ -1 +0,0 @@
const t={common:{loading:{t:0,b:{t:2,i:[{t:3}],s:"Chargement..."}},saving:{t:0,b:{t:2,i:[{t:3}],s:"Enregistrement..."}},error:{t:0,b:{t:2,i:[{t:3}],s:"Une erreur est survenue"}},retry:{t:0,b:{t:2,i:[{t:3}],s:"Réessayer"}},save:{t:0,b:{t:2,i:[{t:3}],s:"Enregistrer"}},saving_data:{t:0,b:{t:2,i:[{t:3}],s:"Enregistrement des données..."}},cancel:{t:0,b:{t:2,i:[{t:3}],s:"Annuler"}},discard:{t:0,b:{t:2,i:[{t:3}],s:"Abandonner"}},delete:{t:0,b:{t:2,i:[{t:3}],s:"Supprimer"}},delete_confirm:{t:0,b:{t:2,i:[{t:3}],s:"Êtes-vous sûr de vouloir supprimer cet élément ?"}},delete_btn:{t:0,b:{t:2,i:[{t:3}],s:"Supprimer"}},delete_title:{t:0,b:{t:2,i:[{t:3}],s:"Confirmer la suppression"}},deleted:{t:0,b:{t:2,i:[{t:3}],s:"Supprimé avec succès"}},edit:{t:0,b:{t:2,i:[{t:3}],s:"Modifier"}},edit_title:{t:0,b:{t:2,i:[{t:3}],s:"Modifier l'élément"}},create:{t:0,b:{t:2,i:[{t:3}],s:"Créer"}},create_btn:{t:0,b:{t:2,i:[{t:3}],s:"Créer"}},create_title:{t:0,b:{t:2,i:[{t:3}],s:"Créer nouveau"}},create_first:{t:0,b:{t:2,i:[{t:3}],s:"Créez votre premier élément pour commencer"}},search:{t:0,b:{t:2,i:[{t:3}],s:"Rechercher"}},search_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Rechercher..."}},filter:{t:0,b:{t:2,i:[{t:3}],s:"Filtrer"}},no_results:{t:0,b:{t:2,i:[{t:3}],s:"Aucun résultat trouvé"}},no_results_hint:{t:0,b:{t:2,i:[{t:3}],s:"Essayez d'ajuster vos critères de recherche ou de filtre"}},no_items:{t:0,b:{t:2,i:[{t:3}],s:"Aucun élément disponible"}},actions:{t:0,b:{t:2,i:[{t:3}],s:"Actions"}},status:{t:0,b:{t:2,i:[{t:3}],s:"Statut"}},active:{t:0,b:{t:2,i:[{t:3}],s:"Actif"}},inactive:{t:0,b:{t:2,i:[{t:3}],s:"Inactif"}},activate:{t:0,b:{t:2,i:[{t:3}],s:"Activer"}},deactivate:{t:0,b:{t:2,i:[{t:3}],s:"Désactiver"}},name:{t:0,b:{t:2,i:[{t:3}],s:"Nom"}},name_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Entrez le nom"}},description:{t:0,b:{t:2,i:[{t:3}],s:"Description"}},desc_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Entrez la description"}},details:{t:0,b:{t:2,i:[{t:3}],s:"Détails"}},email:{t:0,b:{t:2,i:[{t:3}],s:"E-mail"}},id:{t:0,b:{t:2,i:[{t:3}],s:"ID"}},type:{t:0,b:{t:2,i:[{t:3}],s:"Type"}},type_corporate:{t:0,b:{t:2,i:[{t:3}],s:"Entreprise"}},view:{t:0,b:{t:2,i:[{t:3}],s:"Voir"}},updated:{t:0,b:{t:2,i:[{t:3}],s:"Mis à jour avec succès"}},save_error:{t:0,b:{t:2,i:[{t:3}],s:"Échec de l'enregistrement"}},invalid_json:{t:0,b:{t:2,i:[{t:3}],s:"Format JSON invalide"}},prev:{t:0,b:{t:2,i:[{t:3}],s:"Précédent"}},next:{t:0,b:{t:2,i:[{t:3}],s:"Suivant"}},none:{t:0,b:{t:2,i:[{t:3}],s:"Aucun"}},reset:{t:0,b:{t:2,i:[{t:3}],s:"Réinitialiser"}},confirm:{t:0,b:{t:2,i:[{t:3}],s:"Confirmer"}},close:{t:0,b:{t:2,i:[{t:3}],s:"Fermer"}},back:{t:0,b:{t:2,i:[{t:3}],s:"Retour"}},continue:{t:0,b:{t:2,i:[{t:3}],s:"Continuer"}},exit:{t:0,b:{t:2,i:[{t:3}],s:"Quitter"}},all:{t:0,b:{t:2,i:[{t:3}],s:"Tout"}},select_all:{t:0,b:{t:2,i:[{t:3}],s:"Tout sélectionner"}},deselect_all:{t:0,b:{t:2,i:[{t:3}],s:"Tout désélectionner"}},expires:{t:0,b:{t:2,i:[{t:3}],s:"Expire"}},created:{t:0,b:{t:2,i:[{t:3}],s:"Créé"}},start_date:{t:0,b:{t:2,i:[{t:3}],s:"Date de début"}},end_date:{t:0,b:{t:2,i:[{t:3}],s:"Date de fin"}},points:{t:0,b:{t:2,i:[{t:3}],s:"Points"}},level:{t:0,b:{t:2,i:[{t:3}],s:"Niveau"}},user:{t:0,b:{t:2,i:[{t:3}],s:"Utilisateur"}},user_id:{t:0,b:{t:2,i:[{t:3}],s:"ID utilisateur"}},services:{t:0,b:{t:2,i:[{t:3}],s:"Services"}},discoveries:{t:0,b:{t:2,i:[{t:3}],s:"Découvertes"}},restriction:{t:0,b:{t:2,i:[{t:3}],s:"Restriction"}},restriction_mild:{t:0,b:{t:2,i:[{t:3}],s:"Légère"}},restriction_moderate:{t:0,b:{t:2,i:[{t:3}],s:"Modérée"}},restriction_severe:{t:0,b:{t:2,i:[{t:3}],s:"Sévère"}},min_xp:{t:0,b:{t:2,i:[{t:3}],s:"XP min"}},xp:{t:0,b:{t:2,i:[{t:3}],s:"XP"}},penalty:{t:0,b:{t:2,i:[{t:3}],s:"Pénalité"}},feature_flags:{t:0,b:{t:2,i:[{t:3}],s:"Indicateurs de fonctionnalités"}},permission:{t:0,b:{t:2,i:[{t:3}],s:"Permission"}},role:{t:0,b:{t:2,i:[{t:3}],s:"Rôle"}},domain:{t:0,b:{t:2,i:[{t:3}],s:"Domaine"}},action:{t:0,b:{t:2,i:[{t:3}],s:"Action"}},value:{t:0,b:{t:2,i:[{t:3}],s:"Valeur"}},key:{t:0,b:{t:2,i:[{t:3}],s:"Clé"}},enabled:{t:0,b:{t:2,i:[{t:3}],s:"Activé"}},disabled:{t:0,b:{t:2,i:[{t:3}],s:"Désactivé"}},yes:{t:0,b:{t:2,i:[{t:3}],s:"Oui"}},no:{t:0,b:{t:2,i:[{t:3}],s:"Non"}},on:{t:0,b:{t:2,i:[{t:3}],s:"On"}},off:{t:0,b:{t:2,i:[{t:3}],s:"Off"}}}};export{t as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
const t={common:{loading:{t:0,b:{t:2,i:[{t:3}],s:"Betöltés..."}},saving:{t:0,b:{t:2,i:[{t:3}],s:"Mentés..."}},error:{t:0,b:{t:2,i:[{t:3}],s:"Hiba történt"}},retry:{t:0,b:{t:2,i:[{t:3}],s:"Újra"}},save:{t:0,b:{t:2,i:[{t:3}],s:"Mentés"}},saving_data:{t:0,b:{t:2,i:[{t:3}],s:"Adatok mentése..."}},cancel:{t:0,b:{t:2,i:[{t:3}],s:"Mégsem"}},discard:{t:0,b:{t:2,i:[{t:3}],s:"Elvetés"}},delete:{t:0,b:{t:2,i:[{t:3}],s:"Törlés"}},delete_confirm:{t:0,b:{t:2,i:[{t:3}],s:"Biztosan törölni szeretnéd ezt az elemet?"}},delete_btn:{t:0,b:{t:2,i:[{t:3}],s:"Törlés"}},delete_title:{t:0,b:{t:2,i:[{t:3}],s:"Törlés Megerősítése"}},deleted:{t:0,b:{t:2,i:[{t:3}],s:"Sikeresen törölve"}},edit:{t:0,b:{t:2,i:[{t:3}],s:"Szerkesztés"}},edit_title:{t:0,b:{t:2,i:[{t:3}],s:"Elem Szerkesztése"}},create:{t:0,b:{t:2,i:[{t:3}],s:"Létrehozás"}},create_btn:{t:0,b:{t:2,i:[{t:3}],s:"Létrehozás"}},create_title:{t:0,b:{t:2,i:[{t:3}],s:"Új Létrehozása"}},create_first:{t:0,b:{t:2,i:[{t:3}],s:"Hozd létre az első elemet a kezdéshez"}},search:{t:0,b:{t:2,i:[{t:3}],s:"Keresés"}},search_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Keresés..."}},filter:{t:0,b:{t:2,i:[{t:3}],s:"Szűrés"}},no_results:{t:0,b:{t:2,i:[{t:3}],s:"Nincs találat"}},no_results_hint:{t:0,b:{t:2,i:[{t:3}],s:"Próbáld módosítani a keresési vagy szűrési feltételeket"}},no_items:{t:0,b:{t:2,i:[{t:3}],s:"Nincsenek elemek"}},actions:{t:0,b:{t:2,i:[{t:3}],s:"Műveletek"}},status:{t:0,b:{t:2,i:[{t:3}],s:"Státusz"}},active:{t:0,b:{t:2,i:[{t:3}],s:"Aktív"}},inactive:{t:0,b:{t:2,i:[{t:3}],s:"Inaktív"}},activate:{t:0,b:{t:2,i:[{t:3}],s:"Aktiválás"}},deactivate:{t:0,b:{t:2,i:[{t:3}],s:"Deaktiválás"}},name:{t:0,b:{t:2,i:[{t:3}],s:"Név"}},name_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Add meg a nevet"}},description:{t:0,b:{t:2,i:[{t:3}],s:"Leírás"}},desc_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Add meg a leírást"}},details:{t:0,b:{t:2,i:[{t:3}],s:"Részletek"}},email:{t:0,b:{t:2,i:[{t:3}],s:"E-mail"}},id:{t:0,b:{t:2,i:[{t:3}],s:"Azonosító"}},type:{t:0,b:{t:2,i:[{t:3}],s:"Típus"}},type_corporate:{t:0,b:{t:2,i:[{t:3}],s:"Vállalati"}},view:{t:0,b:{t:2,i:[{t:3}],s:"Megtekintés"}},updated:{t:0,b:{t:2,i:[{t:3}],s:"Sikeresen frissítve"}},save_error:{t:0,b:{t:2,i:[{t:3}],s:"Sikertelen mentés"}},invalid_json:{t:0,b:{t:2,i:[{t:3}],s:"Érvénytelen JSON formátum"}},prev:{t:0,b:{t:2,i:[{t:3}],s:"Előző"}},next:{t:0,b:{t:2,i:[{t:3}],s:"Következő"}},none:{t:0,b:{t:2,i:[{t:3}],s:"Nincs"}},reset:{t:0,b:{t:2,i:[{t:3}],s:"Visszaállítás"}},confirm:{t:0,b:{t:2,i:[{t:3}],s:"Megerősítés"}},close:{t:0,b:{t:2,i:[{t:3}],s:"Bezárás"}},back:{t:0,b:{t:2,i:[{t:3}],s:"Vissza"}},continue:{t:0,b:{t:2,i:[{t:3}],s:"Tovább"}},exit:{t:0,b:{t:2,i:[{t:3}],s:"Kilépés"}},all:{t:0,b:{t:2,i:[{t:3}],s:"Minden"}},select_all:{t:0,b:{t:2,i:[{t:3}],s:"Összes kiválasztása"}},deselect_all:{t:0,b:{t:2,i:[{t:3}],s:"Összes elrejtése"}},expires:{t:0,b:{t:2,i:[{t:3}],s:"Lejárat"}},created:{t:0,b:{t:2,i:[{t:3}],s:"Létrehozva"}},start_date:{t:0,b:{t:2,i:[{t:3}],s:"Kezdő Dátum"}},end_date:{t:0,b:{t:2,i:[{t:3}],s:"Befejező Dátum"}},points:{t:0,b:{t:2,i:[{t:3}],s:"Pontok"}},level:{t:0,b:{t:2,i:[{t:3}],s:"Szint"}},user:{t:0,b:{t:2,i:[{t:3}],s:"Felhasználó"}},user_id:{t:0,b:{t:2,i:[{t:3}],s:"Felhasználó Azonosító"}},services:{t:0,b:{t:2,i:[{t:3}],s:"Szolgáltatások"}},discoveries:{t:0,b:{t:2,i:[{t:3}],s:"Felfedezések"}},restriction:{t:0,b:{t:2,i:[{t:3}],s:"Korlátozás"}},restriction_mild:{t:0,b:{t:2,i:[{t:3}],s:"Enyhe"}},restriction_moderate:{t:0,b:{t:2,i:[{t:3}],s:"Mérsékelt"}},restriction_severe:{t:0,b:{t:2,i:[{t:3}],s:"Súlyos"}},min_xp:{t:0,b:{t:2,i:[{t:3}],s:"Min XP"}},xp:{t:0,b:{t:2,i:[{t:3}],s:"XP"}},penalty:{t:0,b:{t:2,i:[{t:3}],s:"Büntetés"}},feature_flags:{t:0,b:{t:2,i:[{t:3}],s:"Funkció Kapcsolók"}},permission:{t:0,b:{t:2,i:[{t:3}],s:"Engedély"}},role:{t:0,b:{t:2,i:[{t:3}],s:"Szerepkör"}},domain:{t:0,b:{t:2,i:[{t:3}],s:"Tartomány"}},action:{t:0,b:{t:2,i:[{t:3}],s:"Művelet"}},value:{t:0,b:{t:2,i:[{t:3}],s:"Érték"}},key:{t:0,b:{t:2,i:[{t:3}],s:"Kulcs"}},enabled:{t:0,b:{t:2,i:[{t:3}],s:"Bekapcsolva"}},disabled:{t:0,b:{t:2,i:[{t:3}],s:"Kikapcsolva"}},yes:{t:0,b:{t:2,i:[{t:3}],s:"Igen"}},no:{t:0,b:{t:2,i:[{t:3}],s:"Nem"}},on:{t:0,b:{t:2,i:[{t:3}],s:"Be"}},off:{t:0,b:{t:2,i:[{t:3}],s:"Ki"}}}};export{t as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
const t={common:{loading:{t:0,b:{t:2,i:[{t:3}],s:"Wird geladen..."}},saving:{t:0,b:{t:2,i:[{t:3}],s:"Wird gespeichert..."}},error:{t:0,b:{t:2,i:[{t:3}],s:"Ein Fehler ist aufgetreten"}},retry:{t:0,b:{t:2,i:[{t:3}],s:"Wiederholen"}},save:{t:0,b:{t:2,i:[{t:3}],s:"Speichern"}},saving_data:{t:0,b:{t:2,i:[{t:3}],s:"Daten werden gespeichert..."}},cancel:{t:0,b:{t:2,i:[{t:3}],s:"Abbrechen"}},discard:{t:0,b:{t:2,i:[{t:3}],s:"Verwerfen"}},delete:{t:0,b:{t:2,i:[{t:3}],s:"Löschen"}},delete_confirm:{t:0,b:{t:2,i:[{t:3}],s:"Sind Sie sicher, dass Sie dieses Element löschen möchten?"}},delete_btn:{t:0,b:{t:2,i:[{t:3}],s:"Löschen"}},delete_title:{t:0,b:{t:2,i:[{t:3}],s:"Löschen bestätigen"}},deleted:{t:0,b:{t:2,i:[{t:3}],s:"Erfolgreich gelöscht"}},edit:{t:0,b:{t:2,i:[{t:3}],s:"Bearbeiten"}},edit_title:{t:0,b:{t:2,i:[{t:3}],s:"Element bearbeiten"}},create:{t:0,b:{t:2,i:[{t:3}],s:"Erstellen"}},create_btn:{t:0,b:{t:2,i:[{t:3}],s:"Erstellen"}},create_title:{t:0,b:{t:2,i:[{t:3}],s:"Neu erstellen"}},create_first:{t:0,b:{t:2,i:[{t:3}],s:"Erstellen Sie Ihr erstes Element, um zu beginnen"}},search:{t:0,b:{t:2,i:[{t:3}],s:"Suchen"}},search_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Suchen..."}},filter:{t:0,b:{t:2,i:[{t:3}],s:"Filter"}},no_results:{t:0,b:{t:2,i:[{t:3}],s:"Keine Ergebnisse gefunden"}},no_results_hint:{t:0,b:{t:2,i:[{t:3}],s:"Versuchen Sie, Ihre Such- oder Filterkriterien anzupassen"}},no_items:{t:0,b:{t:2,i:[{t:3}],s:"Keine Elemente verfügbar"}},actions:{t:0,b:{t:2,i:[{t:3}],s:"Aktionen"}},status:{t:0,b:{t:2,i:[{t:3}],s:"Status"}},active:{t:0,b:{t:2,i:[{t:3}],s:"Aktiv"}},inactive:{t:0,b:{t:2,i:[{t:3}],s:"Inaktiv"}},activate:{t:0,b:{t:2,i:[{t:3}],s:"Aktivieren"}},deactivate:{t:0,b:{t:2,i:[{t:3}],s:"Deaktivieren"}},name:{t:0,b:{t:2,i:[{t:3}],s:"Name"}},name_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Namen eingeben"}},description:{t:0,b:{t:2,i:[{t:3}],s:"Beschreibung"}},desc_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Beschreibung eingeben"}},details:{t:0,b:{t:2,i:[{t:3}],s:"Details"}},email:{t:0,b:{t:2,i:[{t:3}],s:"E-Mail"}},id:{t:0,b:{t:2,i:[{t:3}],s:"ID"}},type:{t:0,b:{t:2,i:[{t:3}],s:"Typ"}},type_corporate:{t:0,b:{t:2,i:[{t:3}],s:"Unternehmen"}},view:{t:0,b:{t:2,i:[{t:3}],s:"Ansehen"}},updated:{t:0,b:{t:2,i:[{t:3}],s:"Erfolgreich aktualisiert"}},save_error:{t:0,b:{t:2,i:[{t:3}],s:"Speichern fehlgeschlagen"}},invalid_json:{t:0,b:{t:2,i:[{t:3}],s:"Ungültiges JSON-Format"}},prev:{t:0,b:{t:2,i:[{t:3}],s:"Vorherige"}},next:{t:0,b:{t:2,i:[{t:3}],s:"Nächste"}},none:{t:0,b:{t:2,i:[{t:3}],s:"Keine"}},reset:{t:0,b:{t:2,i:[{t:3}],s:"Zurücksetzen"}},confirm:{t:0,b:{t:2,i:[{t:3}],s:"Bestätigen"}},close:{t:0,b:{t:2,i:[{t:3}],s:"Schließen"}},back:{t:0,b:{t:2,i:[{t:3}],s:"Zurück"}},continue:{t:0,b:{t:2,i:[{t:3}],s:"Fortfahren"}},exit:{t:0,b:{t:2,i:[{t:3}],s:"Beenden"}},all:{t:0,b:{t:2,i:[{t:3}],s:"Alle"}},select_all:{t:0,b:{t:2,i:[{t:3}],s:"Alle auswählen"}},deselect_all:{t:0,b:{t:2,i:[{t:3}],s:"Alle abwählen"}},expires:{t:0,b:{t:2,i:[{t:3}],s:"Läuft ab"}},created:{t:0,b:{t:2,i:[{t:3}],s:"Erstellt"}},start_date:{t:0,b:{t:2,i:[{t:3}],s:"Startdatum"}},end_date:{t:0,b:{t:2,i:[{t:3}],s:"Enddatum"}},points:{t:0,b:{t:2,i:[{t:3}],s:"Punkte"}},level:{t:0,b:{t:2,i:[{t:3}],s:"Stufe"}},user:{t:0,b:{t:2,i:[{t:3}],s:"Benutzer"}},user_id:{t:0,b:{t:2,i:[{t:3}],s:"Benutzer-ID"}},services:{t:0,b:{t:2,i:[{t:3}],s:"Dienstleistungen"}},discoveries:{t:0,b:{t:2,i:[{t:3}],s:"Entdeckungen"}},restriction:{t:0,b:{t:2,i:[{t:3}],s:"Einschränkung"}},restriction_mild:{t:0,b:{t:2,i:[{t:3}],s:"Mild"}},restriction_moderate:{t:0,b:{t:2,i:[{t:3}],s:"Moderat"}},restriction_severe:{t:0,b:{t:2,i:[{t:3}],s:"Schwer"}},min_xp:{t:0,b:{t:2,i:[{t:3}],s:"Min. XP"}},xp:{t:0,b:{t:2,i:[{t:3}],s:"XP"}},penalty:{t:0,b:{t:2,i:[{t:3}],s:"Strafe"}},feature_flags:{t:0,b:{t:2,i:[{t:3}],s:"Funktionsschalter"}},permission:{t:0,b:{t:2,i:[{t:3}],s:"Berechtigung"}},role:{t:0,b:{t:2,i:[{t:3}],s:"Rolle"}},domain:{t:0,b:{t:2,i:[{t:3}],s:"Domäne"}},action:{t:0,b:{t:2,i:[{t:3}],s:"Aktion"}},value:{t:0,b:{t:2,i:[{t:3}],s:"Wert"}},key:{t:0,b:{t:2,i:[{t:3}],s:"Schlüssel"}},enabled:{t:0,b:{t:2,i:[{t:3}],s:"Aktiviert"}},disabled:{t:0,b:{t:2,i:[{t:3}],s:"Deaktiviert"}},yes:{t:0,b:{t:2,i:[{t:3}],s:"Ja"}},no:{t:0,b:{t:2,i:[{t:3}],s:"Nein"}},on:{t:0,b:{t:2,i:[{t:3}],s:"Ein"}},off:{t:0,b:{t:2,i:[{t:3}],s:"Aus"}}}};export{t as default};

View File

@@ -1 +0,0 @@
const s=(t,r)=>{const o=t.__vccOpts||t;for(const[c,e]of r)o[c]=e;return o};export{s as _};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
const t={common:{loading:{t:0,b:{t:2,i:[{t:3}],s:"Loading..."}},saving:{t:0,b:{t:2,i:[{t:3}],s:"Saving..."}},error:{t:0,b:{t:2,i:[{t:3}],s:"An error occurred"}},retry:{t:0,b:{t:2,i:[{t:3}],s:"Retry"}},save:{t:0,b:{t:2,i:[{t:3}],s:"Save"}},saving_data:{t:0,b:{t:2,i:[{t:3}],s:"Saving data..."}},cancel:{t:0,b:{t:2,i:[{t:3}],s:"Cancel"}},discard:{t:0,b:{t:2,i:[{t:3}],s:"Discard"}},delete:{t:0,b:{t:2,i:[{t:3}],s:"Delete"}},delete_confirm:{t:0,b:{t:2,i:[{t:3}],s:"Are you sure you want to delete this item?"}},delete_btn:{t:0,b:{t:2,i:[{t:3}],s:"Delete"}},delete_title:{t:0,b:{t:2,i:[{t:3}],s:"Confirm Deletion"}},deleted:{t:0,b:{t:2,i:[{t:3}],s:"Deleted successfully"}},edit:{t:0,b:{t:2,i:[{t:3}],s:"Edit"}},edit_title:{t:0,b:{t:2,i:[{t:3}],s:"Edit Item"}},create:{t:0,b:{t:2,i:[{t:3}],s:"Create"}},create_btn:{t:0,b:{t:2,i:[{t:3}],s:"Create"}},create_title:{t:0,b:{t:2,i:[{t:3}],s:"Create New"}},create_first:{t:0,b:{t:2,i:[{t:3}],s:"Create your first item to get started"}},search:{t:0,b:{t:2,i:[{t:3}],s:"Search"}},search_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Search..."}},filter:{t:0,b:{t:2,i:[{t:3}],s:"Filter"}},no_results:{t:0,b:{t:2,i:[{t:3}],s:"No results found"}},no_results_hint:{t:0,b:{t:2,i:[{t:3}],s:"Try adjusting your search or filter criteria"}},no_items:{t:0,b:{t:2,i:[{t:3}],s:"No items available"}},actions:{t:0,b:{t:2,i:[{t:3}],s:"Actions"}},status:{t:0,b:{t:2,i:[{t:3}],s:"Status"}},active:{t:0,b:{t:2,i:[{t:3}],s:"Active"}},inactive:{t:0,b:{t:2,i:[{t:3}],s:"Inactive"}},activate:{t:0,b:{t:2,i:[{t:3}],s:"Activate"}},deactivate:{t:0,b:{t:2,i:[{t:3}],s:"Deactivate"}},name:{t:0,b:{t:2,i:[{t:3}],s:"Name"}},name_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Enter name"}},description:{t:0,b:{t:2,i:[{t:3}],s:"Description"}},desc_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Enter description"}},details:{t:0,b:{t:2,i:[{t:3}],s:"Details"}},email:{t:0,b:{t:2,i:[{t:3}],s:"Email"}},id:{t:0,b:{t:2,i:[{t:3}],s:"ID"}},type:{t:0,b:{t:2,i:[{t:3}],s:"Type"}},type_corporate:{t:0,b:{t:2,i:[{t:3}],s:"Corporate"}},view:{t:0,b:{t:2,i:[{t:3}],s:"View"}},updated:{t:0,b:{t:2,i:[{t:3}],s:"Updated successfully"}},save_error:{t:0,b:{t:2,i:[{t:3}],s:"Failed to save"}},invalid_json:{t:0,b:{t:2,i:[{t:3}],s:"Invalid JSON format"}},prev:{t:0,b:{t:2,i:[{t:3}],s:"Previous"}},next:{t:0,b:{t:2,i:[{t:3}],s:"Next"}},none:{t:0,b:{t:2,i:[{t:3}],s:"None"}},reset:{t:0,b:{t:2,i:[{t:3}],s:"Reset"}},confirm:{t:0,b:{t:2,i:[{t:3}],s:"Confirm"}},close:{t:0,b:{t:2,i:[{t:3}],s:"Close"}},back:{t:0,b:{t:2,i:[{t:3}],s:"Back"}},continue:{t:0,b:{t:2,i:[{t:3}],s:"Continue"}},exit:{t:0,b:{t:2,i:[{t:3}],s:"Exit"}},all:{t:0,b:{t:2,i:[{t:3}],s:"All"}},select_all:{t:0,b:{t:2,i:[{t:3}],s:"Select All"}},deselect_all:{t:0,b:{t:2,i:[{t:3}],s:"Deselect All"}},expires:{t:0,b:{t:2,i:[{t:3}],s:"Expires"}},created:{t:0,b:{t:2,i:[{t:3}],s:"Created"}},start_date:{t:0,b:{t:2,i:[{t:3}],s:"Start Date"}},end_date:{t:0,b:{t:2,i:[{t:3}],s:"End Date"}},points:{t:0,b:{t:2,i:[{t:3}],s:"Points"}},level:{t:0,b:{t:2,i:[{t:3}],s:"Level"}},user:{t:0,b:{t:2,i:[{t:3}],s:"User"}},user_id:{t:0,b:{t:2,i:[{t:3}],s:"User ID"}},services:{t:0,b:{t:2,i:[{t:3}],s:"Services"}},discoveries:{t:0,b:{t:2,i:[{t:3}],s:"Discoveries"}},restriction:{t:0,b:{t:2,i:[{t:3}],s:"Restriction"}},restriction_mild:{t:0,b:{t:2,i:[{t:3}],s:"Mild"}},restriction_moderate:{t:0,b:{t:2,i:[{t:3}],s:"Moderate"}},restriction_severe:{t:0,b:{t:2,i:[{t:3}],s:"Severe"}},min_xp:{t:0,b:{t:2,i:[{t:3}],s:"Min XP"}},xp:{t:0,b:{t:2,i:[{t:3}],s:"XP"}},penalty:{t:0,b:{t:2,i:[{t:3}],s:"Penalty"}},feature_flags:{t:0,b:{t:2,i:[{t:3}],s:"Feature Flags"}},permission:{t:0,b:{t:2,i:[{t:3}],s:"Permission"}},role:{t:0,b:{t:2,i:[{t:3}],s:"Role"}},domain:{t:0,b:{t:2,i:[{t:3}],s:"Domain"}},action:{t:0,b:{t:2,i:[{t:3}],s:"Action"}},value:{t:0,b:{t:2,i:[{t:3}],s:"Value"}},key:{t:0,b:{t:2,i:[{t:3}],s:"Key"}},enabled:{t:0,b:{t:2,i:[{t:3}],s:"Enabled"}},disabled:{t:0,b:{t:2,i:[{t:3}],s:"Disabled"}},yes:{t:0,b:{t:2,i:[{t:3}],s:"Yes"}},no:{t:0,b:{t:2,i:[{t:3}],s:"No"}},on:{t:0,b:{t:2,i:[{t:3}],s:"On"}},off:{t:0,b:{t:2,i:[{t:3}],s:"Off"}}}};export{t as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
const t={common:{loading:{t:0,b:{t:2,i:[{t:3}],s:"Se încarcă..."}},saving:{t:0,b:{t:2,i:[{t:3}],s:"Se salvează..."}},error:{t:0,b:{t:2,i:[{t:3}],s:"A apărut o eroare"}},retry:{t:0,b:{t:2,i:[{t:3}],s:"Reîncercare"}},save:{t:0,b:{t:2,i:[{t:3}],s:"Salvează"}},saving_data:{t:0,b:{t:2,i:[{t:3}],s:"Se salvează datele..."}},cancel:{t:0,b:{t:2,i:[{t:3}],s:"Anulează"}},discard:{t:0,b:{t:2,i:[{t:3}],s:"Renunță"}},delete:{t:0,b:{t:2,i:[{t:3}],s:"Șterge"}},delete_confirm:{t:0,b:{t:2,i:[{t:3}],s:"Ești sigur că vrei să ștergi acest element?"}},delete_btn:{t:0,b:{t:2,i:[{t:3}],s:"Șterge"}},delete_title:{t:0,b:{t:2,i:[{t:3}],s:"Confirmă Ștergerea"}},deleted:{t:0,b:{t:2,i:[{t:3}],s:"Șters cu succes"}},edit:{t:0,b:{t:2,i:[{t:3}],s:"Editează"}},edit_title:{t:0,b:{t:2,i:[{t:3}],s:"Editează Elementul"}},create:{t:0,b:{t:2,i:[{t:3}],s:"Creează"}},create_btn:{t:0,b:{t:2,i:[{t:3}],s:"Creează"}},create_title:{t:0,b:{t:2,i:[{t:3}],s:"Creează Nou"}},create_first:{t:0,b:{t:2,i:[{t:3}],s:"Creează primul tău element pentru a începe"}},search:{t:0,b:{t:2,i:[{t:3}],s:"Caută"}},search_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Caută..."}},filter:{t:0,b:{t:2,i:[{t:3}],s:"Filtru"}},no_results:{t:0,b:{t:2,i:[{t:3}],s:"Niciun rezultat găsit"}},no_results_hint:{t:0,b:{t:2,i:[{t:3}],s:"Încearcă să ajustezi criteriile de căutare sau filtru"}},no_items:{t:0,b:{t:2,i:[{t:3}],s:"Niciun element disponibil"}},actions:{t:0,b:{t:2,i:[{t:3}],s:"Acțiuni"}},status:{t:0,b:{t:2,i:[{t:3}],s:"Status"}},active:{t:0,b:{t:2,i:[{t:3}],s:"Activ"}},inactive:{t:0,b:{t:2,i:[{t:3}],s:"Inactiv"}},activate:{t:0,b:{t:2,i:[{t:3}],s:"Activează"}},deactivate:{t:0,b:{t:2,i:[{t:3}],s:"Dezactivează"}},name:{t:0,b:{t:2,i:[{t:3}],s:"Nume"}},name_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Introdu numele"}},description:{t:0,b:{t:2,i:[{t:3}],s:"Descriere"}},desc_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Introdu descrierea"}},details:{t:0,b:{t:2,i:[{t:3}],s:"Detalii"}},email:{t:0,b:{t:2,i:[{t:3}],s:"Email"}},id:{t:0,b:{t:2,i:[{t:3}],s:"ID"}},type:{t:0,b:{t:2,i:[{t:3}],s:"Tip"}},type_corporate:{t:0,b:{t:2,i:[{t:3}],s:"Corporativ"}},view:{t:0,b:{t:2,i:[{t:3}],s:"Vizualizează"}},updated:{t:0,b:{t:2,i:[{t:3}],s:"Actualizat cu succes"}},save_error:{t:0,b:{t:2,i:[{t:3}],s:"Salvarea a eșuat"}},invalid_json:{t:0,b:{t:2,i:[{t:3}],s:"Format JSON invalid"}},prev:{t:0,b:{t:2,i:[{t:3}],s:"Anterior"}},next:{t:0,b:{t:2,i:[{t:3}],s:"Următor"}},none:{t:0,b:{t:2,i:[{t:3}],s:"Niciunul"}},reset:{t:0,b:{t:2,i:[{t:3}],s:"Resetează"}},confirm:{t:0,b:{t:2,i:[{t:3}],s:"Confirmă"}},close:{t:0,b:{t:2,i:[{t:3}],s:"Închide"}},back:{t:0,b:{t:2,i:[{t:3}],s:"Înapoi"}},continue:{t:0,b:{t:2,i:[{t:3}],s:"Continuă"}},exit:{t:0,b:{t:2,i:[{t:3}],s:"Ieșire"}},all:{t:0,b:{t:2,i:[{t:3}],s:"Toate"}},select_all:{t:0,b:{t:2,i:[{t:3}],s:"Selectează Tot"}},deselect_all:{t:0,b:{t:2,i:[{t:3}],s:"Deselectează Tot"}},expires:{t:0,b:{t:2,i:[{t:3}],s:"Expiră"}},created:{t:0,b:{t:2,i:[{t:3}],s:"Creat"}},start_date:{t:0,b:{t:2,i:[{t:3}],s:"Data Începerii"}},end_date:{t:0,b:{t:2,i:[{t:3}],s:"Data Încheierii"}},points:{t:0,b:{t:2,i:[{t:3}],s:"Puncte"}},level:{t:0,b:{t:2,i:[{t:3}],s:"Nivel"}},user:{t:0,b:{t:2,i:[{t:3}],s:"Utilizator"}},user_id:{t:0,b:{t:2,i:[{t:3}],s:"ID Utilizator"}},services:{t:0,b:{t:2,i:[{t:3}],s:"Servicii"}},discoveries:{t:0,b:{t:2,i:[{t:3}],s:"Descoperiri"}},restriction:{t:0,b:{t:2,i:[{t:3}],s:"Restricție"}},restriction_mild:{t:0,b:{t:2,i:[{t:3}],s:"Ușoară"}},restriction_moderate:{t:0,b:{t:2,i:[{t:3}],s:"Moderată"}},restriction_severe:{t:0,b:{t:2,i:[{t:3}],s:"Severă"}},min_xp:{t:0,b:{t:2,i:[{t:3}],s:"XP Min"}},xp:{t:0,b:{t:2,i:[{t:3}],s:"XP"}},penalty:{t:0,b:{t:2,i:[{t:3}],s:"Penalizare"}},feature_flags:{t:0,b:{t:2,i:[{t:3}],s:"Funcții"}},permission:{t:0,b:{t:2,i:[{t:3}],s:"Permisiune"}},role:{t:0,b:{t:2,i:[{t:3}],s:"Rol"}},domain:{t:0,b:{t:2,i:[{t:3}],s:"Domeniu"}},action:{t:0,b:{t:2,i:[{t:3}],s:"Acțiune"}},value:{t:0,b:{t:2,i:[{t:3}],s:"Valoare"}},key:{t:0,b:{t:2,i:[{t:3}],s:"Cheie"}},enabled:{t:0,b:{t:2,i:[{t:3}],s:"Activat"}},disabled:{t:0,b:{t:2,i:[{t:3}],s:"Dezactivat"}},yes:{t:0,b:{t:2,i:[{t:3}],s:"Da"}},no:{t:0,b:{t:2,i:[{t:3}],s:"Nu"}},on:{t:0,b:{t:2,i:[{t:3}],s:"Pornit"}},off:{t:0,b:{t:2,i:[{t:3}],s:"Oprit"}}}};export{t as default};

View File

@@ -1 +0,0 @@
const t={common:{loading:{t:0,b:{t:2,i:[{t:3}],s:"Načítání..."}},saving:{t:0,b:{t:2,i:[{t:3}],s:"Ukládání..."}},error:{t:0,b:{t:2,i:[{t:3}],s:"Došlo k chybě"}},retry:{t:0,b:{t:2,i:[{t:3}],s:"Opakovat"}},save:{t:0,b:{t:2,i:[{t:3}],s:"Uložit"}},saving_data:{t:0,b:{t:2,i:[{t:3}],s:"Ukládání dat..."}},cancel:{t:0,b:{t:2,i:[{t:3}],s:"Zrušit"}},discard:{t:0,b:{t:2,i:[{t:3}],s:"Zahodit"}},delete:{t:0,b:{t:2,i:[{t:3}],s:"Smazat"}},delete_confirm:{t:0,b:{t:2,i:[{t:3}],s:"Opravdu chcete smazat tuto položku?"}},delete_btn:{t:0,b:{t:2,i:[{t:3}],s:"Smazat"}},delete_title:{t:0,b:{t:2,i:[{t:3}],s:"Potvrdit Smazání"}},deleted:{t:0,b:{t:2,i:[{t:3}],s:"Úspěšně smazáno"}},edit:{t:0,b:{t:2,i:[{t:3}],s:"Upravit"}},edit_title:{t:0,b:{t:2,i:[{t:3}],s:"Upravit Položku"}},create:{t:0,b:{t:2,i:[{t:3}],s:"Vytvořit"}},create_btn:{t:0,b:{t:2,i:[{t:3}],s:"Vytvořit"}},create_title:{t:0,b:{t:2,i:[{t:3}],s:"Vytvořit Nové"}},create_first:{t:0,b:{t:2,i:[{t:3}],s:"Vytvořte svou první položku pro začátek"}},search:{t:0,b:{t:2,i:[{t:3}],s:"Hledat"}},search_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Hledat..."}},filter:{t:0,b:{t:2,i:[{t:3}],s:"Filtr"}},no_results:{t:0,b:{t:2,i:[{t:3}],s:"Nebyly nalezeny žádné výsledky"}},no_results_hint:{t:0,b:{t:2,i:[{t:3}],s:"Zkuste upravit kritéria vyhledávání nebo filtru"}},no_items:{t:0,b:{t:2,i:[{t:3}],s:"Žádné položky k dispozici"}},actions:{t:0,b:{t:2,i:[{t:3}],s:"Akce"}},status:{t:0,b:{t:2,i:[{t:3}],s:"Stav"}},active:{t:0,b:{t:2,i:[{t:3}],s:"Aktivní"}},inactive:{t:0,b:{t:2,i:[{t:3}],s:"Neaktivní"}},activate:{t:0,b:{t:2,i:[{t:3}],s:"Aktivovat"}},deactivate:{t:0,b:{t:2,i:[{t:3}],s:"Deaktivovat"}},name:{t:0,b:{t:2,i:[{t:3}],s:"Název"}},name_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Zadejte název"}},description:{t:0,b:{t:2,i:[{t:3}],s:"Popis"}},desc_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Zadejte popis"}},details:{t:0,b:{t:2,i:[{t:3}],s:"Detaily"}},email:{t:0,b:{t:2,i:[{t:3}],s:"Email"}},id:{t:0,b:{t:2,i:[{t:3}],s:"ID"}},type:{t:0,b:{t:2,i:[{t:3}],s:"Typ"}},type_corporate:{t:0,b:{t:2,i:[{t:3}],s:"Firemní"}},view:{t:0,b:{t:2,i:[{t:3}],s:"Zobrazit"}},updated:{t:0,b:{t:2,i:[{t:3}],s:"Úspěšně aktualizováno"}},save_error:{t:0,b:{t:2,i:[{t:3}],s:"Uložení selhalo"}},invalid_json:{t:0,b:{t:2,i:[{t:3}],s:"Neplatný formát JSON"}},prev:{t:0,b:{t:2,i:[{t:3}],s:"Předchozí"}},next:{t:0,b:{t:2,i:[{t:3}],s:"Další"}},none:{t:0,b:{t:2,i:[{t:3}],s:"Žádné"}},reset:{t:0,b:{t:2,i:[{t:3}],s:"Reset"}},confirm:{t:0,b:{t:2,i:[{t:3}],s:"Potvrdit"}},close:{t:0,b:{t:2,i:[{t:3}],s:"Zavřít"}},back:{t:0,b:{t:2,i:[{t:3}],s:"Zpět"}},continue:{t:0,b:{t:2,i:[{t:3}],s:"Pokračovat"}},exit:{t:0,b:{t:2,i:[{t:3}],s:"Ukončit"}},all:{t:0,b:{t:2,i:[{t:3}],s:"Vše"}},select_all:{t:0,b:{t:2,i:[{t:3}],s:"Vybrat Vše"}},deselect_all:{t:0,b:{t:2,i:[{t:3}],s:"Odznačit Vše"}},expires:{t:0,b:{t:2,i:[{t:3}],s:"Vyprší"}},created:{t:0,b:{t:2,i:[{t:3}],s:"Vytvořeno"}},start_date:{t:0,b:{t:2,i:[{t:3}],s:"Datum Zahájení"}},end_date:{t:0,b:{t:2,i:[{t:3}],s:"Datum Ukončení"}},points:{t:0,b:{t:2,i:[{t:3}],s:"Body"}},level:{t:0,b:{t:2,i:[{t:3}],s:"Úroveň"}},user:{t:0,b:{t:2,i:[{t:3}],s:"Uživatel"}},user_id:{t:0,b:{t:2,i:[{t:3}],s:"ID Uživatele"}},services:{t:0,b:{t:2,i:[{t:3}],s:"Služby"}},discoveries:{t:0,b:{t:2,i:[{t:3}],s:"Objevy"}},restriction:{t:0,b:{t:2,i:[{t:3}],s:"Omezení"}},restriction_mild:{t:0,b:{t:2,i:[{t:3}],s:"Mírné"}},restriction_moderate:{t:0,b:{t:2,i:[{t:3}],s:"Střední"}},restriction_severe:{t:0,b:{t:2,i:[{t:3}],s:"Přísné"}},min_xp:{t:0,b:{t:2,i:[{t:3}],s:"Min XP"}},xp:{t:0,b:{t:2,i:[{t:3}],s:"XP"}},penalty:{t:0,b:{t:2,i:[{t:3}],s:"Trest"}},feature_flags:{t:0,b:{t:2,i:[{t:3}],s:"Funkce"}},permission:{t:0,b:{t:2,i:[{t:3}],s:"Oprávnění"}},role:{t:0,b:{t:2,i:[{t:3}],s:"Role"}},domain:{t:0,b:{t:2,i:[{t:3}],s:"Doména"}},action:{t:0,b:{t:2,i:[{t:3}],s:"Akce"}},value:{t:0,b:{t:2,i:[{t:3}],s:"Hodnota"}},key:{t:0,b:{t:2,i:[{t:3}],s:"Klíč"}},enabled:{t:0,b:{t:2,i:[{t:3}],s:"Povoleno"}},disabled:{t:0,b:{t:2,i:[{t:3}],s:"Zakázáno"}},yes:{t:0,b:{t:2,i:[{t:3}],s:"Ano"}},no:{t:0,b:{t:2,i:[{t:3}],s:"Ne"}},on:{t:0,b:{t:2,i:[{t:3}],s:"Zapnuto"}},off:{t:0,b:{t:2,i:[{t:3}],s:"Vypnuto"}}}};export{t as default};

View File

@@ -1 +0,0 @@
.animate-slide-up[data-v-0bf5a572]{animation:slideUp-0bf5a572 .3s ease-out}@keyframes slideUp-0bf5a572{0%{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}

View File

@@ -1 +0,0 @@
.overflow-y-auto[data-v-b43e13ca]::-webkit-scrollbar{width:6px}.overflow-y-auto[data-v-b43e13ca]::-webkit-scrollbar-track{background:transparent}.overflow-y-auto[data-v-b43e13ca]::-webkit-scrollbar-thumb{background-color:#64748b80;border-radius:3px}.overflow-y-auto[data-v-ab22d560]::-webkit-scrollbar{width:6px}.overflow-y-auto[data-v-ab22d560]::-webkit-scrollbar-track{background:transparent}.overflow-y-auto[data-v-ab22d560]::-webkit-scrollbar-thumb{background-color:#64748b80;border-radius:3px}.overflow-y-auto[data-v-ab22d560]::-webkit-scrollbar-thumb:hover{background-color:#64748bb3}

View File

@@ -1 +0,0 @@
body{margin:0;padding:0}

View File

@@ -1 +0,0 @@
.spotlight[data-v-1bd9e11a]{background:linear-gradient(45deg,#00dc82,#36e4da 50%,#0047e1);bottom:-30vh;filter:blur(20vh);height:40vh}.gradient-border[data-v-1bd9e11a]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-radius:.5rem;position:relative}@media(prefers-color-scheme:light){.gradient-border[data-v-1bd9e11a]{background-color:#ffffff4d}.gradient-border[data-v-1bd9e11a]:before{background:linear-gradient(90deg,#e2e2e2,#e2e2e2 25%,#00dc82,#36e4da 75%,#0047e1)}}@media(prefers-color-scheme:dark){.gradient-border[data-v-1bd9e11a]{background-color:#1414144d}.gradient-border[data-v-1bd9e11a]:before{background:linear-gradient(90deg,#303030,#303030 25%,#00dc82,#36e4da 75%,#0047e1)}}.gradient-border[data-v-1bd9e11a]:before{background-size:400% auto;border-radius:.5rem;content:"";inset:0;-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);-webkit-mask-composite:xor;mask-composite:exclude;opacity:.5;padding:2px;position:absolute;transition:background-position .3s ease-in-out,opacity .2s ease-in-out;width:100%}.gradient-border[data-v-1bd9e11a]:hover:before{background-position:-50% 0;opacity:1}.fixed[data-v-1bd9e11a]{position:fixed}.left-0[data-v-1bd9e11a]{left:0}.right-0[data-v-1bd9e11a]{right:0}.z-10[data-v-1bd9e11a]{z-index:10}.z-20[data-v-1bd9e11a]{z-index:20}.grid[data-v-1bd9e11a]{display:grid}.mb-16[data-v-1bd9e11a]{margin-bottom:4rem}.mb-8[data-v-1bd9e11a]{margin-bottom:2rem}.max-w-520px[data-v-1bd9e11a]{max-width:520px}.min-h-screen[data-v-1bd9e11a]{min-height:100vh}.w-full[data-v-1bd9e11a]{width:100%}.flex[data-v-1bd9e11a]{display:flex}.cursor-pointer[data-v-1bd9e11a]{cursor:pointer}.place-content-center[data-v-1bd9e11a]{place-content:center}.items-center[data-v-1bd9e11a]{align-items:center}.justify-center[data-v-1bd9e11a]{justify-content:center}.overflow-hidden[data-v-1bd9e11a]{overflow:hidden}.bg-white[data-v-1bd9e11a]{--un-bg-opacity:1;background-color:rgb(255 255 255/var(--un-bg-opacity))}.px-4[data-v-1bd9e11a]{padding-left:1rem;padding-right:1rem}.px-8[data-v-1bd9e11a]{padding-left:2rem;padding-right:2rem}.py-2[data-v-1bd9e11a]{padding-bottom:.5rem;padding-top:.5rem}.text-center[data-v-1bd9e11a]{text-align:center}.text-8xl[data-v-1bd9e11a]{font-size:6rem;line-height:1}.text-xl[data-v-1bd9e11a]{font-size:1.25rem;line-height:1.75rem}.text-black[data-v-1bd9e11a]{--un-text-opacity:1;color:rgb(0 0 0/var(--un-text-opacity))}.font-light[data-v-1bd9e11a]{font-weight:300}.font-medium[data-v-1bd9e11a]{font-weight:500}.leading-tight[data-v-1bd9e11a]{line-height:1.25}.font-sans[data-v-1bd9e11a]{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.antialiased[data-v-1bd9e11a]{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media(prefers-color-scheme:dark){.dark\:bg-black[data-v-1bd9e11a]{--un-bg-opacity:1;background-color:rgb(0 0 0/var(--un-bg-opacity))}.dark\:text-white[data-v-1bd9e11a]{--un-text-opacity:1;color:rgb(255 255 255/var(--un-text-opacity))}}@media(min-width:640px){.sm\:px-0[data-v-1bd9e11a]{padding-left:0;padding-right:0}.sm\:px-6[data-v-1bd9e11a]{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-3[data-v-1bd9e11a]{padding-bottom:.75rem;padding-top:.75rem}.sm\:text-4xl[data-v-1bd9e11a]{font-size:2.25rem;line-height:2.5rem}.sm\:text-xl[data-v-1bd9e11a]{font-size:1.25rem;line-height:1.75rem}}

View File

@@ -1 +0,0 @@
.spotlight[data-v-a01dd0ba]{background:linear-gradient(45deg,#00dc82,#36e4da 50%,#0047e1);filter:blur(20vh)}.fixed[data-v-a01dd0ba]{position:fixed}.-bottom-1\/2[data-v-a01dd0ba]{bottom:-50%}.left-0[data-v-a01dd0ba]{left:0}.right-0[data-v-a01dd0ba]{right:0}.grid[data-v-a01dd0ba]{display:grid}.mb-16[data-v-a01dd0ba]{margin-bottom:4rem}.mb-8[data-v-a01dd0ba]{margin-bottom:2rem}.h-1\/2[data-v-a01dd0ba]{height:50%}.max-w-520px[data-v-a01dd0ba]{max-width:520px}.min-h-screen[data-v-a01dd0ba]{min-height:100vh}.place-content-center[data-v-a01dd0ba]{place-content:center}.overflow-hidden[data-v-a01dd0ba]{overflow:hidden}.bg-white[data-v-a01dd0ba]{--un-bg-opacity:1;background-color:rgb(255 255 255/var(--un-bg-opacity))}.px-8[data-v-a01dd0ba]{padding-left:2rem;padding-right:2rem}.text-center[data-v-a01dd0ba]{text-align:center}.text-8xl[data-v-a01dd0ba]{font-size:6rem;line-height:1}.text-xl[data-v-a01dd0ba]{font-size:1.25rem;line-height:1.75rem}.text-black[data-v-a01dd0ba]{--un-text-opacity:1;color:rgb(0 0 0/var(--un-text-opacity))}.font-light[data-v-a01dd0ba]{font-weight:300}.font-medium[data-v-a01dd0ba]{font-weight:500}.leading-tight[data-v-a01dd0ba]{line-height:1.25}.font-sans[data-v-a01dd0ba]{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.antialiased[data-v-a01dd0ba]{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media(prefers-color-scheme:dark){.dark\:bg-black[data-v-a01dd0ba]{--un-bg-opacity:1;background-color:rgb(0 0 0/var(--un-bg-opacity))}.dark\:text-white[data-v-a01dd0ba]{--un-text-opacity:1;color:rgb(255 255 255/var(--un-text-opacity))}}@media(min-width:640px){.sm\:px-0[data-v-a01dd0ba]{padding-left:0;padding-right:0}.sm\:text-4xl[data-v-a01dd0ba]{font-size:2.25rem;line-height:2.5rem}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.animate-slide-up[data-v-c684092a]{animation:slideUp-c684092a .3s ease-out}@keyframes slideUp-c684092a{0%{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}

View File

@@ -1 +0,0 @@
input[type=range][data-v-5941cb1d]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background:#6366f1;border:2px solid #1e293b;border-radius:50%;cursor:pointer;height:16px;width:16px}input[type=range][data-v-5941cb1d]::-moz-range-thumb{background:#6366f1;border:2px solid #1e293b;border-radius:50%;cursor:pointer;height:16px;width:16px}.timeline-enter-active[data-v-5941cb1d]{transition:all .3s ease-out}.timeline-enter-from[data-v-5941cb1d]{opacity:0;transform:translate(-10px)}

View File

@@ -1 +0,0 @@
.animate-slide-up[data-v-46ba4a43]{animation:slideUp-46ba4a43 .3s ease-out}@keyframes slideUp-46ba4a43{0%{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}

View File

@@ -1 +0,0 @@
.animate-slide-up[data-v-5fa877fc]{animation:slideUp-5fa877fc .3s ease-out}@keyframes slideUp-5fa877fc{0%{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}

View File

@@ -1 +0,0 @@
.animate-slide-up[data-v-8222d5f7]{animation:slideUp-8222d5f7 .3s ease-out}@keyframes slideUp-8222d5f7{0%{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}

View File

@@ -1 +0,0 @@
.animate-slide-up[data-v-9dff1238]{animation:slideUp-9dff1238 .3s ease-out}@keyframes slideUp-9dff1238{0%{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}

View File

@@ -1 +0,0 @@
.animate-slide-up[data-v-f21993c6]{animation:slideUp-f21993c6 .3s ease-out}@keyframes slideUp-f21993c6{0%{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}

View File

@@ -1 +0,0 @@
.animate-slide-up[data-v-956f226d]{animation:slideUp-956f226d .3s ease-out}@keyframes slideUp-956f226d{0%{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +0,0 @@
import style_0 from "./edit-styles-1.mjs-J0c-7dzy.js";
export default [
style_0
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +0,0 @@
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
export {
_export_sfc as _
};
//# sourceMappingURL=_plugin-vue_export-helper-1tPrXgE0.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"_plugin-vue_export-helper-1tPrXgE0.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;"}

View File

@@ -1 +0,0 @@
{"file":"_plugin-vue_export-helper-1tPrXgE0.js","mappings":";;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -1,4 +0,0 @@
import style_0 from "./entry-styles-3.mjs-C1rWf53M.js";
export default [
style_0
]

View File

@@ -1,53 +0,0 @@
import { executeAsync } from "/app/node_modules/unctx/dist/index.mjs";
import { j as defineNuxtRouteMiddleware, d as useCookie, n as navigateTo, c as useAuthStore } from "../server.mjs";
import "vue";
import "/app/node_modules/ofetch/dist/node.mjs";
import "#internal/nuxt/paths";
import "/app/node_modules/hookable/dist/index.mjs";
import "/app/node_modules/h3/dist/index.mjs";
import "pinia";
import "/app/node_modules/defu/dist/defu.mjs";
import "vue-router";
import "/app/node_modules/ufo/dist/index.mjs";
import "/app/node_modules/klona/dist/index.mjs";
import "/app/node_modules/cookie-es/dist/index.mjs";
import "/app/node_modules/destr/dist/index.mjs";
import "/app/node_modules/ohash/dist/index.mjs";
import "/app/node_modules/@unhead/vue/dist/index.mjs";
import "@vue/devtools-api";
import "vue/server-renderer";
const auth = defineNuxtRouteMiddleware(async (to, from) => {
let __temp, __restore;
if (to.path === "/login") {
return;
}
const tokenCookie = useCookie("access_token");
if (!tokenCookie.value) {
return navigateTo("/login");
}
const authStore = useAuthStore();
if (!authStore.user) {
try {
;
[__temp, __restore] = executeAsync(() => authStore.fetchUser()), await __temp, __restore();
;
} catch {
authStore.logout();
return navigateTo("/login");
}
}
if (!authStore.user) {
authStore.logout();
return navigateTo("/login");
}
const allowedRoles = ["SUPERADMIN", "ADMIN", "MODERATOR", "SALES_REP", "SERVICE_MGR"];
const userRole = (authStore.user.role || "").toUpperCase();
if (!allowedRoles.includes(userRole)) {
authStore.logout();
return navigateTo("/login");
}
});
export {
auth as default
};
//# sourceMappingURL=auth-nNLBs3fT.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"auth-nNLBs3fT.js","sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"names":["__executeAsync"],"mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;"}

View File

@@ -1 +0,0 @@
{"file":"auth-nNLBs3fT.js","mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;","names":["__executeAsync"],"sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"version":3}

View File

@@ -1,110 +0,0 @@
import { defineComponent, ref, reactive, unref, useSSRContext } from "vue";
import { ssrRenderAttrs, ssrInterpolate, ssrRenderList, ssrRenderAttr, ssrIncludeBooleanAttr, ssrRenderClass } from "vue/server-renderer";
import "/app/node_modules/hookable/dist/index.mjs";
import "/app/node_modules/klona/dist/index.mjs";
import { a as useI18n } from "../server.mjs";
import "/app/node_modules/ofetch/dist/node.mjs";
import "#internal/nuxt/paths";
import "/app/node_modules/unctx/dist/index.mjs";
import "/app/node_modules/h3/dist/index.mjs";
import "pinia";
import "/app/node_modules/defu/dist/defu.mjs";
import "vue-router";
import "/app/node_modules/ufo/dist/index.mjs";
import "/app/node_modules/cookie-es/dist/index.mjs";
import "/app/node_modules/destr/dist/index.mjs";
import "/app/node_modules/ohash/dist/index.mjs";
import "/app/node_modules/@unhead/vue/dist/index.mjs";
import "@vue/devtools-api";
const _sfc_main = /* @__PURE__ */ defineComponent({
__name: "badges",
__ssrInlineRender: true,
setup(__props) {
const { t } = useI18n();
const loading = ref(true);
const error = ref(false);
const saving = ref(false);
const badges = ref([]);
const showModal = ref(false);
const showDeleteModal = ref(false);
const editingBadge = ref(null);
const deletingBadge = ref(null);
ref(false);
const toast = ref(null);
const form = reactive({
name: "",
description: "",
icon_url: ""
});
return (_ctx, _push, _parent, _attrs) => {
_push(`<div${ssrRenderAttrs(_attrs)}><div class="mb-8"><h1 class="text-2xl font-bold text-white">${ssrInterpolate(unref(t)("gamification.badges.title"))}</h1><p class="text-slate-400 mt-1">${ssrInterpolate(unref(t)("gamification.badges.subtitle"))}</p></div>`);
if (unref(loading)) {
_push(`<div class="flex items-center justify-center py-20"><div class="text-slate-400 flex items-center gap-3"><svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg><span>${ssrInterpolate(unref(t)("gamification.badges.loading"))}</span></div></div>`);
} else if (unref(error)) {
_push(`<div class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8"><p class="text-rose-400">${ssrInterpolate(unref(t)("gamification.badges.error"))}</p><button class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">${ssrInterpolate(unref(t)("gamification.badges.retry"))}</button></div>`);
} else {
_push(`<!--[--><div class="mb-6 flex justify-end"><button class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition flex items-center gap-2"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg> ${ssrInterpolate(unref(t)("gamification.badges.create"))}</button></div><div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden"><table class="w-full"><thead><tr class="border-b border-slate-700"><th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">${ssrInterpolate(unref(t)("gamification.badges.id"))}</th><th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">${ssrInterpolate(unref(t)("gamification.badges.name"))}</th><th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">${ssrInterpolate(unref(t)("gamification.badges.description"))}</th><th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">${ssrInterpolate(unref(t)("gamification.badges.icon"))}</th><th class="text-right px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">${ssrInterpolate(unref(t)("gamification.badges.actions"))}</th></tr></thead><tbody class="divide-y divide-slate-700/50"><!--[-->`);
ssrRenderList(unref(badges), (badge) => {
_push(`<tr class="hover:bg-slate-700/30 transition"><td class="px-6 py-4 text-sm text-slate-400">${ssrInterpolate(badge.id)}</td><td class="px-6 py-4"><span class="text-sm font-semibold text-white">${ssrInterpolate(badge.name)}</span></td><td class="px-6 py-4 text-sm text-slate-300 max-w-xs truncate">${ssrInterpolate(badge.description || "—")}</td><td class="px-6 py-4">`);
if (badge.icon_url) {
_push(`<div class="flex items-center gap-2"><img${ssrRenderAttr("src", badge.icon_url)} alt="" class="w-8 h-8 rounded-full object-cover bg-slate-700"><span class="text-xs text-slate-400 truncate max-w-[120px]">${ssrInterpolate(badge.icon_url)}</span></div>`);
} else {
_push(`<span class="text-sm text-slate-500">—</span>`);
}
_push(`</td><td class="px-6 py-4 text-right"><div class="flex items-center justify-end gap-2"><button class="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition"${ssrRenderAttr("title", unref(t)("gamification.badges.edit"))}><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg></button><button class="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 transition"${ssrRenderAttr("title", unref(t)("gamification.badges.delete"))}><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button></div></td></tr>`);
});
_push(`<!--]-->`);
if (unref(badges).length === 0) {
_push(`<tr><td colspan="5" class="px-6 py-12 text-center text-sm text-slate-500">${ssrInterpolate(unref(t)("gamification.badges.no_items"))}</td></tr>`);
} else {
_push(`<!---->`);
}
_push(`</tbody></table></div><!--]-->`);
}
if (unref(showModal)) {
_push(`<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-lg mx-4 shadow-2xl"><h2 class="text-lg font-semibold text-white mb-6">${ssrInterpolate(unref(editingBadge) ? unref(t)("gamification.badges.edit_title") : unref(t)("gamification.badges.create_title"))}</h2><form class="space-y-4"><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.badges.name"))} <span class="text-rose-400">*</span></label><input${ssrRenderAttr("value", unref(form).name)} type="text" required maxlength="100" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"${ssrRenderAttr("placeholder", unref(t)("gamification.badges.name_placeholder"))}></div><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.badges.description"))} <span class="text-rose-400">*</span></label><textarea required maxlength="500" rows="3" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"${ssrRenderAttr("placeholder", unref(t)("gamification.badges.desc_placeholder"))}>${ssrInterpolate(unref(form).description)}</textarea></div><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.badges.icon"))}</label><input${ssrRenderAttr("value", unref(form).icon_url)} type="url" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"${ssrRenderAttr("placeholder", unref(t)("gamification.badges.icon_placeholder"))}><p class="text-xs text-slate-500 mt-1">${ssrInterpolate(unref(t)("gamification.badges.icon_hint"))}</p></div>`);
if (unref(form).icon_url) {
_push(`<div class="flex items-center gap-3 p-3 bg-slate-700/50 rounded-lg"><img${ssrRenderAttr("src", unref(form).icon_url)} alt="Preview" class="w-10 h-10 rounded-full object-cover bg-slate-600"><div class="text-xs text-slate-400"><span class="text-emerald-400">✔</span> ${ssrInterpolate(unref(t)("gamification.badges.icon_preview"))}</div></div>`);
} else {
_push(`<!---->`);
}
_push(`<div class="flex items-center justify-end gap-3 pt-2"><button type="button" class="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition">${ssrInterpolate(unref(t)("gamification.badges.cancel"))}</button><button type="submit"${ssrIncludeBooleanAttr(unref(saving)) ? " disabled" : ""} class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition flex items-center gap-2">`);
if (unref(saving)) {
_push(`<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>`);
} else {
_push(`<!---->`);
}
_push(` ${ssrInterpolate(unref(editingBadge) ? unref(t)("gamification.badges.save") : unref(t)("gamification.badges.create_btn"))}</button></div></form></div></div>`);
} else {
_push(`<!---->`);
}
if (unref(showDeleteModal)) {
_push(`<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-md mx-4 shadow-2xl"><div class="flex items-center gap-3 mb-4"><div class="p-2 rounded-full bg-rose-500/10 text-rose-400"><svg class="w-6 h-6" 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"></path></svg></div><div><h2 class="text-lg font-semibold text-white">${ssrInterpolate(unref(t)("gamification.badges.delete_title"))}</h2><p class="text-sm text-slate-400 mt-1">${ssrInterpolate(unref(t)("gamification.badges.delete_confirm"))}</p></div></div><div class="bg-slate-700/50 rounded-lg p-4 mb-6"><p class="text-sm font-medium text-white">${ssrInterpolate(unref(deletingBadge)?.name)}</p><p class="text-xs text-slate-400 mt-1">${ssrInterpolate(unref(deletingBadge)?.description)}</p></div><div class="flex items-center justify-end gap-3"><button class="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition">${ssrInterpolate(unref(t)("gamification.badges.cancel"))}</button><button${ssrIncludeBooleanAttr(unref(saving)) ? " disabled" : ""} class="px-4 py-2 bg-rose-600 hover:bg-rose-500 disabled:bg-rose-600/50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition flex items-center gap-2">`);
if (unref(saving)) {
_push(`<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>`);
} else {
_push(`<!---->`);
}
_push(` ${ssrInterpolate(unref(t)("gamification.badges.delete_btn"))}</button></div></div></div>`);
} else {
_push(`<!---->`);
}
if (unref(toast)) {
_push(`<div class="${ssrRenderClass([unref(toast).type === "success" ? "bg-emerald-600 text-white" : "bg-rose-600 text-white", "fixed bottom-6 right-6 z-50 px-4 py-3 rounded-lg shadow-xl text-sm font-medium transition-all duration-300"])}">${ssrInterpolate(unref(toast).message)}</div>`);
} else {
_push(`<!---->`);
}
_push(`</div>`);
};
}
});
const _sfc_setup = _sfc_main.setup;
_sfc_main.setup = (props, ctx) => {
const ssrContext = useSSRContext();
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("pages/gamification/badges.vue");
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
};
export {
_sfc_main as default
};
//# sourceMappingURL=badges-BJ6bQeZt.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,20 +0,0 @@
import { mergeProps, useSSRContext } from "vue";
import { ssrRenderAttrs, ssrRenderSlot } from "vue/server-renderer";
import { _ as _export_sfc } from "./_plugin-vue_export-helper-1tPrXgE0.js";
const _sfc_main = {};
function _sfc_ssrRender(_ctx, _push, _parent, _attrs) {
_push(`<div${ssrRenderAttrs(mergeProps({ class: "bg-slate-900 min-h-screen" }, _attrs))}>`);
ssrRenderSlot(_ctx.$slots, "default", {}, null, _push, _parent);
_push(`</div>`);
}
const _sfc_setup = _sfc_main.setup;
_sfc_main.setup = (props, ctx) => {
const ssrContext = useSSRContext();
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("layouts/blank.vue");
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
};
const blank = /* @__PURE__ */ _export_sfc(_sfc_main, [["ssrRender", _sfc_ssrRender]]);
export {
blank as default
};
//# sourceMappingURL=blank-DWT4RB69.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"blank-DWT4RB69.js","sources":["../../../../layouts/blank.vue"],"sourcesContent":["<template>\n <div class=\"bg-slate-900 min-h-screen\">\n <slot />\n </div>\n</template>\n"],"names":["_ssrRenderAttrs","_mergeProps"],"mappings":";;;;;AACO,QAAA,OAAAA,eAAAC,WAAA,EAAA,OAAM,+BAA2B,MAAA,CAAA,CAAA,GAAA;;;;;;;;;;;"}

View File

@@ -1 +0,0 @@
{"file":"blank-DWT4RB69.js","mappings":";;;;;AACO,QAAA,OAAAA,eAAAC,WAAA,EAAA,OAAM,+BAA2B,MAAA,CAAA,CAAA,GAAA;;;;;;;;;;;","names":["_ssrRenderAttrs","_mergeProps"],"sources":["../../../../layouts/blank.vue"],"sourcesContent":["<template>\n <div class=\"bg-slate-900 min-h-screen\">\n <slot />\n </div>\n</template>\n"],"version":3}

View File

@@ -1,151 +0,0 @@
import { defineComponent, ref, reactive, unref, useSSRContext } from "vue";
import { ssrRenderAttrs, ssrInterpolate, ssrRenderAttr, ssrIncludeBooleanAttr, ssrLooseContain, ssrLooseEqual, ssrRenderList, ssrRenderClass } from "vue/server-renderer";
import "/app/node_modules/hookable/dist/index.mjs";
import "/app/node_modules/klona/dist/index.mjs";
import { a as useI18n } from "../server.mjs";
import "/app/node_modules/ofetch/dist/node.mjs";
import "#internal/nuxt/paths";
import "/app/node_modules/unctx/dist/index.mjs";
import "/app/node_modules/h3/dist/index.mjs";
import "pinia";
import "/app/node_modules/defu/dist/defu.mjs";
import "vue-router";
import "/app/node_modules/ufo/dist/index.mjs";
import "/app/node_modules/cookie-es/dist/index.mjs";
import "/app/node_modules/destr/dist/index.mjs";
import "/app/node_modules/ohash/dist/index.mjs";
import "/app/node_modules/@unhead/vue/dist/index.mjs";
import "@vue/devtools-api";
const _sfc_main = /* @__PURE__ */ defineComponent({
__name: "competitions",
__ssrInlineRender: true,
setup(__props) {
const { t } = useI18n();
const loading = ref(true);
const error = ref(false);
const saving = ref(false);
const showModal = ref(false);
const seasons = ref([]);
const competitions = ref([]);
const editingCompetition = ref(null);
const selectedSeasonId = ref(null);
const formError = ref("");
const rulesParseError = ref(false);
const form = reactive({
name: "",
description: "",
season_id: "",
start_date: "",
end_date: "",
status: "draft",
rulesJson: ""
});
function formatDate(dateStr) {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleDateString("hu-HU", { year: "numeric", month: "long", day: "numeric" });
}
function getStatusClass(status) {
switch (status) {
case "active":
return "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30";
case "draft":
return "bg-slate-700 text-slate-400 border border-slate-600";
case "completed":
return "bg-blue-500/10 text-blue-400 border border-blue-500/30";
case "cancelled":
return "bg-rose-500/10 text-rose-400 border border-rose-500/30";
default:
return "bg-slate-700 text-slate-400 border border-slate-600";
}
}
function getStatusLabel(status) {
switch (status) {
case "active":
return t("gamification.competitions.status_active");
case "draft":
return t("gamification.competitions.status_draft");
case "completed":
return t("gamification.competitions.status_completed");
case "cancelled":
return t("gamification.competitions.status_cancelled");
default:
return status;
}
}
return (_ctx, _push, _parent, _attrs) => {
_push(`<div${ssrRenderAttrs(_attrs)}><div class="mb-8 flex items-center justify-between"><div><h1 class="text-2xl font-bold text-white">${ssrInterpolate(unref(t)("gamification.competitions.title"))}</h1><p class="text-slate-400 mt-1">${ssrInterpolate(unref(t)("gamification.competitions.subtitle"))}</p></div><button class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg> ${ssrInterpolate(unref(t)("gamification.competitions.create"))}</button></div><div class="mb-6"><div class="flex items-center gap-3 flex-wrap"><label class="text-sm text-slate-400">${ssrInterpolate(unref(t)("gamification.competitions.season_filter"))}</label><select class="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"><option${ssrRenderAttr("value", null)}${ssrIncludeBooleanAttr(Array.isArray(unref(selectedSeasonId)) ? ssrLooseContain(unref(selectedSeasonId), null) : ssrLooseEqual(unref(selectedSeasonId), null)) ? " selected" : ""}>${ssrInterpolate(unref(t)("gamification.competitions.all_seasons"))}</option><!--[-->`);
ssrRenderList(unref(seasons), (s) => {
_push(`<option${ssrRenderAttr("value", s.id)}${ssrIncludeBooleanAttr(Array.isArray(unref(selectedSeasonId)) ? ssrLooseContain(unref(selectedSeasonId), s.id) : ssrLooseEqual(unref(selectedSeasonId), s.id)) ? " selected" : ""}>${ssrInterpolate(s.name)} ${ssrInterpolate(s.is_active ? unref(t)("gamification.competitions.active_badge") : "")}</option>`);
});
_push(`<!--]--></select></div></div>`);
if (unref(loading)) {
_push(`<div class="flex items-center justify-center py-20"><div class="text-slate-400 flex items-center gap-3"><svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg><span>${ssrInterpolate(unref(t)("gamification.competitions.loading"))}</span></div></div>`);
} else if (unref(error)) {
_push(`<div class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8"><p class="text-rose-400">${ssrInterpolate(unref(t)("gamification.competitions.error"))}</p><button class="mt-2 text-sm text-rose-300 hover:text-rose-200 underline">${ssrInterpolate(unref(t)("gamification.competitions.retry"))}</button></div>`);
} else {
_push(`<!--[-->`);
if (unref(competitions).length === 0) {
_push(`<div class="bg-slate-800 rounded-xl border border-slate-700 p-12 text-center"><svg class="w-12 h-12 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z"></path></svg><p class="text-slate-400">${ssrInterpolate(unref(t)("gamification.competitions.no_items"))}</p><button class="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition">${ssrInterpolate(unref(t)("gamification.competitions.create_first"))}</button></div>`);
} else {
_push(`<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"><!--[-->`);
ssrRenderList(unref(competitions), (comp) => {
_push(`<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 hover:border-slate-600 transition"><div class="flex items-start justify-between mb-3"><div><h3 class="text-lg font-semibold text-white">${ssrInterpolate(comp.name)}</h3>`);
if (comp.description) {
_push(`<p class="text-sm text-slate-400 mt-1 line-clamp-2">${ssrInterpolate(comp.description)}</p>`);
} else {
_push(`<!---->`);
}
_push(`</div><span class="${ssrRenderClass([getStatusClass(comp.status), "px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ml-2"])}">${ssrInterpolate(getStatusLabel(comp.status))}</span></div><div class="space-y-2 text-sm text-slate-400 mb-4"><div class="flex items-center gap-2"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg><span>${ssrInterpolate(formatDate(comp.start_date))}${ssrInterpolate(formatDate(comp.end_date))}</span></div><div class="flex items-center gap-2"><svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path></svg><span>${ssrInterpolate(unref(t)("gamification.competitions.season_id_label"))} ${ssrInterpolate(comp.season_id)}</span></div></div>`);
if (comp.rules && Object.keys(comp.rules).length > 0) {
_push(`<div class="mb-4"><details class="text-sm"><summary class="text-indigo-400 cursor-pointer hover:text-indigo-300">${ssrInterpolate(unref(t)("gamification.competitions.view_rules"))}</summary><pre class="mt-2 p-3 bg-slate-900 rounded-lg text-xs text-slate-300 overflow-x-auto">${ssrInterpolate(JSON.stringify(comp.rules, null, 2))}</pre></details></div>`);
} else {
_push(`<!---->`);
}
_push(`<div class="flex items-center gap-2 pt-2 border-t border-slate-700/50"><button class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-indigo-400 hover:bg-indigo-500/10 transition"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg> ${ssrInterpolate(unref(t)("gamification.competitions.edit"))}</button></div></div>`);
});
_push(`<!--]--></div>`);
}
_push(`<!--]-->`);
}
if (unref(showModal)) {
_push(`<div class="fixed inset-0 z-50 flex items-center justify-center p-4"><div class="fixed inset-0 bg-black/60"></div><div class="relative bg-slate-800 rounded-xl border border-slate-700 w-full max-w-lg p-6 shadow-2xl max-h-[90vh] overflow-y-auto"><h2 class="text-lg font-semibold text-white mb-6">${ssrInterpolate(unref(editingCompetition) ? unref(t)("gamification.competitions.edit_title") : unref(t)("gamification.competitions.create_title"))}</h2><form class="space-y-4"><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.competitions.name"))}</label><input${ssrRenderAttr("value", unref(form).name)} type="text" required class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"${ssrRenderAttr("placeholder", unref(t)("gamification.competitions.name_placeholder"))}></div><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.competitions.description"))}</label><textarea rows="3" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"${ssrRenderAttr("placeholder", unref(t)("gamification.competitions.desc_placeholder"))}>${ssrInterpolate(unref(form).description)}</textarea></div><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.competitions.season"))}</label><select required class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"><option value="" disabled${ssrIncludeBooleanAttr(Array.isArray(unref(form).season_id) ? ssrLooseContain(unref(form).season_id, "") : ssrLooseEqual(unref(form).season_id, "")) ? " selected" : ""}>${ssrInterpolate(unref(t)("gamification.competitions.select_season"))}</option><!--[-->`);
ssrRenderList(unref(seasons), (s) => {
_push(`<option${ssrRenderAttr("value", s.id)}${ssrIncludeBooleanAttr(Array.isArray(unref(form).season_id) ? ssrLooseContain(unref(form).season_id, s.id) : ssrLooseEqual(unref(form).season_id, s.id)) ? " selected" : ""}>${ssrInterpolate(s.name)} ${ssrInterpolate(s.is_active ? unref(t)("gamification.competitions.active_badge") : "")}</option>`);
});
_push(`<!--]--></select></div><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.competitions.start_date"))}</label><input${ssrRenderAttr("value", unref(form).start_date)} type="date" required class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"></div><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.competitions.end_date"))}</label><input${ssrRenderAttr("value", unref(form).end_date)} type="date" required class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"></div><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.competitions.status"))}</label><select class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"><option value="draft"${ssrIncludeBooleanAttr(Array.isArray(unref(form).status) ? ssrLooseContain(unref(form).status, "draft") : ssrLooseEqual(unref(form).status, "draft")) ? " selected" : ""}>${ssrInterpolate(unref(t)("gamification.competitions.status_draft"))}</option><option value="active"${ssrIncludeBooleanAttr(Array.isArray(unref(form).status) ? ssrLooseContain(unref(form).status, "active") : ssrLooseEqual(unref(form).status, "active")) ? " selected" : ""}>${ssrInterpolate(unref(t)("gamification.competitions.status_active"))}</option><option value="completed"${ssrIncludeBooleanAttr(Array.isArray(unref(form).status) ? ssrLooseContain(unref(form).status, "completed") : ssrLooseEqual(unref(form).status, "completed")) ? " selected" : ""}>${ssrInterpolate(unref(t)("gamification.competitions.status_completed"))}</option><option value="cancelled"${ssrIncludeBooleanAttr(Array.isArray(unref(form).status) ? ssrLooseContain(unref(form).status, "cancelled") : ssrLooseEqual(unref(form).status, "cancelled")) ? " selected" : ""}>${ssrInterpolate(unref(t)("gamification.competitions.status_cancelled"))}</option></select></div><div><label class="block text-sm font-medium text-slate-300 mb-1">${ssrInterpolate(unref(t)("gamification.competitions.rules"))}</label><textarea rows="5" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"${ssrRenderAttr("placeholder", unref(t)("gamification.competitions.rules_placeholder"))}>${ssrInterpolate(unref(form).rulesJson)}</textarea>`);
if (unref(rulesParseError)) {
_push(`<p class="text-xs text-rose-400 mt-1">${ssrInterpolate(unref(t)("gamification.competitions.invalid_json"))}</p>`);
} else {
_push(`<!---->`);
}
_push(`</div>`);
if (unref(formError)) {
_push(`<div class="bg-rose-500/10 border border-rose-500/30 rounded-lg p-3"><p class="text-sm text-rose-400">${ssrInterpolate(unref(formError))}</p></div>`);
} else {
_push(`<!---->`);
}
_push(`<div class="flex items-center justify-end gap-3 pt-2"><button type="button" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">${ssrInterpolate(unref(t)("gamification.competitions.cancel"))}</button><button type="submit"${ssrIncludeBooleanAttr(unref(saving) || unref(rulesParseError)) ? " disabled" : ""} class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white text-sm font-medium rounded-lg transition flex items-center gap-2">`);
if (unref(saving)) {
_push(`<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>`);
} else {
_push(`<!---->`);
}
_push(` ${ssrInterpolate(unref(editingCompetition) ? unref(t)("gamification.competitions.save") : unref(t)("gamification.competitions.create_btn"))}</button></div></form></div></div>`);
} else {
_push(`<!---->`);
}
_push(`</div>`);
};
}
});
const _sfc_setup = _sfc_main.setup;
_sfc_main.setup = (props, ctx) => {
const ssrContext = useSSRContext();
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("pages/gamification/competitions.vue");
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
};
export {
_sfc_main as default
};
//# sourceMappingURL=competitions-1WDTeHdG.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +0,0 @@
const config_vue_vue_type_style_index_0_scoped_0bf5a572_lang = ".animate-slide-up[data-v-0bf5a572]{animation:slideUp-0bf5a572 .3s ease-out}@keyframes slideUp-0bf5a572{0%{opacity:0;transform:translateY(1rem)}to{opacity:1;transform:translateY(0)}}";
export {
config_vue_vue_type_style_index_0_scoped_0bf5a572_lang as default
};
//# sourceMappingURL=config-styles-1.mjs-w3nI6KxA.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"config-styles-1.mjs-w3nI6KxA.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -1 +0,0 @@
{"file":"config-styles-1.mjs-w3nI6KxA.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -1,4 +0,0 @@
import style_0 from "./config-styles-1.mjs-w3nI6KxA.js";
export default [
style_0
]

View File

@@ -1,94 +0,0 @@
const resource = {
"common": {
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Načítání..." } },
"saving": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ukládání..." } },
"error": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Došlo k chybě" } },
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Opakovat" } },
"save": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Uložit" } },
"saving_data": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ukládání dat..." } },
"cancel": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zrušit" } },
"discard": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zahodit" } },
"delete": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Smazat" } },
"delete_confirm": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Opravdu chcete smazat tuto položku?" } },
"delete_btn": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Smazat" } },
"delete_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Potvrdit Smazání" } },
"deleted": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Úspěšně smazáno" } },
"edit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Upravit" } },
"edit_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Upravit Položku" } },
"create": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vytvořit" } },
"create_btn": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vytvořit" } },
"create_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vytvořit Nové" } },
"create_first": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vytvořte svou první položku pro začátek" } },
"search": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Hledat" } },
"search_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Hledat..." } },
"filter": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Filtr" } },
"no_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nebyly nalezeny žádné výsledky" } },
"no_results_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zkuste upravit kritéria vyhledávání nebo filtru" } },
"no_items": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Žádné položky k dispozici" } },
"actions": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Akce" } },
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Stav" } },
"active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktivní" } },
"inactive": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Neaktivní" } },
"activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktivovat" } },
"deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deaktivovat" } },
"name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Název" } },
"name_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zadejte název" } },
"description": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Popis" } },
"desc_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zadejte popis" } },
"details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Detaily" } },
"email": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Email" } },
"id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "ID" } },
"type": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Typ" } },
"type_corporate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Firemní" } },
"view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zobrazit" } },
"updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Úspěšně aktualizováno" } },
"save_error": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Uložení selhalo" } },
"invalid_json": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Neplatný formát JSON" } },
"prev": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Předchozí" } },
"next": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Další" } },
"none": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Žádné" } },
"reset": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Reset" } },
"confirm": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Potvrdit" } },
"close": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zavřít" } },
"back": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zpět" } },
"continue": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Pokračovat" } },
"exit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ukončit" } },
"all": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vše" } },
"select_all": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vybrat Vše" } },
"deselect_all": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Odznačit Vše" } },
"expires": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vyprší" } },
"created": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vytvořeno" } },
"start_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Datum Zahájení" } },
"end_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Datum Ukončení" } },
"points": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Body" } },
"level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Úroveň" } },
"user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Uživatel" } },
"user_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "ID Uživatele" } },
"services": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Služby" } },
"discoveries": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Objevy" } },
"restriction": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Omezení" } },
"restriction_mild": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mírné" } },
"restriction_moderate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Střední" } },
"restriction_severe": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Přísné" } },
"min_xp": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Min XP" } },
"xp": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "XP" } },
"penalty": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Trest" } },
"feature_flags": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Funkce" } },
"permission": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Oprávnění" } },
"role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Role" } },
"domain": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Doména" } },
"action": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Akce" } },
"value": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Hodnota" } },
"key": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Klíč" } },
"enabled": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Povoleno" } },
"disabled": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zakázáno" } },
"yes": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ano" } },
"no": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ne" } },
"on": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zapnuto" } },
"off": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vypnuto" } }
}
};
export {
resource as default
};
//# sourceMappingURL=cz-CP7lYDxM.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"cz-CP7lYDxM.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}

View File

@@ -1 +0,0 @@
{"file":"cz-CP7lYDxM.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -1,94 +0,0 @@
const resource = {
"common": {
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Wird geladen..." } },
"saving": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Wird gespeichert..." } },
"error": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ein Fehler ist aufgetreten" } },
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Wiederholen" } },
"save": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Speichern" } },
"saving_data": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Daten werden gespeichert..." } },
"cancel": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Abbrechen" } },
"discard": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Verwerfen" } },
"delete": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Löschen" } },
"delete_confirm": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Sind Sie sicher, dass Sie dieses Element löschen möchten?" } },
"delete_btn": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Löschen" } },
"delete_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Löschen bestätigen" } },
"deleted": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Erfolgreich gelöscht" } },
"edit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Bearbeiten" } },
"edit_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Element bearbeiten" } },
"create": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Erstellen" } },
"create_btn": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Erstellen" } },
"create_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Neu erstellen" } },
"create_first": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Erstellen Sie Ihr erstes Element, um zu beginnen" } },
"search": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Suchen" } },
"search_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Suchen..." } },
"filter": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Filter" } },
"no_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Keine Ergebnisse gefunden" } },
"no_results_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Versuchen Sie, Ihre Such- oder Filterkriterien anzupassen" } },
"no_items": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Keine Elemente verfügbar" } },
"actions": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktionen" } },
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } },
"active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktiv" } },
"inactive": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Inaktiv" } },
"activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktivieren" } },
"deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deaktivieren" } },
"name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Name" } },
"name_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Namen eingeben" } },
"description": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Beschreibung" } },
"desc_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Beschreibung eingeben" } },
"details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Details" } },
"email": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "E-Mail" } },
"id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "ID" } },
"type": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Typ" } },
"type_corporate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Unternehmen" } },
"view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ansehen" } },
"updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Erfolgreich aktualisiert" } },
"save_error": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Speichern fehlgeschlagen" } },
"invalid_json": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ungültiges JSON-Format" } },
"prev": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vorherige" } },
"next": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nächste" } },
"none": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Keine" } },
"reset": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zurücksetzen" } },
"confirm": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Bestätigen" } },
"close": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Schließen" } },
"back": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Zurück" } },
"continue": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Fortfahren" } },
"exit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Beenden" } },
"all": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Alle" } },
"select_all": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Alle auswählen" } },
"deselect_all": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Alle abwählen" } },
"expires": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Läuft ab" } },
"created": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Erstellt" } },
"start_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Startdatum" } },
"end_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Enddatum" } },
"points": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Punkte" } },
"level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Stufe" } },
"user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Benutzer" } },
"user_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Benutzer-ID" } },
"services": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Dienstleistungen" } },
"discoveries": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Entdeckungen" } },
"restriction": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Einschränkung" } },
"restriction_mild": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mild" } },
"restriction_moderate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Moderat" } },
"restriction_severe": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Schwer" } },
"min_xp": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Min. XP" } },
"xp": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "XP" } },
"penalty": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Strafe" } },
"feature_flags": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Funktionsschalter" } },
"permission": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Berechtigung" } },
"role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Rolle" } },
"domain": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Domäne" } },
"action": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktion" } },
"value": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Wert" } },
"key": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Schlüssel" } },
"enabled": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktiviert" } },
"disabled": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deaktiviert" } },
"yes": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ja" } },
"no": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nein" } },
"on": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ein" } },
"off": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aus" } }
}
};
export {
resource as default
};
//# sourceMappingURL=de-Cm6LRCyJ.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"de-Cm6LRCyJ.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}

View File

@@ -1 +0,0 @@
{"file":"de-Cm6LRCyJ.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -1,467 +0,0 @@
import { _ as __nuxt_component_0 } from "./nuxt-link-1NBbHb-h.js";
import { defineComponent, ref, computed, mergeProps, useSSRContext, withCtx, createVNode, toDisplayString, unref, openBlock, createBlock, createTextVNode } from "vue";
import { ssrRenderAttrs, ssrInterpolate, ssrRenderClass, ssrRenderList, ssrRenderComponent, ssrRenderAttr, ssrRenderStyle, ssrRenderSlot } from "vue/server-renderer";
import "/app/node_modules/klona/dist/index.mjs";
import "/app/node_modules/hookable/dist/index.mjs";
import { a as useI18n, b as useRouter, c as useAuthStore } from "../server.mjs";
import { publicAssetsURL } from "#internal/nuxt/paths";
import { useRoute } from "vue-router";
import { u as useRegionStore } from "./region-0mvXqaMM.js";
import "/app/node_modules/ufo/dist/index.mjs";
import "/app/node_modules/defu/dist/defu.mjs";
import "/app/node_modules/ofetch/dist/node.mjs";
import "/app/node_modules/unctx/dist/index.mjs";
import "/app/node_modules/h3/dist/index.mjs";
import "pinia";
import "/app/node_modules/cookie-es/dist/index.mjs";
import "/app/node_modules/destr/dist/index.mjs";
import "/app/node_modules/ohash/dist/index.mjs";
import "/app/node_modules/@unhead/vue/dist/index.mjs";
import "@vue/devtools-api";
const _sfc_main$1 = /* @__PURE__ */ defineComponent({
__name: "LanguageSwitcher",
__ssrInlineRender: true,
setup(__props) {
const { locale, locales } = useI18n();
const dropdownOpen = ref(false);
const dropdownRef = ref(null);
const availableLocales = computed(() => locales.value);
const currentLocale = computed(() => locale.value);
const flagMap = {
hu: "🇭🇺",
en: "🇬🇧"
};
const labelMap = {
hu: "Magyar",
en: "English"
};
function flagFor(code) {
return flagMap[code] || "🌐";
}
function labelFor(code) {
return labelMap[code] || code.toUpperCase();
}
return (_ctx, _push, _parent, _attrs) => {
_push(`<div${ssrRenderAttrs(mergeProps({
class: "relative",
ref_key: "dropdownRef",
ref: dropdownRef
}, _attrs))}><button class="flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg transition bg-slate-700/50 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-600/50"><span class="text-base leading-none">${ssrInterpolate(flagFor(currentLocale.value))}</span><span class="hidden sm:inline">${ssrInterpolate(labelFor(currentLocale.value))}</span><svg class="${ssrRenderClass([{ "rotate-180": dropdownOpen.value }, "w-3.5 h-3.5 text-slate-400 transition-transform duration-200"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg></button>`);
if (dropdownOpen.value) {
_push(`<div class="absolute right-0 mt-2 w-44 bg-slate-800 border border-slate-700 rounded-xl shadow-xl py-1 z-50"><!--[-->`);
ssrRenderList(availableLocales.value, (loc) => {
_push(`<button class="${ssrRenderClass([
loc.code === currentLocale.value ? "bg-indigo-600/20 text-indigo-300" : "text-slate-300 hover:bg-slate-700 hover:text-white",
"flex items-center gap-3 w-full px-4 py-2.5 text-sm transition"
])}"><span class="text-lg leading-none">${ssrInterpolate(flagFor(loc.code))}</span><span>${ssrInterpolate(loc.name)}</span>`);
if (loc.code === currentLocale.value) {
_push(`<span class="ml-auto text-indigo-400"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg></span>`);
} else {
_push(`<!---->`);
}
_push(`</button>`);
});
_push(`<!--]--></div>`);
} else {
_push(`<!---->`);
}
_push(`</div>`);
};
}
});
const _sfc_setup$1 = _sfc_main$1.setup;
_sfc_main$1.setup = (props, ctx) => {
const ssrContext = useSSRContext();
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("components/LanguageSwitcher.vue");
return _sfc_setup$1 ? _sfc_setup$1(props, ctx) : void 0;
};
const _imports_0 = publicAssetsURL("/logo.svg");
const _sfc_main = /* @__PURE__ */ defineComponent({
__name: "default",
__ssrInlineRender: true,
setup(__props) {
const route = useRoute();
useRouter();
const authStore = useAuthStore();
useRegionStore();
const sidebarOpen = ref(false);
const sidebarCollapsed = ref(false);
const dropdownOpen = ref(false);
ref(null);
const expandedGroups = ref(/* @__PURE__ */ new Set(["Központ"]));
const userEmail = computed(() => authStore.userEmail || "Ismeretlen Felhasználó");
const userInitials = computed(() => {
const email = authStore.userEmail;
if (!email) return "??";
return email.split("@")[0].split(".").map((s) => s[0]).join("").toUpperCase().slice(0, 2);
});
const menuGroups = [
{
title: "Központ",
items: [
{
path: "/",
label: "Dashboard",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>'
}
]
},
{
title: "Szolgáltatók",
items: [
{
label: "Szolgáltatók",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>',
children: [
{
path: "/providers",
label: "Összes szolgáltató",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>'
},
{
path: "/providers/pending",
label: "Jóváhagyásra váró",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
}
]
}
]
},
{
title: "Felhasználók & Partnerek",
items: [
{
label: "Garázsok",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>',
children: [
{
path: "/garages",
label: "Garázsok listája",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>'
}
]
},
{
label: "Felhasználók",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>',
children: [
{
path: "/users",
label: "Felhasználók listája",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>'
},
{
path: "/persons",
label: "Személyek",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>'
}
]
}
]
},
{
title: "Pénzügyek",
items: [
{
path: "/packages",
label: "Csomagok",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0l-3-3m3 3l3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" /></svg>'
}
]
},
{
title: "Gamification",
items: [
{
label: "Játékmenet Beállítások",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
children: [
{
path: "/gamification/point-rules",
label: "Pontszabályok",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
},
{
path: "/gamification/levels",
label: "Szintek",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>'
},
{
path: "/gamification/badges",
label: "Kitüntetések",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" /></svg>'
}
]
},
{
label: "Események & Szezonok",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>',
children: [
{
path: "/gamification/seasons",
label: "Szezonok",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
},
{
path: "/gamification/competitions",
label: "Versenyek",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" /></svg>'
}
]
},
{
label: "Felhasználói Adatok",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
children: [
{
path: "/gamification/users",
label: "Statisztikák",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>'
},
{
path: "/gamification/leaderboard",
label: "Ranglista",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 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>'
},
{
path: "/gamification/ledger",
label: "Pontnapló",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /></svg>'
}
]
},
{
path: "/gamification",
label: "Gamification HQ",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" /></svg>'
},
{
path: "/gamification/config",
label: "Rendszer Konfig",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>'
},
{
path: "/gamification/parameters",
label: "Rendszerparaméterek",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /></svg>'
}
]
},
{
title: "Rendszer",
items: [
{
path: "/permissions",
label: "Jogosultságok",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>'
},
{
label: "Rendszernaplók",
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
children: [
{
path: "/",
/* P0 HOTFIX: placeholder until /logs page is built */
label: "Rendszernaplók",
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>'
}
]
}
]
}
];
const pageTitle = computed(() => {
if (route.path === "/") return "Dashboard";
if (route.path.startsWith("/permissions")) return "Jogosultság Kezelés";
if (route.path.startsWith("/garages")) return "Garázsok";
if (route.path.startsWith("/persons")) return "Személyek";
if (route.path.startsWith("/users")) return "Felhasználók";
if (route.path.startsWith("/logs")) return "Rendszernaplók";
if (route.path.startsWith("/packages")) return "Csomagok";
if (route.path.startsWith("/providers")) {
if (route.path === "/providers/pending") return "Jóváhagyásra váró szolgáltatók";
if (route.path.startsWith("/providers/")) return "Szolgáltató részletek";
return "Szolgáltatók";
}
if (route.path.startsWith("/gamification")) {
if (route.path === "/gamification") return "Gamification HQ";
if (route.path === "/gamification/point-rules") return "Pontszabályok";
if (route.path === "/gamification/levels") return "Szintek";
if (route.path === "/gamification/badges") return "Kitüntetések";
if (route.path === "/gamification/seasons") return "Szezonok";
if (route.path === "/gamification/competitions") return "Versenyek";
if (route.path === "/gamification/users") return "Gamification Felhasználók";
if (route.path.startsWith("/gamification/users/")) return "Felhasználó Gamification";
if (route.path === "/gamification/leaderboard") return "Ranglista";
if (route.path === "/gamification/config") return "Rendszer Konfig";
if (route.path === "/gamification/parameters") return "Rendszerparaméterek";
if (route.path === "/gamification/ledger") return "Pontnapló";
return "Gamification";
}
return "Admin Panel";
});
function isActive(path) {
if (path === "/") return route.path === "/";
return route.path.startsWith(path);
}
function isGroupActive(item) {
if (!item.children) return false;
return item.children.some((child) => isActive(child.path));
}
return (_ctx, _push, _parent, _attrs) => {
const _component_NuxtLink = __nuxt_component_0;
const _component_LanguageSwitcher = _sfc_main$1;
_push(`<div${ssrRenderAttrs(mergeProps({ class: "min-h-screen bg-slate-900 text-white flex" }, _attrs))}>`);
if (sidebarOpen.value) {
_push(`<div class="fixed inset-0 bg-black/50 z-20 lg:hidden"></div>`);
} else {
_push(`<!---->`);
}
_push(`<aside class="${ssrRenderClass([
"fixed lg:static inset-y-0 left-0 z-30 flex flex-col bg-slate-800 border-r border-slate-700 transition-all duration-300 ease-in-out overflow-hidden",
sidebarOpen.value || !sidebarCollapsed.value ? "w-52" : "w-20"
])}">`);
_push(ssrRenderComponent(_component_NuxtLink, {
to: "/",
class: "flex items-center h-16 px-4 border-b border-slate-700 overflow-hidden hover:bg-slate-700/50 transition"
}, {
default: withCtx((_, _push2, _parent2, _scopeId) => {
if (_push2) {
_push2(`<div class="flex items-center gap-3 min-w-0"${_scopeId}><img${ssrRenderAttr("src", _imports_0)} class="h-8 w-auto flex-shrink-0" alt="SF Logo"${_scopeId}><div class="${ssrRenderClass([sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0", "transition-all duration-300 ease-in-out overflow-hidden whitespace-nowrap"])}"${_scopeId}><div class="text-sm font-bold text-white leading-tight"${_scopeId}>Service Finder</div><div class="text-[10px] font-semibold uppercase tracking-wider text-indigo-400 leading-tight"${_scopeId}>Admin</div></div></div>`);
} else {
return [
createVNode("div", { class: "flex items-center gap-3 min-w-0" }, [
createVNode("img", {
src: _imports_0,
class: "h-8 w-auto flex-shrink-0",
alt: "SF Logo"
}),
createVNode("div", {
class: ["transition-all duration-300 ease-in-out overflow-hidden whitespace-nowrap", sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0"]
}, [
createVNode("div", { class: "text-sm font-bold text-white leading-tight" }, "Service Finder"),
createVNode("div", { class: "text-[10px] font-semibold uppercase tracking-wider text-indigo-400 leading-tight" }, "Admin")
], 2)
])
];
}
}),
_: 1
}, _parent));
_push(`<button class="hidden lg:flex items-center justify-center h-10 mx-2 mt-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition flex-shrink-0"><svg class="${ssrRenderClass([{ "rotate-180": sidebarCollapsed.value }, "w-5 h-5 transition-transform duration-300"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"></path></svg></button><nav class="flex-1 px-2 py-4 space-y-2 overflow-y-auto overflow-x-hidden"><!--[-->`);
ssrRenderList(menuGroups, (group) => {
_push(`<!--[--><div class="px-3 py-1 text-xs font-semibold uppercase tracking-wider text-slate-500 whitespace-nowrap overflow-hidden" style="${ssrRenderStyle(sidebarOpen.value || !sidebarCollapsed.value ? null : { display: "none" })}">${ssrInterpolate(group.title)}</div><!--[-->`);
ssrRenderList(group.items, (item) => {
_push(`<!--[-->`);
if (item.children) {
_push(`<div><button class="${ssrRenderClass([isGroupActive(item) ? "bg-indigo-600/20 text-indigo-300" : "text-slate-300 hover:bg-slate-700 hover:text-white", "flex items-center w-full gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition"])}"><span class="flex-shrink-0 w-5 h-5">${item.icon ?? ""}</span><span class="${ssrRenderClass([sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0", "flex-1 text-left whitespace-nowrap overflow-hidden transition-all duration-300 ease-in-out"])}">${ssrInterpolate(item.label)}</span><svg class="${ssrRenderClass([{
"rotate-90": expandedGroups.value.has(item.label),
"opacity-100": sidebarOpen.value || !sidebarCollapsed.value,
"opacity-0": sidebarCollapsed.value && !sidebarOpen.value
}, "w-4 h-4 transition-all duration-300 ease-in-out flex-shrink-0"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg></button><div class="ml-2 mt-1 space-y-1 overflow-hidden transition-all duration-200" style="${ssrRenderStyle(expandedGroups.value.has(item.label) && (sidebarOpen.value || !sidebarCollapsed.value) ? null : { display: "none" })}"><!--[-->`);
ssrRenderList(item.children, (child) => {
_push(ssrRenderComponent(_component_NuxtLink, {
key: child.path,
to: child.path,
class: ["flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition", isActive(child.path) ? "bg-indigo-600 text-white" : "text-slate-400 hover:bg-slate-700 hover:text-white"],
onClick: ($event) => sidebarOpen.value = false
}, {
default: withCtx((_, _push2, _parent2, _scopeId) => {
if (_push2) {
_push2(`<span class="flex-shrink-0 w-4 h-4"${_scopeId}>${child.icon ?? ""}</span><span class="whitespace-nowrap overflow-hidden"${_scopeId}>${ssrInterpolate(child.label)}</span>`);
} else {
return [
createVNode("span", {
class: "flex-shrink-0 w-4 h-4",
innerHTML: child.icon
}, null, 8, ["innerHTML"]),
createVNode("span", { class: "whitespace-nowrap overflow-hidden" }, toDisplayString(child.label), 1)
];
}
}),
_: 2
}, _parent));
});
_push(`<!--]--></div></div>`);
} else {
_push(ssrRenderComponent(_component_NuxtLink, {
to: item.path,
class: ["flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition", isActive(item.path) ? "bg-indigo-600 text-white" : "text-slate-300 hover:bg-slate-700 hover:text-white"],
onClick: ($event) => sidebarOpen.value = false
}, {
default: withCtx((_, _push2, _parent2, _scopeId) => {
if (_push2) {
_push2(`<span class="flex-shrink-0 w-5 h-5"${_scopeId}>${item.icon ?? ""}</span><span class="${ssrRenderClass([sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0", "whitespace-nowrap overflow-hidden transition-all duration-300 ease-in-out"])}"${_scopeId}>${ssrInterpolate(item.label)}</span>`);
} else {
return [
createVNode("span", {
class: "flex-shrink-0 w-5 h-5",
innerHTML: item.icon
}, null, 8, ["innerHTML"]),
createVNode("span", {
class: ["whitespace-nowrap overflow-hidden transition-all duration-300 ease-in-out", sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0"]
}, toDisplayString(item.label), 3)
];
}
}),
_: 2
}, _parent));
}
_push(`<!--]-->`);
});
_push(`<!--]--><!--]-->`);
});
_push(`<!--]--></nav><div class="p-4 border-t border-slate-700 overflow-hidden"><div class="${ssrRenderClass([sidebarOpen.value || !sidebarCollapsed.value ? "opacity-100 max-w-[200px]" : "opacity-0 max-w-0", "text-xs text-slate-500 whitespace-nowrap transition-all duration-300 ease-in-out"])}"> ServiceFinder v2.0 </div></div></aside><div class="flex-1 flex flex-col min-w-0 transition-all duration-300 ease-in-out"><header class="sticky top-0 z-10 bg-slate-800/95 backdrop-blur-sm border-b border-slate-700"><div class="flex items-center justify-between h-16 px-4 lg:px-6"><div class="flex items-center gap-3"><button class="lg:hidden p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg></button><h1 class="text-lg font-semibold text-white truncate">${ssrInterpolate(pageTitle.value)}</h1></div><div class="flex items-center gap-3">`);
_push(ssrRenderComponent(_component_LanguageSwitcher, null, null, _parent));
_push(`<div class="relative"><button class="flex items-center gap-2 p-1.5 rounded-lg hover:bg-slate-700 transition"><div class="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold text-white">${ssrInterpolate(userInitials.value)}</div><span class="hidden sm:block text-sm text-slate-300">${ssrInterpolate(userEmail.value)}</span><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg></button>`);
if (dropdownOpen.value) {
_push(`<div class="absolute right-0 mt-2 w-56 bg-slate-800 border border-slate-700 rounded-xl shadow-xl py-1 z-50"><div class="px-4 py-3 border-b border-slate-700"><p class="text-sm font-medium text-white">${ssrInterpolate(userEmail.value)}</p><p class="text-xs text-slate-400 mt-0.5">${ssrInterpolate(unref(authStore).userRole === "SUPERADMIN" ? "Super Administrator" : unref(authStore).userRole || "Administrator")}</p></div>`);
_push(ssrRenderComponent(_component_NuxtLink, {
to: "/profile",
class: "flex items-center gap-3 px-4 py-2.5 text-sm text-slate-300 hover:bg-slate-700 hover:text-white transition",
onClick: ($event) => dropdownOpen.value = false
}, {
default: withCtx((_, _push2, _parent2, _scopeId) => {
if (_push2) {
_push2(`<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"${_scopeId}><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"${_scopeId}></path></svg> Profile Settings `);
} else {
return [
(openBlock(), createBlock("svg", {
class: "w-4 h-4",
fill: "none",
stroke: "currentColor",
viewBox: "0 0 24 24"
}, [
createVNode("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"
})
])),
createTextVNode(" Profile Settings ")
];
}
}),
_: 1
}, _parent));
_push(`<button class="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-slate-700 hover:text-red-300 transition"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg> Sign Out </button></div>`);
} else {
_push(`<!---->`);
}
_push(`</div></div></div></header><main class="flex-1 p-4 lg:p-6 overflow-auto">`);
ssrRenderSlot(_ctx.$slots, "default", {}, null, _push, _parent);
_push(`</main></div></div>`);
};
}
});
const _sfc_setup = _sfc_main.setup;
_sfc_main.setup = (props, ctx) => {
const ssrContext = useSSRContext();
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("layouts/default.vue");
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
};
export {
_sfc_main as default
};
//# sourceMappingURL=default-BU4PyScw.js.map

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More