frontend admin refakctorálás
This commit is contained in:
430
.roo/history.md
430
.roo/history.md
@@ -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 ✅
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
53
backend/app/models/gamification/validation_rule.py
Normal file
53
backend/app/models/gamification/validation_rule.py
Normal 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()
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -165,10 +165,13 @@ 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"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
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)
|
||||
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"))
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
131
backend/app/scripts/migrate_i18n_jsonb.py
Normal file
131
backend/app/scripts/migrate_i18n_jsonb.py
Normal 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())
|
||||
259
backend/app/scripts/migrate_provider_addresses.py
Normal file
259
backend/app/scripts/migrate_provider_addresses.py
Normal 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())
|
||||
90
backend/app/scripts/seed_dismantler_category.py
Normal file
90
backend/app/scripts/seed_dismantler_category.py
Normal 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())
|
||||
@@ -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"],
|
||||
|
||||
129
backend/app/scripts/seed_gas_station.py
Normal file
129
backend/app/scripts/seed_gas_station.py
Normal 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()
|
||||
62
backend/app/scripts/seed_validation_rules.py
Normal file
62
backend/app/scripts/seed_validation_rules.py
Normal 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())
|
||||
360
backend/app/services/validation_engine.py
Normal file
360
backend/app/services/validation_engine.py
Normal 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
|
||||
199
backend/app/tests/test_validation_engine.py
Normal file
199
backend/app/tests/test_validation_engine.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
263
docs/p0_address_unification_plan.md
Normal file
263
docs/p0_address_unification_plan.md
Normal 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
|
||||
279
docs/p0_discovery_provider_validation_audit.md
Normal file
279
docs/p0_discovery_provider_validation_audit.md
Normal 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
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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)}}
|
||||
@@ -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}
|
||||
@@ -1 +0,0 @@
|
||||
body{margin:0;padding:0}
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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)}}
|
||||
@@ -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)}
|
||||
@@ -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)}}
|
||||
@@ -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)}}
|
||||
@@ -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)}}
|
||||
@@ -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)}}
|
||||
@@ -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)}}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"_plugin-vue_export-helper-1tPrXgE0.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;"}
|
||||
@@ -1 +0,0 @@
|
||||
{"file":"_plugin-vue_export-helper-1tPrXgE0.js","mappings":";;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
@@ -1,4 +0,0 @@
|
||||
import style_0 from "./entry-styles-3.mjs-C1rWf53M.js";
|
||||
export default [
|
||||
style_0
|
||||
]
|
||||
@@ -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
|
||||
@@ -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;;"}
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
|
||||
@@ -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;;;;;;;;;;;"}
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"config-styles-1.mjs-w3nI6KxA.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
@@ -1 +0,0 @@
|
||||
{"file":"config-styles-1.mjs-w3nI6KxA.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
@@ -1,4 +0,0 @@
|
||||
import style_0 from "./config-styles-1.mjs-w3nI6KxA.js";
|
||||
export default [
|
||||
style_0
|
||||
]
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"cz-CP7lYDxM.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
||||
@@ -1 +0,0 @@
|
||||
{"file":"cz-CP7lYDxM.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"de-Cm6LRCyJ.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
||||
@@ -1 +0,0 @@
|
||||
{"file":"de-Cm6LRCyJ.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
@@ -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
Reference in New Issue
Block a user