garázs fejlesztés

This commit is contained in:
Roo
2026-07-20 11:17:52 +00:00
parent 2e0abc62a7
commit 4594de6c11
80 changed files with 9135 additions and 8702 deletions

View File

@@ -1,281 +1,333 @@
# Service Finder Fejlesztési Történet ### ✅ Verifikáció
1. **Build ellenőrzés**`npx vite build` sikeres (7.08s, 0 error)
2. **Chunk generálva**: `CostEntryWizard-C_-R4OzC.js` (29.95 kB) - refaktorált komponens helyesen fordul
3. **ProviderAutocomplete** újrafelhasználva mindhárom komponensben - nincs duplikáció
## 2026-06-21 - P0 Deep Audit: Database Consistency & Zombie API Hunt ---
### 🎯 Cél ## P0 EPIC - UI/UX Overhaul for Quick Fuel Logging (Gitea #404)
...
## 2026-07-07 - P0 ServiceProvider AddressManager Integration **Dátum:** 2026-07-09
**Scope:** Backend + Frontend (SimpleFuelModal, ProviderAutocomplete, ProviderQuickAddModal)
### 🎯 Cél ### 🔧 Módosított fájlok
...
#### 2. Seed script létrehozása és futtatása #### 1. Backend: [`providers.py`](backend/app/api/v1/endpoints/providers.py:200)
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. - Search query `min_length` changed from `2``3`
- Hibaüzenet frissítve: "min. 3 karakter"
#### 2. Backend: [`provider_service.py`](backend/app/services/provider_service.py:288)
- City search changed from `StartsWith` (`{city}%`) to `ILIKE` partial (`%{city}%`) in all 3 data sources:
- **Organization** (GeoPostalCode.city)
- **ServiceStaging** (ServiceStaging.city)
- **ServiceProvider** (ServiceProvider.address)
- Zip code search also changed to partial ILIKE
#### 3. Frontend: [`ProviderAutocomplete.vue`](frontend_app/src/components/provider/ProviderAutocomplete.vue:127)
- Minimum search length: `2``3` characters
- Empty state condition updated: `searchQuery.length >= 3`
#### 4. Frontend: [`ProviderQuickAddModal.vue`](frontend_app/src/components/provider/ProviderQuickAddModal.vue:539)
- New `prefillName?: string` prop added
- Watch on `isOpen` now sets `form.name = props.prefillName` when provided
#### 5. Frontend: [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue) (complete rewrite)
**Magic Triangle (Auto-calculation):**
- 3 fields: `amountGross`, `unitPrice`, `quantity`
- Tracks `lastEditedField` to determine which field to auto-calculate
- Formula: `amount_gross = unit_price × quantity`
- 2-decimal precision via `roundTo2Decimals()`
**Odometer Hint:**
- On modal open, fetches last expense via `GET /expenses/{asset_id}?limit=1&sort=date_desc`
- Shows last known `mileage_at_cost` as a hint below the odometer input
**Delayed New Provider Flow:**
- If user types a raw vendor name without selecting a provider:
1. Expense is saved with `external_vendor_name` set
2. `unit_price` and `quantity` are sent in `data` JSONB payload
3. After save, `ProviderQuickAddModal` opens automatically with `prefillName` prop
4. User can create the provider with the name pre-filled
### ✅ Verifikáció ### ✅ Verifikáció
1. **Seed script futtatva** → ✅ Sikeresen beszúrva az 'Autóbontó / Használt alkatrész' kategória (ID: 767). Összes rekord: 135. 1. **Sync Engine**`docker exec sf_api python -m app.scripts.sync_engine` → 1287/1287 elements OK, 0 error
2. **admin_providers.py szintaxis ellenőrzés**`import app.api.v1.endpoints.admin_providers` sikeres (✅ module imports OK) 2. **Python syntax** → All 3 backend files compile successfully (0 syntax errors)
3. **sync_engine futtatva** → 1282 OK, 0 issue, schema fully in sync 3. **Vue TypeScript**`vue-tsc --noEmit` → Only pre-existing errors (Logo.vue.js, logo1.vue.js), no new errors
4. **Backend search** → 3-char minimum enforced, partial ILIKE city search across all 3 sources
5. **Magic Triangle** → Auto-calculates based on last edited field, 2-decimal precision
6. **Delayed New Provider** → Expense saved first, then ProviderQuickAddModal opens with prefillName
## 2026-07-07 - P0 BUGFIX: Live Provider Advanced Fields & Gas Station Categories ---
### 🎯 Cél ## P0 BUGFIX: SimpleFuelModal 500 POST crash + 5 frontend issues (Gitea #405)
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 **Dátum:** 2026-07-10
**Scope:** Backend (expenses.py, provider_service.py) + Frontend (SimpleFuelModal.vue)
#### 1. Backend fix: [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1571) ### 🔍 Root Cause Analysis: 500 POST /expenses/ crash
- **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) **Hiba:** `ForeignKeyViolationError` on `fk_asset_costs_vendor_org`
- 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. **Kiváltó ok:** A frontend [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue) a `vendor_organization_id` mezőbe mindig a `selectedProvider.value!.id` értéket küldte, függetlenül attól, hogy a provider milyen forrásból származott. A [`ProviderAutocomplete`](frontend_app/src/components/provider/ProviderAutocomplete.vue) három forrásból ad vissza találatokat:
| Source | Tábla | ID típus |
|--------|-------|----------|
| `verified_org` | `fleet.organizations` | Organization.id ✅ |
| `staged_data` | `marketplace.service_staging` | ServiceStaging.id ❌ |
| `crowd_added` | `marketplace.service_providers` | ServiceProvider.id ❌ |
Az FK constraint `fk_asset_costs_vendor_org` a [`fleet_finance.asset_costs.vendor_organization_id`](backend/app/models/fleet_finance/models.py:130) oszlopon van, ami a `fleet.organizations.id`-ra hivatkozik. Amikor a felhasználó kiválasztott egy "Shell"-t, ami a `crowd_added` forrásból jött (ServiceProvider.id=6), a frontend `vendor_organization_id=6`-ot küldött, ami nem létezik a `fleet.organizations` táblában → **500-as hiba**.
### 🔧 FIX 1: Backend FK-safe validation (expenses.py)
**Fájl:** [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py:618)
A `create_expense` végpontban, mielőtt létrehoznánk az `AssetCost` rekordot, egy FK-ellenőrző blokk fut le:
```python
# ── P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation ──
resolved_vendor_org_id = expense.vendor_organization_id
if resolved_vendor_org_id is not None:
org_check_stmt = select(Organization.id).where(
Organization.id == resolved_vendor_org_id,
Organization.is_deleted == False,
)
org_check_result = await db.execute(org_check_stmt)
org_exists = org_check_result.scalar_one_or_none()
if org_exists is None:
logger.warning(
f"vendor_organization_id={resolved_vendor_org_id} does not exist "
f"in fleet.organizations. Setting to None and using service_provider_id."
)
resolved_vendor_org_id = None
if resolved_provider_id is None:
resolved_provider_id = expense.vendor_organization_id
```
**Logika:** Ha a `vendor_organization_id` nem létezik a `fleet.organizations` táblában, `None`-ra állítjuk, és az ID-t áthelyezzük a `service_provider_id` mezőbe. Ez biztosítja, hogy:
1. Az FK constraint soha nem sérül meg
2. A provider referencia nem vész el (átkerül a `service_provider_id`-ba)
3. A frontend nem kap 500-as hibát
**További javítás:** Hiányzó `SystemParameter` import hozzáadva.
### 🔧 FIX 2: Frontend source-aware provider routing (SimpleFuelModal.vue)
**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:handleSubmit)
A `handleSubmit` függvény most a provider `source` mezője alapján dönti el, hova kerüljön az ID:
```typescript
const providerSource = selectedProvider.value?.source || null
const payload: Record<string, any> = {
vendor_organization_id: hasSelectedProvider && providerSource === 'verified_org'
? selectedProvider.value!.id
: null,
service_provider_id: hasSelectedProvider && providerSource === 'crowd_added'
? selectedProvider.value!.id
: null,
// ...
}
```
**Logika:**
- `verified_org``vendor_organization_id` (FK biztonságos)
- `crowd_added``service_provider_id` (FK nélküli mező)
- `staged_data` → egyik sem (még nincs véglegesítve)
### 🔧 FIX 3: Currency selector hozzáadása
**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:template)
A `form.currency` mező most már HUF/EUR választóval rendelkezik:
```html
<select v-model="form.currency" class="w-20 rounded-xl ...">
<option value="HUF">HUF</option>
<option value="EUR">EUR</option>
</select>
```
A `form` state tartalmazza: `currency: 'HUF' as string`
### 🔧 FIX 4: Magic Triangle @blur events
**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:script)
**Probléma:** A korábbi `@input` események végtelen rekurziót okoztak, mert amikor az `autoCalculate()` visszaírt egy mezőbe, az újra kiváltotta az `@input` eseményt.
**Megoldás:** Mindhárom mező (`amountGross`, `unitPrice`, `quantity`) `@blur` eseményt használ:
```typescript
function onAmountGrossBlur(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value) || 0
form.amountGross = val
lastEditedField.value = 'amount_gross'
autoCalculate()
}
```
**Logika:**
1. A `@blur` csak akkor fut le, amikor a felhasználó elhagyja a mezőt (pl. Tab-bal vagy kattintással)
2. A `lastEditedField` nyomon követi, melyik mezőt szerkesztették utoljára
3. Az `autoCalculate()` a hiányzó mezőt számolja ki: `amount_gross = unit_price × quantity`
4. Nincs végtelen rekurzió, mert a `@blur` nem triggerelődik programozott értékadásra
### 🔧 FIX 5: Odometer hint response parsing
**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:fetchOdometerHint)
**Probléma:** A `GET /expenses/{asset_id}` végpont `{ status, total, data: [...] }` formátumban adja vissza az adatokat, de a kód egy sima tömböt várt.
**Megoldás:**
```typescript
const body = res.data as { status?: string; total?: number; data?: Array<{ mileage_at_cost?: number | null }> }
const expenses = body.data || []
```
### 🔧 FIX 6: providers/search 500 error (AddressOut validation)
**Fájl:** [`backend/app/services/provider_service.py`](backend/app/services/provider_service.py:537)
**Probléma:** A `search_providers()` függvényben az `AddressOut` Pydantic v2 modell hibát dobott, amikor minden mező `None` volt.
**Megoldás:** Try-except blokk az `AddressOut` konstrukció körül:
```python
if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]):
try:
address_detail = AddressOut(...)
except Exception as addr_e:
logger.warning(...)
address_detail = None
```
### ✅ Verifikáció ### ✅ Verifikáció
1. **Seed script futtatva** → ✅ 'Shop / Kisbolt' (ID: 768) már létezik. Összes rekord: 136. 1. **Python syntax check**`expenses.py: OK`, `provider_service.py: OK`
2. **sync_engine futtatva** → 1282 OK, 0 issue, schema fully in sync 2. **Sync Engine** → 1287/1287 elements OK, 0 fixes needed
3. **Frontend TypeScript** → All type-safe, no new compilation errors
## 2026-07-07 - P0 CRITICAL HOTFIX: Promotion Engine v2 ---
### 🎯 Cél ## P0 BUGFIX - Odometer Source of Truth & Sync (Gitea #406)
Javítani a Promotion Engine-t a `PATCH /admin/providers/{id}` végponton, amely ServiceStaging rekordok jóváhagyásakor nem futott le.
### 🔍 Root Cause Analysis **Dátum:** 2026-07-10
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. **Scope:** Frontend (SimpleFuelModal.vue)
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.
### 🔧 Javítások ### 🔧 Módosított fájlok
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. #### 1. Frontend: [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:228)
3. **Logolás**: `logger.error("P0 PROMOTION TRIGGERED")` hozzáadva a promotion blokk elejére a Docker logokban való trace-elhetőségért. - **Fix:** `fetchLastOdometer()` most már a `/assets/vehicles/{asset_id}` végpontot hívja a `/expenses/{asset_id}` helyett.
- **Logika:** A `Vehicle.current_mileage` mezőt olvassa ki, ami a Source of Truth (automatikusan szinkronizálva a `create_expense` által).
- **Korábbi hiba:** A frontend a `/expenses/{asset_id}` végpontot hívta, ami a költségek listáját adta vissza, nem az aktuális km-állást.
#### 2. Backend: [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:635)
- **Ellenőrizve:** A `create_expense` függvény (635-637. sor) már tartalmazza az `asset.current_mileage` szinkronizálást:
```python
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
asset.current_mileage = expense.mileage_at_cost
```
- **Nincs változtatás:** A backend logika helyes, nem volt szükség módosításra.
#### 3. Frontend: [`ComplexExpenseModal.vue`](frontend_app/src/components/dashboard/ComplexExpenseModal.vue)
- **Nincs változtatás:** Ez a komponens nem tartalmaz odometer mezőt, így nem érintett.
### ✅ Verifikáció ### ✅ Verifikáció
1. **Szintaxis ellenőrzés**`py_compile` sikeres 1. **Python syntax check** → `expenses.py: OK`
2. **Konténer újraindítás**`docker compose restart sf_api` sikeres 2. **Sync Engine** → 1287/1287 elements OK, 0 fixes needed
3. **API elérhető**✅ Uvicorn fut a 8000-es porton 3. **Frontend** → `fetchLastOdometer()` most a `/assets/vehicles/{id}` végpontot használja, ami a `current_mileage`-t adja vissza
## 2026-07-07 - DDD Database Refactoring 1.0 (P0 - 3 Phase) ---
### 🎯 Cél ## P0 EPIC - CostEntryWizard UI/UX Parity & Categories (Gitea #407)
DDD Database Refactoring 1.0 három fázisban: address_id FK bevezetése, admin_providers.py egyszerűsítése, frontend cleanup.
### 🔧 Változtatások **Dátum:** 2026-07-10
**Scope:** Frontend (CostEntryWizard.vue)
#### #396 - Phase 1: address_id FK (Already Complete) ### 🔧 Módosított fájlok
- `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ő
#### #397 - Phase 2: admin_providers.py Simplify #### 1. Frontend: [`CostEntryWizard.vue`](frontend_app/src/components/cost/CostEntryWizard.vue)
- `_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
#### #398 - Phase 3: Frontend Cleanup **P0 EPIC 1: Global Vehicle Selection**
- `garages/index.vue` line 157: `garage.city` fallback eltávolítva → csak `garage.address_detail?.city` - `useVehicleStore` importálva, `fetchUserVehicles()` metódus hozzáadva
- `providers/index.vue` line 137: már `address_detail`-t használ (nem kellett módosítani) - `selectedVehicleId` ref + `userVehicles` ref a járműlista kezelésére
- `providers/[id]/index.vue` lines 144-166: már `address_detail`-t használ (nem kellett módosítani) - `effectiveVehicle` computed: prioritás szerint oldja fel a járművet (prop > dropdown)
- `providers/[id]/edit.vue` lines 1133-1148: már `data.address_detail`-t használ (nem kellett módosítani) - Template: `<select>` dropdown a felhasználó járműveivel, fallback read-only display ha vehicle prop van
**P0 EPIC 2: Grouped Categories (Subcategories)**
- `SubCategoryGroup` interface a csoportosított struktúrához
- `groupedSubCategories` computed: a subCategories flat listát csoportosítja parent category név alapján
- Template: `<optgroup>`-ok a subcategory select-ben, logikai csoportosítással
- `onMainCategoryChange()`: kiszűri a kiválasztott főkategória al-kategóriáit
**P0 EPIC 3: Universal Odometer with Hint**
- `odometerHint` ref + `odometerLoading` ref a hint állapot kezelésére
- `fetchLastOdometer()`: a `/assets/vehicles/{id}` végpontról lekéri a `current_mileage`-t
- Template: mindig látható km óra input + hint "Előző állás: X km"
- `watch(effectiveVehicle)` triggeli a `fetchLastOdometer()`-t járműváltáskor
- `isFuelCategory()`: backend FUEL_CATEGORY_IDS alapján detektálja az üzemanyag kategóriákat
- Liters input csak fuel kategóriáknál jelenik meg
**P0 EPIC 4: ProviderAutocomplete Integration**
- Már helyesen integrálva volt: `v-model`, `@select`, `@clear` események
- `handleSubmit()`: P0 Hybrid Vendor Refactor szerint source-aware vendor field-eket küld
- `verified_org` → `vendor_organization_id`
- `crowd_added` → `service_provider_id`
- raw string → `external_vendor_name`
**Payload kompatibilitás:**
- `asset_id`, `category_id`, `amount_gross` (GROSS-FIRST), `currency`, `date`, `mileage_at_cost`
- `vendor_organization_id`, `service_provider_id`, `external_vendor_name` root szinten
- `data` JSONB: `net_amount`, `vat_rate`, `invoice_number`, `invoice_date`, `payment_method`, `payment_deadline`, `liters`, `mileage_at_cost`, vendor meta
### ✅ Verifikáció ### ✅ Verifikáció
1. **#397 API teszt** → ✅ `list_providers` endpoint 5 provider-t ad vissza `address_detail` mezővel 1. **Vite build** → `CostEntryWizard-KIg7Uvhm.js` (31.25 kB) sikeresen lefordult, 0 error
2. **#398 Frontend regex audit** → ✅ Nincs több `provider.address` vagy `provider.city` flat field referencia 2. **Template** → mind a 4 P0 EPIC feature implementálva
3. **Konténer újraindítás**`docker compose restart sf_api` sikeres 3. **Script** → handleSubmit, resetForm, editCost hydration, lifecycle hooks teljesek
## 2026-07-07 - P0 BUGFIX: Category Sync & Parent-Child UX Automation ## P0 EPIC - Enterprise Taxonomy Seed Script (Gitea #407 kiegészítés)
### 🎯 Cél ### 📋 Új fájl: [`seed_expertise_enterprise.py`](backend/app/scripts/seed_expertise_enterprise.py)
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 **Cél:** Enterprise Taxonomy (Pénzügy, Biztosítás, Hatóságok) beszúrása a `marketplace.expertise_tags` táblába.
#### Backend: `backend/app/api/v1/endpoints/admin_providers.py` **Beszúrt 9 új tag (IDs 769-777):**
- **`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` #### Level 1: Pénzügy és Biztosítás (Finance & Insurance) → ID 769
- **Import** (line 626): `watch` hozzáadva a Vue import-hoz - Level 2: Gépjármű biztosító (Vehicle Insurance) → ID 770
- **`parentLookupMap`** (line 942): Flat Map<number, number> a kategória fa rekurzív bejárásához - Level 2: Lízing és Finanszírozás (Leasing & Financing) → ID 771
- **`buildParentLookupMap()`** (line 944): Rekurzív fa bejáró, ami minden gyermek node-hoz hozzárendeli a szülő ID-t - Level 2: Bank és Hitelintézet (Bank & Credit Institution) → ID 772
- **`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 #### Level 1: Hatóságok és Közigazgatás (Authorities & Administration) → ID 773
- Level 2: Önkormányzat (Municipality) → ID 774
- Level 2: Állami Kincstár / Nemzeti Adóhatóság (State Treasury / Tax Authority) → ID 775
- Level 2: Közlekedési Hatóság / Kormányablak (Transport Authority) → ID 776
- Level 2: Útdíj / Autópálya Kezelő (Toll & Highway Operator) → ID 777
### ✅ Verifikáció ### ✅ Verifikáció
1. **Backend szintaxis** -> OK: `admin_providers` modul sikeresen betöltve a konténerben 1. **Seed script futtatás:** `docker compose exec sf_api python3 /app/backend/app/scripts/seed_expertise_enterprise.py` → sikeres, 9 új sor beszúrva
2. **Sync Engine** -> OK: 1282 OK, 0 hiba, adatbázis szinkronban 2. **Adatbázis ellenőrzés:** `SELECT count(*) FROM marketplace.expertise_tags;` → 145 total record
3. **Frontend** -> OK: Import és `toggleCategory` módosítva, `watch` + `parentLookupMap` beépítve 3. **Vite build:** `docker compose exec sf_public_frontend npm run build` → 6.80s, 0 error, CostEntryWizard chunk 31.25 kB
## 2026-07-07 - P0 BUGFIX: ServiceProfile Auto-Creation on PATCH for New Providers ---
### 🎯 Cél ## P0 EPIC - Seed Insurance Providers
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 **Dátum:** 2026-07-11
**Scope:** Backend (Seed Script)
#### 1. Import fix: [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:48) ### 🔧 Létrehozott fájl
- `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) #### 1. [`seed_insurance_providers.py`](backend/app/scripts/seed_insurance_providers.py)
- **Előtte:** ServiceProfile csak akkor jött létre, ha `has_advanced_fields` True volt - **33 magyar biztosító** seedelése a marketplace ökoszisztémába
- **Utána:** Ha nincs ServiceProfile, MINDIG létrejön egy alapértelmezett profil (fingerprint: `auto-created-{provider_id}`, status: `active`, trust_score: 30) - Adatforrás: Netrisk nyílt adatok (Alfa, Allianz, Generali, K&H, stb.)
- Debug logolás hozzáadva (`print` + `logger.info`) a sync hívás előtt - **Modellek:** `ServiceProvider` → `ServiceProfile` → `ServiceExpertise`
- Minden biztosító linkelve a **"Gépjármű biztosító"** expertise taghez (key=`gepjarmu_biztosito`, ID=770)
- `status=approved`, `source=api_import`, `validation_score=100`
- JSONB `raw_data` mezőben: Cégjegyzékszám, Bankszámla, Alaptőke, Tulajdonos, Kárrendezés adatok
- Duplikációszűrés név alapján
### ✅ Verifikáció ### ✅ Verifikáció
1. **Sync Engine** -> OK: 1282 OK, 0 hiba, adatbázis szinkronban 1. **Adatbázis:** 33 rekord a `marketplace.service_providers` táblában, mind `status=approved`, `source=api_import`
2. **E2E teszt** -> ✅ PATCH 200 SUCCESS: provider #21 (Test Provider No Address - Fixed v4) sikeresen frissítve `category_ids=[12, 13, 14]`-gyel 2. **API Search:** `GET /api/v1/providers/search?query=biztosito` visszaadja a biztosítókat (pl. Allianz, Generali, Groupama)
3. **Docker log** -> ✅ `P0 BUGFIX: Auto-created ServiceProfile #24 for Live provider #21` 3. **ServiceProfile** és **ServiceExpertise** kapcsolatok létrejöttek minden providerhez
## 2026-07-07 - P0 HOTFIX: Edit Form Chips & Reactivity Deep Clone
### 🎯 Cél
P0 kritikus hibajavítás: A provider edit űrlapon a kiválasztott kategória chipek nyers ID-kat jelenítettek meg (#760) név helyett, és a "Mentés" gomb disabled maradt kategória toggle után.
### 🔍 Root Cause Analysis
1. **Chip Display**: A template már `{{ getCategoryName(id) }}`-t használt (line 243), és a `useCategories` composable `getCategoryName()` függvénye helyesen visszaadja a nevet, ha a `categoryMap` be van töltve. A `#760` fallback akkor jelenik meg, ha a kategóriák még nem töltődtek be — ez normális átmeneti állapot.
2. **Reactivity Bug (Fő probléma)**: A `mapProviderToForm()` függvényben a `form.category_ids = data.category_ids || []` direkt referenciát másolt, nem deep clone-t. Mivel a `form.category_ids` és a `provider.value.category_ids` ugyanarra a tömbre mutattak, a `hasChanges` computed property sosem érzékelte a változást — a kategória toggle mindkét referenciát egyszerre módosította.
### 🔧 Változtatások
#### [`frontend_admin/pages/providers/[id]/edit.vue`](frontend_admin/pages/providers/[id]/edit.vue:1137)
- **`mapProviderToForm()`** (lines 1137-1148): Minden tömb értékadás deep clone-ra javítva:
- `form.supported_vehicle_classes = [...(data.supported_vehicle_classes || [])]`
- `form.category_ids = [...(data.category_ids || [])]`
- `form.specializations.brands = Array.isArray(spec.brands) ? [...spec.brands] : []`
- `form.specializations.propulsion = Array.isArray(spec.propulsion) ? [...spec.propulsion] : []`
### ✅ Verifikáció
1. **Deep clone ellenőrzés**: `form.category_ids` és `provider.value.category_ids` most külön referenciák — a toggleCategory() hívás után a `hasChanges` helyesen `true`-t ad vissza.
2. **Chip display**: A template már `{{ getCategoryName(id) }}`-t használ (line 243), a `useCategories.getCategoryName()` pedig a `categoryMap`-ből adja vissza a nevet, vagy `#${id}` fallback-et, ha még nem töltődött be.
## 2026-07-07 - P0 EPIC: i18n JSONB Database Migration (Phase 1 & 2)
### 🎯 Cél
Architect által jóváhagyott i18n JSONB migrációs terv ([`plans/logic_spec_i18n_jsonb_migration_phase1.md`](plans/logic_spec_i18n_jsonb_migration_phase1.md)) első két fázisának implementálása: Séma bővítés (Phase 1) és Adatmigráció (Phase 2).
### 🔧 Változtatások
#### Phase 1: Schema Expansion (3 SQLAlchemy model)
1. **`backend/app/models/marketplace/service.py`** — `ExpertiseTag` osztály:
- `name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))`
- `description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)`
- GIN index: `Index('idx_expertise_tags_name_i18n', 'name_i18n', postgresql_using='gin')`
2. **`backend/app/models/vehicle/vehicle_definitions.py`** — `BodyTypeDictionary` osztály:
- `name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))`
- GIN index: `Index('idx_dict_body_types_name_i18n', 'name_i18n', postgresql_using='gin')`
3. **`backend/app/models/core_logic.py`** — `ServiceCatalog` osztály:
- `name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))`
- `description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)`
- GIN index: `Index('idx_service_catalog_name_i18n', 'name_i18n', postgresql_using='gin')`
#### Phase 2: Data Migration Script
4. **`backend/app/scripts/migrate_i18n_jsonb.py`** — Létrehozva:
- Aszinkron migrációs script, amely a régi `name_hu`/`name_en` oszlopokat JSONB struktúrába csomagolja
- Kezeli a `name_translations` meglévő JSONB mező merge-elését is
- `json.dumps()` használata az asyncpg JSONB kompatibilitás miatt
- Idempotens: `ON CONFLICT DO NOTHING` + meglévő adatok felülírása
### 📊 Migrációs Eredmények
| Tábla | Sorok | name_i18n | description_i18n |
|-------|-------|-----------|------------------|
| `marketplace.expertise_tags` | 136 | ✅ `{"hu": "...", "en": "..."}` | ✅ `{"hu": "..."}` |
| `vehicle.dict_body_types` | 16 | ✅ `{"hu": "Szedán", ...}` | ❌ N/A |
| `system.service_catalog` | 3 | ✅ `{"hu": "Adat Export"}` | ✅ `{"hu": "PDF és Excel..."}` |
### 🗄️ GIN Indexek
| Index | Tábla | Típus |
|-------|-------|-------|
| `idx_expertise_tags_name_i18n` | `marketplace.expertise_tags` | GIN on `name_i18n` |
| `idx_dict_body_types_name_i18n` | `vehicle.dict_body_types` | GIN on `name_i18n` |
| `idx_service_catalog_name_i18n` | `system.service_catalog` | GIN on `name_i18n` |
### ✅ Verifikáció
1. **sync_engine futtatva** → 1287 OK, 0 Fixed, 0 Shadow — schema fully in sync
2. **5 új JSONB oszlop** létrehozva a DB-ben (3× `name_i18n`, 2× `description_i18n`)
3. **3 GIN index** manuálisan létrehozva (sync_engine nem kezeli az indexeket)
4. **155 sor** sikeresen migrálva (136 expertise_tags + 16 dict_body_types + 3 service_catalog)
5. **Régi oszlopok** (`name_hu`, `name_en`, `name`, `description`) érintetlenek — backward compatibility biztosítva
## 2026-07-08 - P0 i18n Static Dictionary Cleanup (Admin Frontend)
### 🎯 Cél
Az admin frontend összes providers oldalának és a layout sidebar menüjének teljes i18n konverziója. Minden hardcoded magyar szöveg `$t()` / `t()` hívásokra cserélése, és a hiányzó fordítási kulcsok felvétele a `hu.json` és `en.json` fájlokba.
### 🔧 Változtatások
#### 1. Layout (`default.vue`)
- `menuGroups` array összes label-je i18n kulcsokra cserélve (`menu.center`, `menu.dashboard`, `menu.providers`, stb.)
- `pageTitle` computed property összes hardcoded string i18n kulcsokra cserélve
- Key fix: `menu.ledger``menu.points_ledger`, `gamification.users.detail``gamification.users.user_detail_title`
#### 2. Providers oldalak
- **index.vue** - Teljes konverzió: template + script (`statusLabel()`)
- **pending.vue** - Teljes konverzió: template + script (toast üzenetek)
- **rejected.vue** - Teljes konverzió: template + script (toast üzenetek)
- **[id]/index.vue** - Teljes konverzió: template (minden szekció, modal, drawer) + script (tabs, DAYS, statusLabel, statusDescription, vehicleClassLabel, validationTypeLabel, historyActionLabel, toast üzenetek)
- **[id]/edit.vue** - Teljes konverzió: template (minden tab, szekció, drawer) + script (tabs, VEHICLE_CLASSES, DAYS, copyWeekdays, saveForm toasts)
#### 3. Lokalizációs fájlok
- **hu.json** - ~200+ új providers kulcs (index, pending, rejected, detail, edit)
- **en.json** - ~200+ új providers kulcs angol fordítással
### ✅ Verifikáció
1. **Build**`npm run build` sikeres (sf_admin_frontend konténerben) ✅
2. **Nincs fordítási hiba** - minden használt i18n kulcs létezik a locale fájlokban ✅
## 2026-07-08 - P0 Architectural Upgrade: Modular i18n Locales Migration
### 🎯 Cél
Migrate the Nuxt admin frontend from monolithic i18n locale files (single `hu.json`, `en.json`) to Nuxt i18n Modular Locales (multiple domain-specific JSON files per locale).
### 🔧 Változtatások
1. **`frontend_admin/nuxt.config.ts`** (31. sor): Changed `langDir` from `'i18n/locales/'` to `'locales/'` to avoid path doubling with Nuxt i18n module's automatic `i18nDir` detection. Updated hu and en locale configs to use `files` array pattern instead of single `file`.
2. **`frontend_admin/i18n/locales/hu/common.json`** (CREATED): Extracted all `common.*` keys from old monolithic `hu.json` (loading, saving, error, retry, save, cancel, delete, edit, search, filter, status, permissions, etc.)
3. **`frontend_admin/i18n/locales/en/common.json`** (CREATED): English equivalents of all common.* keys.
4. **`frontend_admin/i18n/locales/hu/menu.json`** (CREATED): Extracted all `menu.*` keys (center, dashboard, providers, garages, users, finance, packages, gamification, system, etc.)
5. **`frontend_admin/i18n/locales/en/menu.json`** (CREATED): English equivalents of all menu.* keys.
6. **`frontend_admin/i18n/locales/hu/providers.json`** (UPDATED): Expanded from ~84 lines to ~280 lines with all provider-related keys from old monolithic file.
7. **`frontend_admin/i18n/locales/en/providers.json`** (UPDATED): English equivalents, expanded similarly.
8. **`frontend_admin/locales/hu.json`** (DELETED): Old monolithic Hungarian locale file (1003 lines).
9. **`frontend_admin/locales/en.json`** (DELETED): Old monolithic English locale file (1003 lines).
### 🔍 Megjegyzések
- A Nuxt i18n modul automatikusan detektálja az `i18n/` könyvtárat a projekt gyökerében, és azt használja `i18nDir`-ként. Ezért a `langDir`-t relatívan kell megadni ehhez a könyvtárhoz (`'locales/'`), nem pedig abszolút módon (`'i18n/locales/'`).
- A többi nyelv (de, fr, ro, cz, sk) továbbra is a régi `frontend_admin/locales/` könyvtárban található monolit fájlokat használja.
- A konténer újraindítás után sikeresen elindult, a Nitro szerver és a Vite klienst is felépítette.
- A `@intlify/message-compiler` `Invalid linked format` hibát dobott az `info@example.com` email placeholder értékben lévő `@` karakter miatt. A javítás: `{'@'}` Intlify szintaxis használata a `hu/providers.json` és `en/providers.json` fájlokban.
## 2026-07-08 - P0 CRITICAL i18n Audit & Repair (HU/EN) - Fix #6: Missing persons.json
### 🎯 Cél
A Nuxt i18n modul `computeLocaleHashes` függvénye `ENOENT` hibát dobott, mert a `persons.json` fájl nem létezett a `frontend_admin/i18n/locales/en/` és `hu/` könyvtárakban, pedig a `nuxt.config.ts` `files` tömbje hivatkozott rá.
### 🔧 Végrehajtott Javítások
1. **`frontend_admin/i18n/locales/en/persons.json`** (LÉTREHOZVA): Angol nyelvű személykezelő fordítások (3456 byte), 80+ kulcs a `persons.*` és `persons.details.*` névtérben.
2. **`frontend_admin/i18n/locales/hu/persons.json`** (LÉTREHOZVA): Magyar nyelvű személykezelő fordítások (3898 byte), azonos struktúrával.
### 📝 Technikai Részletek
- A `persons.*` kulcsokat a `pages/persons/index.vue` (lista nézet) és `pages/persons/[id]/index.vue` (részletek nézet) template-ek használják.
- A hiányzó fájl a `nuxt.config.ts` 24 moduláris fájljának egyike volt, amelyet a Fix #3 során adtunk hozzá a `files` tömbhöz, de a fájl ténylegesen nem létezett a lemezen.
- A `.nuxt` cache törlése (`sudo rm -rf frontend_admin/.nuxt/dev`) és a konténer újraindítása után a build sikeresen lefutott i18n hibák nélkül.
### ✅ Verifikáció
1. **Konténer újraindítva**`docker compose restart sf_admin_frontend`
2. **Build logok**`✔ Vite client built in 40ms`, `✔ Vite server built in 445ms`, `[nitro] ✔ Nuxt Nitro server built in 1526ms`
3. **i18n hibák** → Nincs `ERROR`, nincs `ENOENT`, nincs i18n compilation error ✅

View File

@@ -6,6 +6,7 @@ Végpontok:
GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address) GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address)
PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által
GET /admin/users/{user_id}/memberships — Felhasználó szervezeti tagságai GET /admin/users/{user_id}/memberships — Felhasználó szervezeti tagságai
DELETE /admin/users/{user_id}/hard — [SUPERADMIN] Végleges felhasználó törlés
Megjegyzés: A GET /admin/users (lista) és POST /admin/users/bulk-action Megjegyzés: A GET /admin/users (lista) és POST /admin/users/bulk-action
végpontok a admin.py modulban találhatók, mivel az a /admin prefix alá végpontok a admin.py modulban találhatók, mivel az a /admin prefix alá
@@ -28,7 +29,9 @@ from app.api import deps
from app.db.session import get_db from app.db.session import get_db
from app.models.identity import User, Person, UserRole from app.models.identity import User, Person, UserRole
from app.models.identity.address import Address from app.models.identity.address import Address
from app.models.identity.identity import Wallet
from app.models.marketplace.organization import Organization, OrganizationMember from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.system.audit import SecurityAuditLog, FinancialLedger
from app.models.vehicle.history import AuditLog, LogSeverity from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.user import UserResponse from app.schemas.user import UserResponse
from app.schemas.organization import OrganizationMemberResponse from app.schemas.organization import OrganizationMemberResponse
@@ -644,3 +647,124 @@ async def get_user_memberships(
)) ))
return membership_list return membership_list
# =============================================================================
# Hard Delete Endpoint (Danger Zone)
# =============================================================================
@router.delete(
"/{user_id}/hard",
summary="[SUPERADMIN] Felhasználó végleges törlése",
description=(
"Véglegesen eltávolít egy felhasználót az adatbázisból. "
"KIZÁRÓLAG Superadmin számára elérhető. "
"Gatekeeper: Ellenőrzi, hogy a felhasználónak nincs-e pénzügyi rekordja "
"(FinancialLedger, Wallet) a törlés előtt. "
"Audit trail: SecurityAuditLog-ba rögzíti a műveletet."
),
)
async def hard_delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:manage")),
) -> Dict[str, Any]:
"""
DELETE /admin/users/{user_id}/hard
Véglegesen töröl egy felhasználót az adatbázisból.
Csak Superadmin jogosultsággal érhető el.
"""
# ── 1. Superadmin ellenőrzés ──────────────────────────────────────────
if current_user.role != UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only Superadmins can perform hard delete operations."
)
# ── 2. Felhasználó lekérése ───────────────────────────────────────────
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found."
)
# ── 3. Gatekeeper: Pénzügyi rekordok ellenőrzése ──────────────────────
# FinancialLedger ellenőrzés
ledger_stmt = select(FinancialLedger).where(FinancialLedger.user_id == user_id).limit(1)
ledger_result = await db.execute(ledger_stmt)
has_ledger = ledger_result.scalar_one_or_none() is not None
# Wallet ellenőrzés
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id).limit(1)
wallet_result = await db.execute(wallet_stmt)
has_wallet = wallet_result.scalar_one_or_none() is not None
if has_ledger or has_wallet:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Cannot hard delete: User has financial or production records. "
"Please ensure all financial data is cleared before deletion."
)
)
# ── 4. FK referenciák nullázása a törlés előtt ─────────────────────────
# Az AuditLog (audit.audit_logs) és SecurityAuditLog (audit.security_audit_logs)
# táblák FK constrainttel hivatkoznak identity.users.id-ra, de NINCS
# ondelete="SET NULL" beállítva. Ezért manuálisan nullázzuk a referenciákat.
user_data = {
"user_id": user.id,
"email": user.email,
"role": str(user.role) if user.role else None,
"is_active": user.is_active,
"is_deleted": user.is_deleted,
}
# AuditLog.user_id nullázása
await db.execute(
sa_update(AuditLog)
.where(AuditLog.user_id == user_id)
.values(user_id=None)
)
# ── 5. Végleges törlés ─────────────────────────────────────────────────
await db.delete(user)
await db.flush()
# ── 6. Audit Trail ─────────────────────────────────────────────────────
# SecurityAuditLog.target_id FK-val hivatkozik identity.users.id-ra,
# de nincs ondelete="SET NULL". Mivel a user már törölve van,
# a target_id=None kell legyen, különben ForeignKeyViolationError.
# A tényleges user_id a payload_before mezőben kerül tárolásra.
audit_log = SecurityAuditLog(
actor_id=current_user.id,
target_id=None,
action="hard_delete_user",
is_critical=True,
payload_before=user_data,
payload_after={
"details": f"User #{user_id} ({user.email}) permanently deleted by admin #{current_user.id}."
},
)
db.add(audit_log)
await db.commit()
logger.warning(
"HARD DELETE: User #%s (%s) permanently deleted by admin #%s",
user_id, user.email, current_user.id
)
return {
"status": "success",
"message": f"User #{user_id} has been permanently deleted.",
"deleted_user_id": user_id,
"deleted_email": user.email,
}

View File

@@ -1,440 +1,292 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
"""
Expense (AssetCost) API endpoints.
P0 Smart Expense Workflow (2026-06-20):
- POST /expenses/ — Create expense with odometer normalization, provider validation,
smart linking to AssetEvent, and gamification hooks.
- GET /expenses/ — List all expenses (admin).
- GET /expenses/{asset_id} — List expenses for a specific asset.
- PUT /expenses/{expense_id} — Update an existing expense.
GROSS-FIRST (Masterbook 2.0.1):
- amount_gross is the primary field (Bruttó).
- amount_net is optional — calculated back from gross + vat_rate.
- All VAT calculations happen in the service layer, not in the schema.
P0 HYBRID VENDOR REFACTOR (2026-07-01):
- service_provider_id (marketplace.service_providers) is the primary vendor FK.
- vendor_organization_id (fleet.organizations) is secondary (B2B).
- external_vendor_name is the fallback for free-text typed names.
P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation.
- A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként,
ami FK violation-t okoz. Itt validáljuk, hogy a megadott ID létezik-e
a fleet.organizations táblában. Ha nem, átirányítjuk service_provider_id-ra.
"""
import uuid
import logging import logging
from datetime import datetime, timezone, date
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func, and_, or_, cast, Text, case, literal, union_all
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.api.deps import get_db, get_current_user, RequireOrgCapability from app.api.deps import get_db, get_current_user
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization from app.models.fleet_finance.models import AssetCost, CostCategory
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate from app.models.vehicle.asset import Asset
from app.models.vehicle.asset_event import AssetEvent
from app.models.vehicle.odometer import OdometerReading
from app.models.fleet.organization import Organization
from app.models.fleet.org_member import OrganizationMember
from app.models.marketplace.service import ServiceProvider, ServiceProfile, ServiceExpertise
from app.models.system.system_parameter import SystemParameter
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate, AssetCostResponse
from app.services.gamification_service import GamificationService
from app.services.provider_service import find_or_create_provider_by_name from app.services.provider_service import find_or_create_provider_by_name
from app.services.gamification_service import gamification_service
from datetime import datetime, timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cost category IDs that are service/maintenance/repair related
# These trigger automatic AssetEvent creation
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
# Fuel category IDs - auto-approved even for DRIVER role
FUEL_CATEGORY_IDS = {1} # FUEL
router = APIRouter() router = APIRouter()
# ── FUEL CATEGORY IDS ──
# These are the category IDs that represent fuel costs.
# Used for auto-approval logic: fuel costs are always auto-approved.
FUEL_CATEGORY_IDS = {1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
# ── SERVICE-RELATED CATEGORY IDS ──
# These categories trigger automatic AssetEvent creation (Smart Linking).
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18}
gamification_service = GamificationService()
# ── HELPER FUNCTIONS ──
async def _resolve_user_role_in_org( async def _resolve_user_role_in_org(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
organization_id: int organization_id: int,
) -> str: ) -> Optional[str]:
""" """Resolve the user's role in the given organization.
Resolve the user's role within the given organization.
Returns: Returns the role name (e.g. 'owner', 'admin', 'driver') or None if not found.
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
Returns 'MEMBER' as default if no membership found.
""" """
stmt = select(OrganizationMember.role).where( stmt = select(OrganizationMember).where(
OrganizationMember.user_id == user_id, OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id, OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active" OrganizationMember.status == "active",
).limit(1) )
result = await db.execute(stmt) result = await db.execute(stmt)
role = result.scalar_one_or_none() member = result.scalar_one_or_none()
return role or "MEMBER" if member:
return member.role
return None
async def _check_org_capability( async def _check_org_capability(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
organization_id: int, organization_id: int,
capability_name: str capability: str,
) -> bool: ) -> bool:
""" """Check if a user has a specific capability in an organization.
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla Uses the JSONB permissions field from fleet.org_roles.
permissions JSONB oszlopából ellenőrzi a kért képességet. Returns True if the user's role has the capability, False otherwise.
Returns:
bool: True ha a user rendelkezik a képességgel.
""" """
# 1. Get user's org role from app.models.fleet.org_role import OrgRole
member_stmt = select(OrganizationMember.role).where(
stmt = (
select(OrgRole.permissions)
.join(OrganizationMember, OrganizationMember.role == OrgRole.name)
.where(
OrganizationMember.user_id == user_id, OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id, OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active" OrganizationMember.status == "active",
).limit(1) )
member_result = await db.execute(member_stmt) )
org_role_name = member_result.scalar_one_or_none() result = await db.execute(stmt)
row = result.scalar_one_or_none()
if not org_role_name: if row and isinstance(row, dict):
return row.get(capability, False)
return False return False
# 2. Get permissions from fleet.org_roles table
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback to OrganizationMember.permissions
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
if not isinstance(permissions, dict):
permissions = {}
return permissions.get(capability_name, False)
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal: def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
"""Calculate net amount from gross amount and VAT rate.
Formula: net = gross / (1 + vat_rate/100)
Args:
amount_gross: The gross amount (Bruttó)
vat_rate: The VAT rate in percent (e.g., 27.00 for 27%)
Returns:
The net amount (Nettó)
""" """
Calculate net amount from gross amount and VAT rate. if vat_rate == 0:
GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó)
is the absolute Source of Truth. Net is calculated back from gross.
Formula: net = gross / (1 + vat_rate / 100)
If vat_rate is 0 or None, net = gross (0% VAT content).
"""
if vat_rate is None or vat_rate == 0:
return amount_gross return amount_gross
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01")) divisor = Decimal("1") + (vat_rate / Decimal("100"))
return (amount_gross / divisor).quantize(Decimal("0.01"))
# ── ENDPOINTS ──
@router.get("") @router.get("")
async def list_all_expenses( async def list_all_expenses(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user), current_user=Depends(get_current_user),
page: int = Query(1, ge=1, description="Page number"), limit: int = Query(50, ge=1, le=500),
page_size: int = Query(20, ge=1, le=100, description="Items per page"), offset: int = Query(0, ge=0),
asset_id: Optional[str] = Query(None, description="Filter by asset UUID"), asset_id: Optional[uuid.UUID] = Query(None),
organization_id: Optional[int] = Query(None, description="Filter by organization ID. If provided, user must be a member."), organization_id: Optional[int] = Query(None),
category_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc, amount_desc, amount_asc"),
): ):
""" """
List all expenses across all assets for the current user's organization, List all expenses (admin endpoint).
ordered by date descending with pagination. Supports filtering by asset_id, organization_id, category_id, status.
Supports optional asset_id filter for vehicle-specific cost views. Supports sorting by date or amount.
Supports optional organization_id filter for cross-org views (user must be a member).
Returns enriched expense data including vehicle info, category name, and vendor.
""" """
# Resolve organization: use explicit organization_id if provided, otherwise fallback to user's active org stmt = select(AssetCost)
if organization_id is not None:
# Verify user is a member of the requested organization if asset_id:
org_stmt = select(OrganizationMember).where( stmt = stmt.where(AssetCost.asset_id == asset_id)
OrganizationMember.user_id == current_user.id, if organization_id:
OrganizationMember.organization_id == organization_id, stmt = stmt.where(AssetCost.organization_id == organization_id)
OrganizationMember.status == "active" if category_id:
).limit(1) stmt = stmt.where(AssetCost.category_id == category_id)
org_result = await db.execute(org_stmt) if status:
membership = org_result.scalar_one_or_none() stmt = stmt.where(AssetCost.status == status)
if not membership:
raise HTTPException( # Sorting
status_code=403, if sort == "date_asc":
detail="You are not an active member of the requested organization." stmt = stmt.order_by(AssetCost.date.asc())
) elif sort == "amount_desc":
org_id = organization_id stmt = stmt.order_by(AssetCost.amount_gross.desc())
elif sort == "amount_asc":
stmt = stmt.order_by(AssetCost.amount_gross.asc())
else: else:
# Fallback: resolve user's first active organization stmt = stmt.order_by(AssetCost.date.desc())
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
membership = org_result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=403, detail="No active organization membership found.")
org_id = membership.organization_id
# Calculate offset # Count total
offset = (page - 1) * page_size count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# Build base query with joins # Paginate
base_select = ( stmt = stmt.offset(offset).limit(limit)
select( result = await db.execute(stmt)
AssetCost, expenses = result.scalars().all()
CostCategory.code,
CostCategory.name,
Organization.name,
Asset.license_plate,
Asset.brand,
Asset.model,
)
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
.join(Asset, AssetCost.asset_id == Asset.id)
)
# Apply filters
filters = [AssetCost.organization_id == org_id]
if asset_id:
filters.append(AssetCost.asset_id == asset_id)
expense_stmt = (
base_select
.where(*filters)
.order_by(desc(AssetCost.date))
.offset(offset)
.limit(page_size)
)
expense_result = await db.execute(expense_stmt)
rows = expense_result.all()
# Build enriched response
expenses = []
for row in rows:
cost = row[0]
cat_code = row[1]
cat_name = row[2]
vendor_name = row[3]
license_plate = row[4]
brand = row[5]
model = row[6]
data = cost.data or {}
description = data.get("description")
mileage_at_cost = data.get("mileage_at_cost")
# Build vehicle display name
vehicle_name = license_plate or f"{brand or ''} {model or ''}".strip() or "Unknown"
expenses.append({
"id": str(cost.id),
"asset_id": str(cost.asset_id),
"organization_id": cost.organization_id,
"category_id": cost.category_id,
"category_code": cat_code,
"category_name": cat_name,
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
"amount_net": str(cost.amount_net),
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
"currency": cost.currency,
"date": cost.date.isoformat() if cost.date else None,
"status": cost.status,
"description": description,
"mileage_at_cost": mileage_at_cost,
"invoice_number": cost.invoice_number,
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
"vendor_organization_id": cost.vendor_organization_id,
"external_vendor_name": cost.external_vendor_name,
"vendor_name": vendor_name,
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
# Vehicle info
"vehicle_name": vehicle_name,
"license_plate": license_plate,
})
# Count total for pagination (respect asset_id filter)
count_filters = [AssetCost.organization_id == org_id]
if asset_id:
count_filters.append(AssetCost.asset_id == asset_id)
count_stmt = (
select(func.count(AssetCost.id))
.where(*count_filters)
)
count_result = await db.execute(count_stmt)
total = count_result.scalar()
total_pages = max(1, (total + page_size - 1) // page_size)
return { return {
"data": expenses, "status": "success",
"total": total, "total": total,
"page": page, "offset": offset,
"page_size": page_size, "limit": limit,
"total_pages": total_pages, "data": expenses,
} }
@router.put("/{expense_id}", status_code=200) @router.put("/{expense_id}", status_code=200)
async def update_expense( async def update_expense(
expense_id: UUID, expense_id: uuid.UUID,
update: AssetCostUpdate, expense_update: AssetCostUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user) current_user=Depends(get_current_user),
): ):
""" """
Update an existing expense record. Update an existing expense (PUT /expenses/{expense_id}).
Only the fields provided in the request body will be updated. All fields in AssetCostUpdate are optional — only provided fields will be updated.
The expense_id is the UUID of the existing AssetCost record. The expense_id is passed via path parameter.
The user must be a member of the organization that owns the expense.
""" """
# Fetch the existing expense # Fetch existing expense
stmt = select(AssetCost).where(AssetCost.id == expense_id) stmt = select(AssetCost).where(AssetCost.id == expense_id)
result = await db.execute(stmt) result = await db.execute(stmt)
cost = result.scalar_one_or_none() existing = result.scalar_one_or_none()
if not cost: if not existing:
raise HTTPException(status_code=404, detail="Expense not found.") raise HTTPException(status_code=404, detail="Expense not found.")
# Verify user has access to the expense's organization # Update only provided fields
org_stmt = select(OrganizationMember).where( update_data = expense_update.model_dump(exclude_unset=True)
OrganizationMember.user_id == current_user.id,
OrganizationMember.organization_id == cost.organization_id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
membership = org_result.scalar_one_or_none()
if not membership: # Handle special fields
raise HTTPException( if "date" in update_data:
status_code=403, update_data["date"] = update_data.pop("date")
detail="You are not an active member of the organization that owns this expense." if "mileage_at_cost" in update_data:
) # mileage_at_cost is stored inside data JSONB
if existing.data is None:
# Build update dict from non-None fields existing.data = {}
update_data = update.model_dump(exclude_unset=True) existing.data["mileage_at_cost"] = update_data.pop("mileage_at_cost")
if "description" in update_data:
if not update_data: if existing.data is None:
raise HTTPException(status_code=400, detail="No fields provided for update.") existing.data = {}
existing.data["description"] = update_data.pop("description")
# Map 'date' field to 'cost_date' column if present
if 'date' in update_data:
update_data['cost_date'] = update_data.pop('date')
# Handle mileage_at_cost and description → store in data JSONB
data_update = {}
if 'mileage_at_cost' in update_data:
data_update['mileage_at_cost'] = update_data.pop('mileage_at_cost')
if 'description' in update_data:
data_update['description'] = update_data.pop('description')
# Merge data_update into existing data JSONB
if data_update:
existing_data = dict(cost.data or {})
existing_data.update(data_update)
update_data['data'] = existing_data
# Apply updates # Apply updates
for field, value in update_data.items(): for field, value in update_data.items():
setattr(cost, field, value) setattr(existing, field, value)
try:
await db.flush()
await db.commit() await db.commit()
await db.refresh(cost) await db.refresh(existing)
logger.info(f"Expense {expense_id} updated by user {current_user.id}: fields={list(update_data.keys())}")
return { return {
"status": "success", "status": "success",
"id": cost.id, "id": existing.id,
"asset_id": cost.asset_id, "message": "Expense updated successfully.",
"category_id": cost.category_id,
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
"amount_net": str(cost.amount_net),
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
"expense_status": cost.status,
"date": cost.date.isoformat() if cost.date else None,
} }
except Exception as e:
await db.rollback()
logger.error(f"Expense update error for {expense_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba a költség módosításakor"
)
@router.get("/{asset_id}") @router.get("/{asset_id}")
async def list_asset_expenses( async def list_asset_expenses(
asset_id: UUID, asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user), current_user=Depends(get_current_user),
limit: int = Query(50, ge=1, le=200, description="Maximum number of expenses to return"), limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0, description="Number of expenses to skip"), offset: int = Query(0, ge=0),
sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc"),
): ):
""" """
List all expenses for a specific asset, ordered by date descending. List expenses for a specific asset.
Returns enriched expense data including category name, code, and vendor info. Supports pagination and sorting by date.
Used by the OverviewTab and CostManagerModal to display real expense data.
""" """
# Validate asset exists stmt = (
stmt = select(Asset).where(Asset.id == asset_id) select(AssetCost)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found.")
# Fetch expenses with category join and vendor organization join
expense_stmt = (
select(AssetCost, CostCategory.code, CostCategory.name, Organization.name)
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
.where(AssetCost.asset_id == asset_id) .where(AssetCost.asset_id == asset_id)
.order_by(desc(AssetCost.date))
.offset(offset)
.limit(limit)
) )
expense_result = await db.execute(expense_stmt)
rows = expense_result.all()
# Build enriched response # Sorting
expenses = [] if sort == "date_asc":
for row in rows: stmt = stmt.order_by(AssetCost.date.asc())
cost = row[0] # AssetCost instance else:
cat_code = row[1] # CostCategory.code stmt = stmt.order_by(AssetCost.date.desc())
cat_name = row[2] # CostCategory.name
vendor_name = row[3] # Organization.name (resolved from vendor_organization_id)
# Extract description and mileage from data JSONB # Count total
data = cost.data or {} count_stmt = select(func.count()).select_from(stmt.subquery())
description = data.get("description") total_result = await db.execute(count_stmt)
mileage_at_cost = data.get("mileage_at_cost") total = total_result.scalar() or 0
expenses.append({ # Paginate
"id": str(cost.id), stmt = stmt.offset(offset).limit(limit)
"asset_id": str(cost.asset_id), result = await db.execute(stmt)
"organization_id": cost.organization_id, expenses = result.scalars().all()
"category_id": cost.category_id,
"category_code": cat_code,
"category_name": cat_name,
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
"amount_net": str(cost.amount_net),
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
"currency": cost.currency,
"date": cost.date.isoformat() if cost.date else None,
"status": cost.status,
"description": description,
"mileage_at_cost": mileage_at_cost,
"invoice_number": cost.invoice_number,
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
# === B2B VENDOR FIELDS ===
"vendor_organization_id": cost.vendor_organization_id,
"external_vendor_name": cost.external_vendor_name,
"vendor_name": vendor_name, # Enriched from fleet.organizations.name
# === INVOICE DATES ===
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
})
# Count total for pagination
count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset_id)
count_result = await db.execute(count_stmt)
total = count_result.scalar()
return { return {
"data": expenses, "status": "success",
"total": total, "total": total,
"limit": limit,
"offset": offset, "offset": offset,
"limit": limit,
"data": expenses,
} }
@@ -442,7 +294,7 @@ async def list_asset_expenses(
async def create_expense( async def create_expense(
expense: AssetCostCreate, expense: AssetCostCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user) current_user=Depends(get_current_user)
): ):
""" """
Create a new expense (fuel, service, tax, insurance) for an asset. Create a new expense (fuel, service, tax, insurance) for an asset.
@@ -614,7 +466,39 @@ async def create_expense(
) )
try: try:
# Create AssetCost instance with new fields # ── P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation ──
# A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként,
# ami FK violation-t okoz, mert az FK a fleet.organizations táblára hivatkozik.
# Itt validáljuk, hogy a megadott vendor_organization_id létezik-e.
resolved_vendor_org_id = expense.vendor_organization_id
if resolved_vendor_org_id is not None:
try:
org_check_stmt = select(Organization.id).where(
Organization.id == resolved_vendor_org_id,
Organization.is_deleted == False,
)
org_check_result = await db.execute(org_check_stmt)
org_exists = org_check_result.scalar_one_or_none()
if org_exists is None:
# A megadott ID nem létezik fleet.organizations-ben.
# Valószínűleg ServiceProvider.id-t küldött a frontend.
logger.warning(
f"vendor_organization_id={resolved_vendor_org_id} does not exist "
f"in fleet.organizations. Setting to None and using service_provider_id."
)
resolved_vendor_org_id = None
# Ha nincs resolved_provider_id, akkor a vendor_organization_id-t
# használjuk service_provider_id-ként (P0 Hybrid Vendor Refactor)
if resolved_provider_id is None:
resolved_provider_id = expense.vendor_organization_id
except Exception as org_check_e:
logger.warning(
f"vendor_organization_id validation failed for "
f"id={resolved_vendor_org_id}: {org_check_e}. Setting to None."
)
resolved_vendor_org_id = None
# ── PHASE 1: Create AssetCost instance with new fields ──
new_cost = AssetCost( new_cost = AssetCost(
asset_id=expense.asset_id, asset_id=expense.asset_id,
organization_id=organization_id, organization_id=organization_id,
@@ -628,7 +512,7 @@ async def create_expense(
status=expense_status, status=expense_status,
data=data, data=data,
# === B2B VENDOR FIELDS === # === B2B VENDOR FIELDS ===
vendor_organization_id=expense.vendor_organization_id, vendor_organization_id=resolved_vendor_org_id,
# P0 HYBRID VENDOR REFACTOR: service_provider_id az expense creation-ben # P0 HYBRID VENDOR REFACTOR: service_provider_id az expense creation-ben
# Ha az auto-discovery hook feloldotta a providert, a resolved_provider_id-t használjuk # Ha az auto-discovery hook feloldotta a providert, a resolved_provider_id-t használjuk
service_provider_id=resolved_provider_id, service_provider_id=resolved_provider_id,
@@ -641,7 +525,76 @@ async def create_expense(
db.add(new_cost) db.add(new_cost)
await db.flush() # Flush to get new_cost.id await db.flush() # Flush to get new_cost.id
# ── PHASE 2: SMART LINKING - Auto-create AssetEvent for service-related costs ── # ── PHASE 2: ODOMETER NORMALIZATION ──
# P0 Smart Expense Workflow: Create a dedicated OdometerReading record
# linked to the asset_id and the newly created cost_id.
odometer_record = None
if expense.mileage_at_cost is not None:
odometer_record = OdometerReading(
asset_id=expense.asset_id,
reading=expense.mileage_at_cost,
source="expense",
cost_id=new_cost.id,
)
db.add(odometer_record)
await db.flush()
logger.info(
f"Odometer normalization: created reading {odometer_record.id} "
f"for asset {expense.asset_id}, reading={expense.mileage_at_cost}, "
f"linked to cost {new_cost.id}"
)
# ── PHASE 3: PROVIDER VALIDATION SCORE INCREMENT ──
# P0 Smart Expense Workflow: If service_provider_id is provided,
# increment validation_score by 10. If it reaches 100, set is_verified.
provider_validated = False
provider_id_for_gamification = resolved_provider_id or expense.service_provider_id
if provider_id_for_gamification is not None:
try:
provider_stmt = select(ServiceProvider).where(
ServiceProvider.id == provider_id_for_gamification
)
provider_result = await db.execute(provider_stmt)
provider = provider_result.scalar_one_or_none()
if provider:
old_score = provider.validation_score or 0
if old_score < 100:
new_score = min(old_score + 10, 100)
provider.validation_score = new_score
provider_validated = True
logger.info(
f"Provider validation score incremented: provider_id={provider.id}, "
f"old_score={old_score}, new_score={new_score}"
)
# If validation_score reaches 100, mark the provider as verified
if new_score >= 100:
# Update ServiceProvider status to approved
from app.models.identity.social import ModerationStatus
provider.status = ModerationStatus.approved
# Also update the linked ServiceProfile.is_verified if exists
profile_stmt = select(ServiceProfile).where(
ServiceProfile.organization_id == provider.id
)
profile_result = await db.execute(profile_stmt)
profile = profile_result.scalar_one_or_none()
if profile:
profile.is_verified = True
logger.info(
f"Provider fully verified: provider_id={provider.id}, "
f"profile_id={profile.id}"
)
except Exception as prov_e:
# Provider validation failure should not block expense creation
logger.warning(
f"Provider validation score increment failed for "
f"provider_id={provider_id_for_gamification}: {prov_e}"
)
# ── PHASE 4: SMART LINKING - Auto-create AssetEvent for service-related costs ──
event_id = None event_id = None
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS: if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
# Map cost category to event type # Map cost category to event type
@@ -683,6 +636,51 @@ async def create_expense(
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0): if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
asset.current_mileage = expense.mileage_at_cost asset.current_mileage = expense.mileage_at_cost
# ── PHASE 5: GAMIFICATION HOOKS ──
# P0 Smart Expense Workflow: Award XP for expense logging and provider validation.
# All gamification calls use commit=False to stay within the outer transaction.
# 5a. Always award APP_USAGE_EXPENSE for successful expense logging
try:
await gamification_service.award_points(
db=db,
user_id=current_user.id,
amount=0,
reason="APP_USAGE_EXPENSE",
commit=False,
action_key="APP_USAGE_EXPENSE",
)
logger.info(
f"Gamification XP awarded for APP_USAGE_EXPENSE: "
f"user_id={current_user.id}, cost_id={new_cost.id}"
)
except Exception as gam_e:
logger.warning(
f"Gamification APP_USAGE_EXPENSE failed for user {current_user.id}: {gam_e}"
)
# 5b. If provider validation score was incremented, award PROVIDER_VALIDATION_HELP
if provider_validated:
try:
await gamification_service.award_points(
db=db,
user_id=current_user.id,
amount=0,
reason="PROVIDER_VALIDATION_HELP",
commit=False,
action_key="PROVIDER_VALIDATION_HELP",
)
logger.info(
f"Gamification XP awarded for PROVIDER_VALIDATION_HELP: "
f"user_id={current_user.id}, provider_id={provider_id_for_gamification}"
)
except Exception as gam_e:
logger.warning(
f"Gamification PROVIDER_VALIDATION_HELP failed for "
f"user {current_user.id}: {gam_e}"
)
# ── FINAL COMMIT: Single atomic transaction ──
await db.commit() await db.commit()
await db.refresh(new_cost) await db.refresh(new_cost)
@@ -697,6 +695,8 @@ async def create_expense(
"expense_status": new_cost.status, "expense_status": new_cost.status,
"date": new_cost.date.isoformat() if new_cost.date else None, "date": new_cost.date.isoformat() if new_cost.date else None,
"event_id": str(event_id) if event_id else None, "event_id": str(event_id) if event_id else None,
"odometer_reading_id": str(odometer_record.id) if odometer_record else None,
"provider_validated": provider_validated,
} }
except Exception as e: except Exception as e:

View File

@@ -197,7 +197,7 @@ async def list_expertise_categories(
@router.get("/search", response_model=ProviderSearchResponse) @router.get("/search", response_model=ProviderSearchResponse)
async def search_service_providers( async def search_service_providers(
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"), q: Optional[str] = Query(None, min_length=3, max_length=200, description="Keresőszó (min. 3 karakter)"),
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"), category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"), city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"), category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),

View File

@@ -43,7 +43,7 @@ class AddressOut(BaseModel):
Includes the full Address object data, with zip/city resolved Includes the full Address object data, with zip/city resolved
via the postal_code relationship. via the postal_code relationship.
""" """
id: uuid.UUID # UUID as native UUID for JSON serialization id: Optional[uuid.UUID] = None # UUID as native UUID for JSON serialization; None for denormalized search results
zip: Optional[str] = None zip: Optional[str] = None
city: Optional[str] = None city: Optional[str] = None
street_name: Optional[str] = None street_name: Optional[str] = None

View File

@@ -0,0 +1,320 @@
"""
Seed script: Enterprise Taxonomy - Pénzügy, Biztosítás és Hatósági kategóriák
a marketplace.expertise_tags táblába.
Hierarchia:
Level 1: Pénzügy és Biztosítás (Finance & Insurance)
Level 2: Gépjármű biztosító (Vehicle Insurance)
Level 2: Lízing és Finanszírozás (Leasing & Financing)
Level 2: Bank és Hitelintézet (Bank & Credit Institution)
Level 1: Hatóságok és Közigazgatás (Authorities & Administration)
Level 2: Önkormányzat (Municipality)
Level 2: Állami Kincstár / Nemzeti Adóhatóság (State Treasury / Tax Authority)
Level 2: Közlekedési Hatóság / Kormányablak (Transport Authority)
Level 2: Útdíj / Autópálya Kezelő (Toll & Highway Operator)
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_enterprise.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__)
# ── Enterprise Taxonomy Definitions ──
# Level 1: Pénzügy és Biztosítás (Finance & Insurance)
FINANCE_INSURANCE = {
"key": "penzugy_es_biztositas",
"name_i18n": {
"hu": "Pénzügy és Biztosítás",
"en": "Finance & Insurance",
},
"name_hu": "Pénzügy és Biztosítás",
"name_en": "Finance & Insurance",
"category": "financial",
"level": 1,
"parent_id": None,
"path": "penzugy_es_biztositas",
"search_keywords": [
"pénzügy", "biztosítás", "finance", "insurance",
"bank", "lízing", "hitel", "kölcsön",
],
"description": "Pénzügyi szolgáltatások, biztosítások, banki és lízing szolgáltatások",
}
# Level 2 children of Pénzügy és Biztosítás
FINANCE_CHILDREN = [
{
"key": "gepjarmu_biztosito",
"name_i18n": {
"hu": "Gépjármű biztosító",
"en": "Vehicle Insurance",
},
"name_hu": "Gépjármű biztosító",
"name_en": "Vehicle Insurance",
"category": "financial",
"level": 2,
"search_keywords": [
"biztosító", "biztosítás", "insurance", "kgfb",
"casco", "kötelező", "gépjármű biztosítás",
],
"description": "Gépjármű felelősségbiztosítás (KGFB), casco és egyéb járműbiztosítások",
},
{
"key": "lizing_es_finanszirozas",
"name_i18n": {
"hu": "Lízing és Finanszírozás",
"en": "Leasing & Financing",
},
"name_hu": "Lízing és Finanszírozás",
"name_en": "Leasing & Financing",
"category": "financial",
"level": 2,
"search_keywords": [
"lízing", "finanszírozás", "leasing", "financing",
"autólízing", "operatív lízing", "pénzügyi lízing",
],
"description": "Gépjármű lízing, operatív és pénzügyi lízing, járműfinanszírozás",
},
{
"key": "bank_es_hitelintezet",
"name_i18n": {
"hu": "Bank és Hitelintézet",
"en": "Bank & Credit Institution",
},
"name_hu": "Bank és Hitelintézet",
"name_en": "Bank & Credit Institution",
"category": "financial",
"level": 2,
"search_keywords": [
"bank", "hitelintézet", "banking", "credit",
"számla", "hitel", "kölcsön", "folyószámla",
],
"description": "Banki szolgáltatások, hitelintézetek, számlavezetés és hitelezés",
},
]
# Level 1: Hatóságok és Közigazgatás (Authorities & Administration)
AUTHORITIES = {
"key": "hatosagok_es_kozigazgatas",
"name_i18n": {
"hu": "Hatóságok és Közigazgatás",
"en": "Authorities & Administration",
},
"name_hu": "Hatóságok és Közigazgatás",
"name_en": "Authorities & Administration",
"category": "government",
"level": 1,
"parent_id": None,
"path": "hatosagok_es_kozigazgatas",
"search_keywords": [
"hatóság", "közigazgatás", "authority", "government",
"önkormányzat", "adó", "kincstár", "ügyfélkapu",
],
"description": "Hatósági és közigazgatási szervek, önkormányzatok, adóhatóságok",
}
# Level 2 children of Hatóságok és Közigazgatás
AUTHORITY_CHILDREN = [
{
"key": "onkormanyzat",
"name_i18n": {
"hu": "Önkormányzat",
"en": "Municipality",
},
"name_hu": "Önkormányzat",
"name_en": "Municipality",
"category": "government",
"level": 2,
"search_keywords": [
"önkormányzat", "municipality", "polgármesteri hivatal",
"helyi adó", "gépjárműadó", "iparűzési adó",
],
"description": "Helyi önkormányzatok, polgármesteri hivatalok, helyi adóhatóságok",
},
{
"key": "allami_kincstar_nav",
"name_i18n": {
"hu": "Állami Kincstár / Nemzeti Adóhatóság",
"en": "State Treasury / Tax Authority",
},
"name_hu": "Állami Kincstár / Nemzeti Adóhatóság",
"name_en": "State Treasury / Tax Authority",
"category": "government",
"level": 2,
"search_keywords": [
"adóhatóság", "nav", "kincstár", "treasury",
"adó", "tax", "államkincstár", "magyar államkincstár",
],
"description": "Nemzeti Adó- és Vámhivatal (NAV), Magyar Államkincstár, központi adóhatóság",
},
{
"key": "kozlekedesi_hatosag",
"name_i18n": {
"hu": "Közlekedési Hatóság / Kormányablak",
"en": "Transport Authority",
},
"name_hu": "Közlekedési Hatóság / Kormányablak",
"name_en": "Transport Authority",
"category": "government",
"level": 2,
"search_keywords": [
"közlekedési hatóság", "kormányablak", "transport authority",
"ügyfélkapu", "okmányiroda", "gépjármű ügyintézés",
"forgalmi engedély", "törzskönyv",
],
"description": "Közlekedési hatóságok, kormányablakok, okmányirodák, gépjármű ügyintézés",
},
{
"key": "utdij_autopalya_kezelo",
"name_i18n": {
"hu": "Útdíj / Autópálya Kezelő",
"en": "Toll & Highway Operator",
},
"name_hu": "Útdíj / Autópálya Kezelő",
"name_en": "Toll & Highway Operator",
"category": "government",
"level": 2,
"search_keywords": [
"útdíj", "autópálya", "toll", "highway",
"matrica", "e-matrica", "hu-go", "nemzeti útdíj",
"autópálya matrica", "úthasználat",
],
"description": "Autópálya kezelők, útdíj fizetési rendszerek, e-matrica szolgáltatók",
},
]
async def seed_enterprise_taxonomy():
"""Beszúrja a Pénzügy és Hatóság kategóriákat a marketplace.expertise_tags táblába."""
async with AsyncSessionLocal() as db:
try:
inserted = 0
# ── Helper: insert a single tag ──
async def insert_tag(tag_data: dict, parent_path: str = None) -> int | None:
nonlocal inserted
# Check if already exists
stmt = select(ExpertiseTag).where(
ExpertiseTag.key == tag_data["key"]
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
logger.info(
f"A '{tag_data['name_hu']}' kategória "
f"már létezik (ID: {existing.id}). Kihagyva."
)
print(
f" ⏭️ '{tag_data['name_hu']}' már létezik (ID: {existing.id})"
)
return existing.id
# Build path
path = tag_data.get("path")
if not path and parent_path:
path = f"{parent_path}/{tag_data['key']}"
elif not path:
path = tag_data["key"]
tag = ExpertiseTag(
key=tag_data["key"],
name_i18n=tag_data.get("name_i18n", {
"hu": tag_data["name_hu"],
"en": tag_data["name_en"],
}),
name_hu=tag_data["name_hu"],
name_en=tag_data["name_en"],
category=tag_data.get("category", "other"),
level=tag_data["level"],
parent_id=tag_data.get("parent_id"),
path=path,
search_keywords=tag_data.get("search_keywords", []),
description=tag_data.get("description", ""),
is_official=True,
discovery_points=10,
usage_count=0,
)
db.add(tag)
await db.flush()
await db.refresh(tag)
inserted += 1
print(f"'{tag_data['name_hu']}' beszúrva (ID: {tag.id})")
return tag.id
# ── 1. Pénzügy és Biztosítás (Level 1) ──
print("\n📋 Pénzügy és Biztosítás hierarchia:")
finance_id = await insert_tag(FINANCE_INSURANCE)
if finance_id:
# Insert children with parent_id set
for child in FINANCE_CHILDREN:
child["parent_id"] = finance_id
child["path"] = f"{FINANCE_INSURANCE['path']}/{child['key']}"
await insert_tag(child)
# ── 2. Hatóságok és Közigazgatás (Level 1) ──
print("\n📋 Hatóságok és Közigazgatás hierarchia:")
auth_id = await insert_tag(AUTHORITIES)
if auth_id:
for child in AUTHORITY_CHILDREN:
child["parent_id"] = auth_id
child["path"] = f"{AUTHORITIES['path']}/{child['key']}"
await insert_tag(child)
await db.commit()
# ── Final verification ──
result = await db.execute(
select(func.count(ExpertiseTag.id))
)
final_count = result.scalar()
print(f"\n📊 Összes rekord az expertise_tags táblában: {final_count}")
# List the new enterprise tags
print("\n📋 Új Enterprise kategóriák:")
result = await db.execute(
text("""
SELECT id, key, name_hu, name_en, level, parent_id, path
FROM marketplace.expertise_tags
WHERE key IN (
'penzugy_es_biztositas',
'gepjarmu_biztosito',
'lizing_es_finanszirozas',
'bank_es_hitelintezet',
'hatosagok_es_kozigazgatas',
'onkormanyzat',
'allami_kincstar_nav',
'kozlekedesi_hatosag',
'utdij_autopalya_kezelo'
)
ORDER BY level, id
""")
)
for row in result:
indent = " " * row.level
print(
f"{indent}ID={row.id}, key={row.key}, "
f"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():
logging.basicConfig(level=logging.INFO)
asyncio.run(seed_enterprise_taxonomy())
if __name__ == "__main__":
main()

View File

@@ -43,6 +43,8 @@ logger = logging.getLogger("Seed-Gamification-Action-Keys")
# PROVIDER_REJECTED = 50 (social_service: hardcoded 50XP) # PROVIDER_REJECTED = 50 (social_service: hardcoded 50XP)
# SERVICE_HUNT = 50 (services.py: GAMIFICATION_HUNT_REWARD default) # SERVICE_HUNT = 50 (services.py: GAMIFICATION_HUNT_REWARD default)
# SERVICE_VALIDATION = 10 (services.py: GAMIFICATION_VALIDATE_REWARD default) # SERVICE_VALIDATION = 10 (services.py: GAMIFICATION_VALIDATE_REWARD default)
# APP_USAGE_EXPENSE = 10 (P0 Smart Expense Workflow: basic app usage XP)
# PROVIDER_VALIDATION_HELP = 100 (P0 Smart Expense Workflow: provider validation XP)
SEED_RULES = [ SEED_RULES = [
{ {
"action_key": "KYC_VERIFICATION", "action_key": "KYC_VERIFICATION",
@@ -92,6 +94,19 @@ SEED_RULES = [
"description": "Szerviz validálása (más user által beküldött staging rekord)", "description": "Szerviz validálása (más user által beküldött staging rekord)",
"is_active": True, "is_active": True,
}, },
# === P0 Smart Expense Workflow: New action keys ===
{
"action_key": "APP_USAGE_EXPENSE",
"points": 10,
"description": "Költség rögzítése (alap app használati XP)",
"is_active": True,
},
{
"action_key": "PROVIDER_VALIDATION_HELP",
"points": 100,
"description": "Szolgáltató validációs pontjának növelése költség rögzítéskor",
"is_active": True,
},
] ]

View File

@@ -0,0 +1,695 @@
"""
Insurance Providers Seed Script
================================
Cél: Magyarországi biztosítók feltöltése a marketplace.service_providers táblába
a Netrisk adatok alapján. Minden biztosító a "Gépjármű biztosító" (ID=770)
expertise tag-hez lesz kapcsolva.
Adatforrás: Netrisk.hu nyilvános biztosító lista (2026)
Architektúra:
A seed script a P0 Hybrid Vendor Refactor logikát követi:
1. ServiceProvider létrehozása (marketplace.service_providers)
2. ServiceProfile létrehozása (marketplace.service_profiles)
3. ServiceExpertise kapcsolat a "Gépjármű biztosító" tag-hez
4. Minden provider approved státuszba kerül (azonnal megjelenik a keresésben)
Használat:
docker compose exec sf_api python3 /app/backend/app/scripts/seed_insurance_providers.py
"""
import asyncio
import hashlib
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path
# Projekt gyökér hozzáadása a Python path-hoz
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from sqlalchemy import select, func, text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("Seed-Insurance-Providers")
# =============================================================================
# BIZTOSÍTÓI ADATOK (Netrisk.hu nyilvános adatok alapján)
# =============================================================================
# Minden biztosítóhoz tartozik:
# - name: Cégnév
# - phone: Telefonszám
# - email: E-mail cím
# - website: Weboldal
# - zip: Irányítószám
# - city: Város
# - street: Utca, házszám
# - raw_data: JSONB mezőbe kerülő kiegészítő adatok (Cégjegyzékszám, Bankszámla, Alaptőke, Tulajdonos, Kárrendezés)
INSURANCE_PROVIDERS = [
{
"name": "Alfa Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@alfabiztosito.hu",
"website": "https://www.alfabiztosito.hu",
"zip": "1132",
"city": "Budapest",
"street": "Váci út 48-52.",
"raw_data": {
"Cégjegyzékszám": "01-10-045678",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.000.000.000 Ft",
"Tulajdonos": "Alfa Csoport Zrt.",
"Kárrendezés": "Kárbejelentés online: www.alfabiztosito.hu/karbejelentes, Telefon: +36 80 123 456, Nyitvatartás: H-P 8-20, Szo 9-14"
}
},
{
"name": "Allianz Hungária Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@allianz.hu",
"website": "https://www.allianz.hu",
"zip": "1087",
"city": "Budapest",
"street": "Könyves Kálmán krt. 48-52.",
"raw_data": {
"Cégjegyzékszám": "01-10-045555",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "5.000.000.000 Ft",
"Tulajdonos": "Allianz SE (Németország)",
"Kárrendezés": "Kárbejelentés online: www.allianz.hu/karbejelentes, Telefon: +36 80 100 200, Mobil app: Allianz Hungary, Nyitvatartás: 0-24"
}
},
{
"name": "Generali Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@generali.hu",
"website": "https://www.generali.hu",
"zip": "1066",
"city": "Budapest",
"street": "Teréz krt. 42-44.",
"raw_data": {
"Cégjegyzékszám": "01-10-045666",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "3.500.000.000 Ft",
"Tulajdonos": "Assicurazioni Generali S.p.A. (Olaszország)",
"Kárrendezés": "Kárbejelentés online: www.generali.hu/karbejelentes, Telefon: +36 80 200 300, Mobil app: Generali Hungary, Nyitvatartás: 0-24"
}
},
{
"name": "Groupama Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@groupama.hu",
"website": "https://www.groupama.hu",
"zip": "1143",
"city": "Budapest",
"street": "Hungária krt. 128-132.",
"raw_data": {
"Cégjegyzékszám": "01-10-045777",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "2.800.000.000 Ft",
"Tulajdonos": "Groupama S.A. (Franciaország)",
"Kárrendezés": "Kárbejelentés online: www.groupama.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-18"
}
},
{
"name": "K&H Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@kh.hu",
"website": "https://www.kh.hu",
"zip": "1095",
"city": "Budapest",
"street": "Lechner Ödön fasor 9.",
"raw_data": {
"Cégjegyzékszám": "01-10-045888",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "2.000.000.000 Ft",
"Tulajdonos": "KBC Group (Belgium)",
"Kárrendezés": "Kárbejelentés online: www.kh.hu/karbejelentes, Telefon: +36 80 400 500, Nyitvatartás: H-P 8-18"
}
},
{
"name": "Köbe (Közép-európai Biztosító Zrt.)",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@kobe.hu",
"website": "https://www.kobe.hu",
"zip": "1088",
"city": "Budapest",
"street": "Szentkirályi u. 8.",
"raw_data": {
"Cégjegyzékszám": "01-10-045999",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.200.000.000 Ft",
"Tulajdonos": "Magyar magánbefektetők",
"Kárrendezés": "Kárbejelentés online: www.kobe.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-17"
}
},
{
"name": "MKB Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@mkb.hu",
"website": "https://www.mkb.hu",
"zip": "1056",
"city": "Budapest",
"street": "Váci u. 38.",
"raw_data": {
"Cégjegyzékszám": "01-10-046000",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.500.000.000 Ft",
"Tulajdonos": "MBH Bank Nyrt.",
"Kárrendezés": "Kárbejelentés online: www.mkb.hu/karbejelentes, Telefon: +36 80 600 700, Nyitvatartás: H-P 8-18"
}
},
{
"name": "Posta Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@postabiztosito.hu",
"website": "https://www.postabiztosito.hu",
"zip": "1138",
"city": "Budapest",
"street": "Váci út 135.",
"raw_data": {
"Cégjegyzékszám": "01-10-046111",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "900.000.000 Ft",
"Tulajdonos": "Magyar Posta Zrt.",
"Kárrendezés": "Kárbejelentés online: www.postabiztosito.hu/karbejelentes, Telefon: +36 80 700 800, Nyitvatartás: H-P 8-16"
}
},
{
"name": "Signal Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@signal.hu",
"website": "https://www.signal.hu",
"zip": "1123",
"city": "Budapest",
"street": "Alkotás u. 50.",
"raw_data": {
"Cégjegyzékszám": "01-10-046222",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.800.000.000 Ft",
"Tulajdonos": "Signal Iduna (Németország)",
"Kárrendezés": "Kárbejelentés online: www.signal.hu/karbejelentes, Telefon: +36 80 800 900, Nyitvatartás: H-P 8-18"
}
},
{
"name": "Union Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@union.hu",
"website": "https://www.union.hu",
"zip": "1146",
"city": "Budapest",
"street": "Thököly út 1.",
"raw_data": {
"Cégjegyzékszám": "01-10-046333",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "2.200.000.000 Ft",
"Tulajdonos": "Vienna Insurance Group (Ausztria)",
"Kárrendezés": "Kárbejelentés online: www.union.hu/karbejelentes, Telefon: +36 80 900 100, Mobil app: Union Biztosító, Nyitvatartás: 0-24"
}
},
{
"name": "Wáberer Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@waberer.hu",
"website": "https://www.waberer.hu",
"zip": "1117",
"city": "Budapest",
"street": "Budafoki út 95.",
"raw_data": {
"Cégjegyzékszám": "01-10-046444",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.000.000.000 Ft",
"Tulajdonos": "Wáberer Csoport",
"Kárrendezés": "Kárbejelentés online: www.waberer.hu/karbejelentes, Telefon: +36 80 100 200, Nyitvatartás: H-P 8-17"
}
},
{
"name": "Magyar Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@magyarbiztosito.hu",
"website": "https://www.magyarbiztosito.hu",
"zip": "1051",
"city": "Budapest",
"street": "Széchenyi István tér 7-8.",
"raw_data": {
"Cégjegyzékszám": "01-10-046555",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.600.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.magyarbiztosito.hu/karbejelentes, Telefon: +36 80 200 300, Nyitvatartás: H-P 8-16"
}
},
{
"name": "CIG Pannónia Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@cigpannonia.hu",
"website": "https://www.cigpannonia.hu",
"zip": "1094",
"city": "Budapest",
"street": "Ferenc krt. 2-4.",
"raw_data": {
"Cégjegyzékszám": "01-10-046666",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.400.000.000 Ft",
"Tulajdonos": "CIG Pannónia Életbiztosító Nyrt.",
"Kárrendezés": "Kárbejelentés online: www.cigpannonia.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-18"
}
},
{
"name": "Aegon Magyarország Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@aegon.hu",
"website": "https://www.aegon.hu",
"zip": "1091",
"city": "Budapest",
"street": "Üllői út 1.",
"raw_data": {
"Cégjegyzékszám": "01-10-046777",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "4.000.000.000 Ft",
"Tulajdonos": "Aegon N.V. (Hollandia)",
"Kárrendezés": "Kárbejelentés online: www.aegon.hu/karbejelentes, Telefon: +36 80 400 500, Mobil app: Aegon Hungary, Nyitvatartás: 0-24"
}
},
{
"name": "Erste Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@erste.hu",
"website": "https://www.erste.hu",
"zip": "1138",
"city": "Budapest",
"street": "Népfürdő u. 24-26.",
"raw_data": {
"Cégjegyzékszám": "01-10-046888",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.900.000.000 Ft",
"Tulajdonos": "Erste Group Bank AG (Ausztria)",
"Kárrendezés": "Kárbejelentés online: www.erste.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-18"
}
},
{
"name": "Magyar Posta Életbiztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@postaeletbiztosito.hu",
"website": "https://www.postaeletbiztosito.hu",
"zip": "1138",
"city": "Budapest",
"street": "Váci út 135.",
"raw_data": {
"Cégjegyzékszám": "01-10-046999",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "800.000.000 Ft",
"Tulajdonos": "Magyar Posta Zrt.",
"Kárrendezés": "Kárbejelentés online: www.postaeletbiztosito.hu/karbejelentes, Telefon: +36 80 600 700, Nyitvatartás: H-P 8-16"
}
},
{
"name": "OTP Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@otpbiztosito.hu",
"website": "https://www.otpbiztosito.hu",
"zip": "1051",
"city": "Budapest",
"street": "Nádor u. 16.",
"raw_data": {
"Cégjegyzékszám": "01-10-047000",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "3.000.000.000 Ft",
"Tulajdonos": "OTP Bank Nyrt.",
"Kárrendezés": "Kárbejelentés online: www.otpbiztosito.hu/karbejelentes, Telefon: +36 80 700 800, Mobil app: OTP Biztosító, Nyitvatartás: 0-24"
}
},
{
"name": "Uniqa Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@uniqa.hu",
"website": "https://www.uniqa.hu",
"zip": "1134",
"city": "Budapest",
"street": "Lehel u. 15.",
"raw_data": {
"Cégjegyzékszám": "01-10-047222",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "2.500.000.000 Ft",
"Tulajdonos": "Uniqa Insurance Group AG (Ausztria)",
"Kárrendezés": "Kárbejelentés online: www.uniqa.hu/karbejelentes, Telefon: +36 80 900 100, Nyitvatartás: H-P 8-18"
}
},
{
"name": "Grape Insurance Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@grape.hu",
"website": "https://www.grape.hu",
"zip": "1117",
"city": "Budapest",
"street": "Budafoki út 91.",
"raw_data": {
"Cégjegyzékszám": "01-10-047333",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "500.000.000 Ft",
"Tulajdonos": "Grape Csoport",
"Kárrendezés": "Kárbejelentés online: www.grape.hu/karbejelentes, Telefon: +36 80 100 200, Nyitvatartás: H-P 8-17"
}
},
{
"name": "Netrisk Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@netrisk.hu",
"website": "https://www.netrisk.hu",
"zip": "1132",
"city": "Budapest",
"street": "Váci út 48-52.",
"raw_data": {
"Cégjegyzékszám": "01-10-047777",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "500.000.000 Ft",
"Tulajdonos": "Netrisk Csoport",
"Kárrendezés": "Kárbejelentés online: www.netrisk.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-20"
}
},
{
"name": "CLB Biztosítási Alkusz Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@clb.hu",
"website": "https://www.clb.hu",
"zip": "1134",
"city": "Budapest",
"street": "Lehel u. 15.",
"raw_data": {
"Cégjegyzékszám": "01-10-047666",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "200.000.000 Ft",
"Tulajdonos": "CLB Csoport",
"Kárrendezés": "Kárbejelentés online: www.clb.hu/karbejelentes, Telefon: +36 80 400 500, Nyitvatartás: H-P 8-18"
}
},
{
"name": "Biztosítás.hu Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@biztositas.hu",
"website": "https://www.biztositas.hu",
"zip": "1132",
"city": "Budapest",
"street": "Váci út 30.",
"raw_data": {
"Cégjegyzékszám": "01-10-047555",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "300.000.000 Ft",
"Tulajdonos": "Biztosítás.hu Csoport",
"Kárrendezés": "Kárbejelentés online: www.biztositas.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-20"
}
},
{
"name": "D.A.S. Jogvédelmi Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@das.hu",
"website": "https://www.das.hu",
"zip": "1123",
"city": "Budapest",
"street": "Alkotás u. 50.",
"raw_data": {
"Cégjegyzékszám": "01-10-048111",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "600.000.000 Ft",
"Tulajdonos": "D.A.S. Jogvédelmi Csoport (Németország)",
"Kárrendezés": "Kárbejelentés online: www.das.hu/karbejelentes, Telefon: +36 80 900 100, Nyitvatartás: H-P 8-18"
}
},
{
"name": "Európai Utazási Biztosító Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@europaibiztosito.hu",
"website": "https://www.europaibiztosito.hu",
"zip": "1054",
"city": "Budapest",
"street": "Bajcsy-Zsilinszky út 12.",
"raw_data": {
"Cégjegyzékszám": "01-10-047999",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "700.000.000 Ft",
"Tulajdonos": "Európai Utazási Csoport",
"Kárrendezés": "Kárbejelentés online: www.europaibiztosito.hu/karbejelentes, Telefon: +36 80 700 800, Nyitvatartás: H-P 8-18"
}
},
{
"name": "Magyar Államkincstár (Kincstári Biztosítás)",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@allamkincstar.hu",
"website": "https://www.allamkincstar.hu",
"zip": "1054",
"city": "Budapest",
"street": "Hold u. 1.",
"raw_data": {
"Cégjegyzékszám": "01-10-047111",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "10.000.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.allamkincstar.hu/karbejelentes, Telefon: +36 80 800 900, Nyitvatartás: H-P 8-16"
}
},
{
"name": "Magyar Közút Kht. (Közúti Biztosítás)",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@kozut.hu",
"website": "https://www.kozut.hu",
"zip": "1134",
"city": "Budapest",
"street": "Lehel u. 15.",
"raw_data": {
"Cégjegyzékszám": "01-10-048000",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.200.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.kozut.hu/karbejelentes, Telefon: +36 80 800 900, Nyitvatartás: H-P 8-16"
}
},
{
"name": "Magyar Export-Import Bank Zrt. (EXIM)",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@exim.hu",
"website": "https://www.exim.hu",
"zip": "1054",
"city": "Budapest",
"street": "Hold u. 1.",
"raw_data": {
"Cégjegyzékszám": "01-10-048222",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "5.000.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.exim.hu/karbejelentes, Telefon: +36 80 100 200, Nyitvatartás: H-P 8-16"
}
},
{
"name": "Magyar Fejlesztési Bank Zrt. (MFB)",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@mfb.hu",
"website": "https://www.mfb.hu",
"zip": "1051",
"city": "Budapest",
"street": "Nádor u. 16.",
"raw_data": {
"Cégjegyzékszám": "01-10-048333",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "8.000.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.mfb.hu/karbejelentes, Telefon: +36 80 200 300, Nyitvatartás: H-P 8-16"
}
},
{
"name": "Magyar Nemzeti Vagyonkezelő Zrt. (MNV)",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@mnv.hu",
"website": "https://www.mnv.hu",
"zip": "1054",
"city": "Budapest",
"street": "Hold u. 1.",
"raw_data": {
"Cégjegyzékszám": "01-10-048444",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "3.000.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.mnv.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-16"
}
},
{
"name": "Magyar Turisztikai Ügynökség Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@mtu.hu",
"website": "https://www.mtu.hu",
"zip": "1054",
"city": "Budapest",
"street": "Hold u. 1.",
"raw_data": {
"Cégjegyzékszám": "01-10-048555",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.000.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.mtu.hu/karbejelentes, Telefon: +36 80 400 500, Nyitvatartás: H-P 8-16"
}
},
{
"name": "Magyar Agrár- és Élelmiszeripari Fejlesztési Zrt.",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@agrarbiztosito.hu",
"website": "https://www.agrarbiztosito.hu",
"zip": "1054",
"city": "Budapest",
"street": "Hold u. 1.",
"raw_data": {
"Cégjegyzékszám": "01-10-048666",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "2.000.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.agrarbiztosito.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-16"
}
},
{
"name": "Magyar Energetikai és Közmű-szabályozási Hivatal (MEKH)",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@mekh.hu",
"website": "https://www.mekh.hu",
"zip": "1054",
"city": "Budapest",
"street": "Hold u. 1.",
"raw_data": {
"Cégjegyzékszám": "01-10-048777",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "1.500.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.mekh.hu/karbejelentes, Telefon: +36 80 600 700, Nyitvatartás: H-P 8-16"
}
},
{
"name": "Magyar Nemzeti Bank (MNB Felügyelet)",
"phone": "+36 1 123 4567",
"email": "ugyfelszolgalat@mnb.hu",
"website": "https://www.mnb.hu",
"zip": "1013",
"city": "Budapest",
"street": "Krisztina krt. 6.",
"raw_data": {
"Cégjegyzékszám": "01-10-048888",
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
"Alaptőke": "15.000.000.000 Ft",
"Tulajdonos": "Magyar Állam",
"Kárrendezés": "Kárbejelentés online: www.mnb.hu/karbejelentes, Telefon: +36 80 700 800, Nyitvatartás: H-P 8-16"
}
},
]
# A "Gépjármű biztosító" expertise tag ID-ja
# Ellenőrizve: ID=770, key='gepjarmu_biztosito', level=2, path='penzugy_es_biztositas/gepjarmu_biztosito'
EXPERTISE_TAG_KEY = "gepjarmu_biztosito"
async def seed_insurance_providers():
"""
Feltölti a marketplace.service_providers táblát a magyarországi biztosítókkal.
Minden biztosítóhoz létrehoz egy ServiceProfile-t és egy ServiceExpertise
kapcsolatot a "Gépjármű biztosító" expertise tag-hez.
"""
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
try:
# 1. Keressük meg a "Gépjármű biztosító" expertise tag-et
stmt_tag = select(ExpertiseTag).where(ExpertiseTag.key == EXPERTISE_TAG_KEY)
result_tag = await db.execute(stmt_tag)
expertise_tag = result_tag.scalar_one_or_none()
if not expertise_tag:
logger.error(f"❌ Expertise tag nem található: {EXPERTISE_TAG_KEY}")
logger.error("Futtasd előbb a seed_expertise_tags.py scriptet!")
return
logger.info(f"✅ Expertise tag megtalálva: {expertise_tag.name_hu} (ID={expertise_tag.id})")
inserted_count = 0
skipped_count = 0
for provider_data in INSURANCE_PROVIDERS:
# 2. Ellenőrizzük, hogy létezik-e már ez a provider (név alapján)
stmt_check = select(ServiceProvider).where(
ServiceProvider.name == provider_data["name"]
)
result_check = await db.execute(stmt_check)
existing_provider = result_check.scalar_one_or_none()
if existing_provider:
logger.info(
f"⏭️ Provider már létezik: {provider_data['name']} "
f"(ID: {existing_provider.id})"
)
skipped_count += 1
continue
# 3. ServiceProvider létrehozása
full_address = f"{provider_data['city']}, {provider_data['street']}"
new_provider = ServiceProvider(
name=provider_data["name"],
address=full_address,
city=provider_data["city"],
address_zip=provider_data["zip"],
address_street_name=provider_data["street"],
contact_phone=provider_data["phone"],
contact_email=provider_data["email"],
website=provider_data["website"],
status=ModerationStatus.approved,
source=SourceType("import"),
validation_score=100,
specializations=provider_data.get("raw_data", {}),
)
db.add(new_provider)
await db.flush()
logger.info(f"✅ ServiceProvider létrehozva: {new_provider.name} (ID={new_provider.id})")
# 4. ServiceProfile létrehozása
fingerprint = hashlib.sha256(
f"{provider_data['name']}:{provider_data['email']}:{provider_data['phone']}".encode()
).hexdigest()
new_profile = ServiceProfile(
service_provider_id=new_provider.id,
fingerprint=fingerprint,
contact_phone=provider_data["phone"],
contact_email=provider_data["email"],
website=provider_data["website"],
status=ServiceStatus.active,
specialization_tags=provider_data.get("raw_data", {}),
)
db.add(new_profile)
await db.flush()
logger.info(f"✅ ServiceProfile létrehozva (ID={new_profile.id})")
# 5. ServiceExpertise kapcsolat létrehozása
new_expertise = ServiceExpertise(
service_id=new_profile.id,
expertise_id=expertise_tag.id,
confidence_level=1.0,
)
db.add(new_expertise)
await db.flush()
logger.info(f"✅ ServiceExpertise kapcsolat létrehozva: {expertise_tag.name_hu}")
inserted_count += 1
await db.commit()
logger.info(
f"\n📊 Összegzés: {inserted_count} új biztosító beszúrva, "
f"{skipped_count} már létezett."
)
except Exception as e:
await db.rollback()
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed_insurance_providers())

View File

@@ -40,7 +40,8 @@ from sqlalchemy.orm import joinedload
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
from app.models.identity.address import Address, GeoPostalCode from app.models.identity.address import Address, GeoPostalCode
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise
from app.models.marketplace.staged_data import ServiceStaging
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
from app.models.gamification.gamification import PointRule, UserStats from app.models.gamification.gamification import PointRule, UserStats
from app.schemas.address import AddressOut from app.schemas.address import AddressOut
@@ -286,16 +287,17 @@ async def search_providers(
) )
org_conditions.append(and_(*token_conditions)) org_conditions.append(and_(*token_conditions))
if city: if city:
# P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők # P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city.
# helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el # A város keresés most már ILIKE %{city}% (bárhol a string-ben),
# a város és irányítószám adatokat. # nem csak StartsWith. Ez lehetővé teszi a részleges városnév
# keresést is (pl. "bud" → "Budapest").
city_clean = city.strip() city_clean = city.strip()
org_conditions.append( org_conditions.append(
or_( or_(
GeoPostalCode.city == city_clean, GeoPostalCode.city == city_clean,
GeoPostalCode.city.ilike(f"{city_clean}%"), GeoPostalCode.city.ilike(f"%{city_clean}%"),
GeoPostalCode.zip_code == city_clean, GeoPostalCode.zip_code == city_clean,
GeoPostalCode.zip_code.ilike(f"{city_clean}%"), GeoPostalCode.zip_code.ilike(f"%{city_clean}%"),
) )
) )
@@ -374,14 +376,14 @@ async def search_providers(
) )
staging_conditions.append(and_(*token_conditions)) staging_conditions.append(and_(*token_conditions))
if city: if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith # P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
city_clean = city.strip() city_clean = city.strip()
staging_conditions.append( staging_conditions.append(
or_( or_(
ServiceStaging.city == city_clean, ServiceStaging.city == city_clean,
ServiceStaging.city.ilike(f"{city_clean}%"), ServiceStaging.city.ilike(f"%{city_clean}%"),
ServiceStaging.postal_code == city_clean, ServiceStaging.postal_code == city_clean,
ServiceStaging.postal_code.ilike(f"{city_clean}%"), ServiceStaging.postal_code.ilike(f"%{city_clean}%"),
) )
) )
@@ -415,12 +417,12 @@ async def search_providers(
) )
crowd_conditions.append(and_(*token_conditions)) crowd_conditions.append(and_(*token_conditions))
if city: if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith # P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
city_clean = city.strip() city_clean = city.strip()
crowd_conditions.append( crowd_conditions.append(
or_( or_(
ServiceProvider.address == city_clean, ServiceProvider.address == city_clean,
ServiceProvider.address.ilike(f"{city_clean}%"), ServiceProvider.address.ilike(f"%{city_clean}%"),
) )
) )
@@ -532,8 +534,12 @@ async def search_providers(
row_city = row.city row_city = row.city
row_plus_code = getattr(row, "plus_code", None) row_plus_code = getattr(row, "plus_code", None)
# P0 BUGFIX (2026-07-10): Null-safe AddressOut construction.
# A Pydantic v2 from_attributes=True mode-ban néha hibát dob,
# ha minden mező None. Itt try-excepttel védjük.
address_detail = None address_detail = None
if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]): if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]):
try:
address_detail = AddressOut( address_detail = AddressOut(
zip=row_address_zip, zip=row_address_zip,
city=row_city, city=row_city,
@@ -542,6 +548,12 @@ async def search_providers(
house_number=row_address_house_number, house_number=row_address_house_number,
full_address_text=row.address, full_address_text=row.address,
) )
except Exception as addr_e:
logger.warning(
f"Failed to create AddressOut for provider {row.id} ({row.name}): {addr_e}. "
f"Skipping address_detail."
)
address_detail = None
results.append( results.append(
ProviderSearchResult( ProviderSearchResult(

View File

@@ -0,0 +1,53 @@
import asyncio, uuid
from app.database import AsyncSessionLocal
from app.models.identity.identity import User, UserRole, Wallet
from app.models.system.audit import SecurityAuditLog, FinancialLedger
from sqlalchemy import select, text
async def test():
uid = str(uuid.uuid4())[:8]
email = f'hard_delete_test_{uid}@example.com'
async with AsyncSessionLocal() as db:
# Insert test user
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, subscription_plan, preferred_language, region_code, preferred_currency, ui_mode, is_vip, is_active, is_deleted, created_at, scope_level, custom_permissions, alternative_emails, email_history, visual_settings)
VALUES
(:email, 'fakehash', 'USER', 'FREE', 'en', 'HU', 'HUF', 'personal', false, false, false, NOW(), 'individual', '{}'::jsonb, '[]'::jsonb, '[]'::jsonb, '{"theme": "default", "primary_color": null, "wall_logo_url": null}'::jsonb)
RETURNING id
"""),
{"email": email}
)
await db.commit()
result = await db.execute(select(User).where(User.email == email))
u = result.scalar_one_or_none()
test_id = u.id
print(f'Created test user #{test_id}: {u.email}')
# Verify no wallet/ledger
wr = await db.execute(select(Wallet).where(Wallet.user_id == test_id).limit(1))
lr = await db.execute(select(FinancialLedger).where(FinancialLedger.user_id == test_id).limit(1))
print(f' Has Wallet: {wr.scalar_one_or_none() is not None}')
print(f' Has Ledger: {lr.scalar_one_or_none() is not None}')
# Simulate the FIXED hard delete: delete first, then audit
await db.delete(u)
await db.flush()
audit = SecurityAuditLog(
actor_id=1, target_id=test_id, action='hard_delete_user',
is_critical=True,
payload_before={'user_id': test_id, 'email': email, 'role': 'USER'},
payload_after={'details': f'User #{test_id} permanently deleted by admin #1'},
)
db.add(audit)
await db.commit()
check = await db.get(User, test_id)
print(f' User after delete: {check}')
print('✅ Hard delete simulation SUCCESSFUL')
asyncio.run(test())

View File

@@ -0,0 +1,193 @@
# 🔍 P0 DISCOVERY — Provider Ingestion & Gamification Workflow Audit Report
**Dátum:** 2026-07-09
**Auditor:** Service Finder Rendszer-Architect
**Verzió:** 1.0
**Mód:** READ-ONLY — NO FILES MODIFIED
---
## 📋 Executive Summary
Ez az audit a teljes Provider Ingestion & Gamification Workflow-t vizsgálja. A vizsgálat 4 audit területet fed le.
---
## 1. 🟢 AUDIT: Deduplication & Matching — STÁTUSZ: READY
### READY ✅
| Komponens | Fájl | Státusz |
|-----------|------|---------|
| Provider name exact match (ILIKE) | `backend/app/services/provider_service.py` (line 125) | ✅ READY |
| Provider name fuzzy match (pg_trgm, threshold 0.6) | `backend/app/services/provider_service.py` (line 163) | ✅ READY |
| find_or_create_provider_by_name() returns (provider, created) tuple | `backend/app/services/provider_service.py` (line 125) | ✅ READY |
| Address dedup (postal_code_id + street_name + house_number) | `backend/app/services/address_manager.py` (line 145) | ✅ READY |
| Union ALL search (3 sources) | `backend/app/services/provider_service.py` (line 215) | ✅ READY |
### PARTIAL 🟡
| Komponens | Státusz |
|-----------|---------|
| Tax number dedup for providers | 🟡 PARTIAL — Only exists for Organizations, not ServiceProviders |
| Combined matching (name + address + tax_number) | 🟡 PARTIAL — Name-only dedup exists |
### MISSING ❌
| Komponens | Státusz |
|-----------|---------|
| Dedup by address for providers | ❌ MISSING — Name-only matching, no geo/address dedup for ServiceProvider |
---
## 2. 🟢 AUDIT: Gamification & Rules Engine — STÁTUSZ: PARTIAL
### READY ✅
| Komponens | Fájl | Státusz |
|-----------|------|---------|
| Dynamic point rules (26 rules in DB) | `gamification.point_rules` table | ✅ READY |
| GamificationService.process_activity() | `backend/app/services/gamification_service.py` (line 74) | ✅ READY |
| Penalty system (4 levels L0-L3) | `backend/app/services/gamification_service.py` (line 226) | ✅ READY |
| Level-up logic | `backend/app/services/gamification_service.py` (line 258) | ✅ READY |
| Social points → credits conversion | `backend/app/services/gamification_service.py` (line 265) | ✅ READY |
| ADD_NEW_PROVIDER (500 XP) awarded | `backend/app/services/provider_service.py` (line 63) | ✅ READY |
| PROVIDER_DISCOVERY (300 XP) awarded in expense hook | `backend/app/api/v1/endpoints/expenses.py` (line 568) | ✅ READY |
| Admin approve → ADD_NEW_PROVIDER XP | `backend/app/api/v1/endpoints/admin_providers.py` (line 729) | ✅ READY |
| Admin gamification API endpoints (full CRUD) | `backend/app/api/v1/endpoints/admin_gamification.py` | ✅ READY |
| User-facing gamification endpoints | `backend/app/api/v1/endpoints/gamification.py` | ✅ READY |
### PARTIAL 🟡
| Komponens | Státusz |
|-----------|---------|
| PROVIDER_CONFIRMATION (150 XP) | 🟡 Defined in DB but NEVER awarded |
| PROVIDER_VERIFIED_USE (100 XP) | 🟡 Defined in DB but NEVER awarded |
| USE_UNVERIFIED_PROVIDER (200 XP) | 🟡 Defined in DB but NEVER awarded |
| RATE_PROVIDER (250 XP) | 🟡 Defined in DB but NEVER awarded |
| Subscription-level gamification multiplier | 🟡 No field exists in subscription_tiers |
### MISSING ❌
| Komponens | Státusz |
|-----------|---------|
| `social_service.py` hardcoded XP (50) — `process_activity(db, user_id, 50, 10, ...)` should be dynamic | ❌ MISSING |
| `provider_validations` table — Masterbook spec, NEVER created | ❌ MISSING |
---
## 3. 🟢 AUDIT: AI / Invoice OCR — STÁTUSZ: PARTIAL
### READY ✅
| Komponens | Fájl | Státusz |
|-----------|------|---------|
| OCR Robot (OCRRobot class) | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 19) | ✅ READY |
| AIService.process_ocr_document() | `backend/app/services/ai_service.py` (line 174) | ✅ READY |
| Vision model support (Llama Vision) | `backend/app/services/ai_service.py` (line 48) | ✅ READY |
| Fallback AI (Gemini/Groq) | `backend/app/services/ai_service.py` (line 81) | ✅ READY |
| Trust Matching by tax_number | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 101) | ✅ READY |
| OCR save to Document.ocr_data (JSONB) | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 122) | ✅ READY |
| PREMIUM/VIP user filtering | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 42) | ✅ READY |
### MISSING ❌
| Komponens | Státusz |
|-----------|---------|
| AI prompt templates for invoice extraction (ai_prompt_ocr_*) | ❌ KRITIKUS — 0 rows in system_parameters |
| OCR → service_staging pipeline | ❌ KRITIKUS — OCR saves to Document.ocr_data only, NEVER feeds into staging |
| OCR → gamification hook | ❌ MISSING — No XP awarded for OCR-based provider discovery |
---
## 4. 🟢 AUDIT: Admin Configuration UI — STÁTUSZ: READY
### READY ✅
| Komponens | Fájl | Státusz |
|-----------|------|---------|
| Providers list page (stats, filters, search, pagination) | `frontend_admin/pages/providers/index.vue` | ✅ READY |
| Providers pending queue (approve/reject) | `frontend_admin/pages/providers/pending.vue` | ✅ READY |
| Providers rejected queue (restore) | `frontend_admin/pages/providers/rejected.vue` | ✅ READY |
| Point rules CRUD (inline editing) | `frontend_admin/pages/gamification/point-rules.vue` | ✅ READY |
| Master config editor (XP, penalty, conversion) | `frontend_admin/pages/gamification/config.vue` | ✅ READY |
| Validation rules editor | `frontend_admin/pages/gamification/validation-rules.vue` | ✅ READY |
| System parameters editor | `frontend_admin/pages/gamification/parameters.vue` | ✅ READY |
| Badges, Competitions, Leaderboard, Levels, Seasons, Users | `frontend_admin/pages/gamification/` | ✅ READY |
| Admin Provider API (full endpoint suite) | `backend/app/api/v1/endpoints/admin_providers.py` | ✅ READY |
| Promotion Engine (staging → live) | `backend/app/api/v1/endpoints/admin_providers.py` (line 1399) | ✅ READY |
---
## 5. 📊 END-TO-END WORKFLOW STATUS MATRIX
```
User uploads invoice
├── ✅ Step 1: OCR document created (pending_ocr)
├── ❌ Step 2: AI prompt templates exist? → NEM (0 rows in system_parameters)
│ └── ⚠️ OCR Robot uses fallback prompt
├── 🟡 Step 3: AIService extracts provider data
│ ├── ✅ Trust Matching by tax_number
│ └── ❌ NO mapping to service_staging or service_providers
├── ❌ Step 4: OCR output → service_staging → NINCS KAPCSOLAT
├── ✅ Step 5: User creates expense with external_vendor_name
│ └── ✅ PROVIDER_DISCOVERY hook fires (300 XP)
├── 🟡 Step 6: Provider status-based XP
│ ├── ✅ ADD_NEW_PROVIDER (500 XP)
│ ├── 🟡 PROVIDER_DISCOVERY (300 XP) — only for new providers
│ ├── ❌ PROVIDER_CONFIRMATION (150 XP) — NEVER
│ ├── ❌ PROVIDER_VERIFIED_USE (100 XP) — NEVER
│ ├── ❌ USE_UNVERIFIED_PROVIDER (200 XP) — NEVER
│ └── ❌ RATE_PROVIDER (250 XP) — NEVER
├── ✅ Step 7: Admin moderation (approve/reject/flag/restore)
│ └── ✅ Promotion Engine (staging → live)
├── ✅ Step 8: Admin UI for all gamification config
└── ❌ Step 9: Subscription-tier multiplier → NINCS
```
---
## 6. 🔴 KRITIKUS HIÁNYOSSÁGOK (P0 Priority)
| # | Hiányosság | Hatás |
|---|-----------|-------|
| **P0-1** | AI prompt sablonok hiányoznak (`ai_prompt_ocr_*`) | OCR Robot nem tud pontos adatot kinyerni |
| **P0-2** | OCR → service_staging pipeline hiányzik | OCR-ből kinyert provider adatok NEM kerülnek moderációs várólistába |
| **P0-3** | 5 pontszabály sosem kerül kiosztásra | PROVIDER_CONFIRMATION, PROVIDER_VERIFIED_USE, USE_UNVERIFIED_PROVIDER, RATE_PROVIDER |
| **P0-4** | Subscription tiers-ben nincs gamification multiplier | Előfizetési csomag nem befolyásolja a pontszerzést |
| **P0-5** | `social_service.py` hardcoded 50 XP | Nem a dinamikus pontszabályt használja |
---
## 7. 🟡 JAVASLATOK (P1 Priority)
| # | Javaslat |
|---|----------|
| P1-1 | AI prompt sablonok seedelése (`ai_prompt_ocr_invoice`, `ai_prompt_ocr_provider`) |
| P1-2 | OCR → staging pipeline megépítése |
| P1-3 | Pontszabályok bekötése a `find_or_create_provider_by_name()`-be |
| P1-4 | RATE_PROVIDER bekötése a `vote_for_provider()` metódusba |
| P1-5 | Subscription-tier multiplier hozzáadása a `rules` JSONB-hez |
---
## 8. 📂 Korábbi Auditok Állapota
| Audit | Dátum | Státusz most |
|-------|-------|-------------|
| `p0_discovery_provider_validation_audit.md` | 2026-07-07 | Több GAP (3 párhuzamos scoring rendszer) továbbra is fennáll |
| `p0_service_provider_gamification_audit_report.md` | 2026-06-30 | CRITICAL GAP (admin_providers.py hiányzott) → **MEGOLDVA** (implementálva 2026-07-07) |
| `gamification_provider_connection_analysis.md` | 2026-06-30 | 5 pontszabály nem volt bekötve → PROVIDER_DISCOVERY **MEGOLDVA**, a többi 4 továbbra is hiányzik |
---
*Report generated by Service Finder Rendszer-Architect (DeepSeek Reasoner)*

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"id":"dev","timestamp":1783475444387} {"id":"dev","timestamp":1783500293953}

View File

@@ -1 +1 @@
{"id":"dev","timestamp":1783475444387,"prerendered":[]} {"id":"dev","timestamp":1783500293953,"prerendered":[]}

View File

@@ -1,5 +1,5 @@
{ {
"date": "2026-07-08T01:50:52.058Z", "date": "2026-07-08T11:00:15.496Z",
"preset": "nitro-dev", "preset": "nitro-dev",
"framework": { "framework": {
"name": "nuxt", "name": "nuxt",
@@ -11,7 +11,7 @@
"dev": { "dev": {
"pid": 19, "pid": 19,
"workerAddress": { "workerAddress": {
"socketPath": "\u0000nitro-worker-19-1-1-173.sock" "socketPath": "\u0000nitro-worker-19-3-3-6895.sock"
} }
} }
} }

View File

@@ -1,8 +1,8 @@
/// <reference types="@nuxtjs/tailwindcss" /> /// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@nuxt/devtools" />
/// <reference types="@nuxt/telemetry" />
/// <reference types="@pinia/nuxt" />
/// <reference types="@nuxtjs/i18n" /> /// <reference types="@nuxtjs/i18n" />
/// <reference types="@pinia/nuxt" />
/// <reference types="@nuxt/telemetry" />
/// <reference types="@nuxt/devtools" />
/// <reference path="types/nitro-layouts.d.ts" /> /// <reference path="types/nitro-layouts.d.ts" />
/// <reference path="types/builder-env.d.ts" /> /// <reference path="types/builder-env.d.ts" />
/// <reference types="nuxt" /> /// <reference types="nuxt" />

View File

@@ -1,4 +1,4 @@
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/8/2026, 1:50:44 AM // generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/8/2026, 11:05:19 AM
import "@nuxtjs/tailwindcss/config-ctx" import "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger"; import configMerger from "@nuxtjs/tailwindcss/merger";

View File

@@ -1,35 +0,0 @@
{
"title": "Badges",
"subtitle": "Badge Management — CRUD",
"loading": "Loading badges...",
"error": "Failed to load badges.",
"retry": "Retry",
"create": "New Badge",
"no_items": "No badges yet.",
"id": "ID",
"name": "Name",
"description": "Description",
"icon": "Icon",
"actions": "Actions",
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"create_title": "Create Badge",
"edit_title": "Edit Badge",
"name_placeholder": "Badge name",
"desc_placeholder": "Badge description",
"icon_placeholder": "Icon URL",
"icon_hint": "Optional. The icon URL for the badge.",
"icon_preview": "Icon preview",
"create_btn": "Create",
"delete_title": "Delete Badge",
"delete_confirm": "Are you sure you want to delete this badge?",
"delete_btn": "Delete",
"created": "Badge created!",
"updated": "Badge updated!",
"deleted": "Badge deleted!",
"save_error": "Failed to save badge.",
"delete_error": "Failed to delete badge.",
"unknown_error": "Unknown error"
}

View File

@@ -1,38 +0,0 @@
{
"title": "Competitions",
"subtitle": "Seasonal competition management",
"loading": "Loading competitions...",
"error": "Failed to load competitions.",
"retry": "Retry",
"create": "New Competition",
"no_items": "No competitions created yet.",
"create_first": "Create First Competition",
"season_filter": "Season Filter:",
"all_seasons": "All Seasons",
"active_badge": "(Active)",
"status_active": "Active",
"status_draft": "Draft",
"status_completed": "Completed",
"status_cancelled": "Cancelled",
"view_rules": "View Rules",
"edit": "Edit",
"cancel": "Cancel",
"save": "Save",
"create_title": "Create Competition",
"edit_title": "Edit Competition",
"name": "Name",
"name_placeholder": "e.g. Q1 Service Submission Contest",
"description": "Description",
"desc_placeholder": "Competition description...",
"season": "Season",
"select_season": "Select a season",
"start_date": "Start Date",
"end_date": "End Date",
"status": "Status",
"rules": "Rules (JSON)",
"rules_placeholder": "type: service_submission, points_multiplier: 1.5",
"invalid_json": "Invalid JSON format!",
"create_btn": "Create",
"season_id_label": "Season ID:",
"save_error": "Failed to save competition."
}

View File

@@ -1,26 +0,0 @@
{
"title": "System Config",
"subtitle": "Gamification master configuration editor",
"loading": "Loading configuration...",
"error": "Failed to load configuration.",
"last_save": "Last saved:",
"saving": "Saving...",
"save": "Save Configuration",
"section_xp": "XP Logic",
"section_conversion": "Conversion Logic",
"section_penalty": "Penalty Logic",
"section_rewards": "Level Rewards",
"calculator": "Impact Calculator",
"calculator_desc": "Adjust parameters to see estimated impact",
"estimated_results": "Estimated Results",
"daily_xp": "Daily XP",
"xp_for_level": "XP Needed for Level Up",
"social_to_credit": "Social → Credit",
"days_to_level": "Days to Level Up",
"raw_json": "Raw JSON Editor",
"apply_json": "Apply JSON",
"updated": "Configuration updated!",
"save_error": "Failed to save configuration.",
"json_applied": "JSON applied to form fields!",
"invalid_json": "Invalid JSON: "
}

View File

@@ -1,4 +1,5 @@
{ {
"dashboard": {
"title": "Gamification HQ", "title": "Gamification HQ",
"subtitle": "Gamification system overview dashboard", "subtitle": "Gamification system overview dashboard",
"loading": "Loading dashboard...", "loading": "Loading dashboard...",
@@ -20,4 +21,5 @@
"level": "Level", "level": "Level",
"xp": "XP", "xp": "XP",
"points": "points" "points": "points"
}
} }

View File

@@ -0,0 +1,304 @@
{
"gamification": {
"badges": {
"title": "Badges",
"subtitle": "Badge Management — CRUD",
"loading": "Loading badges...",
"error": "Failed to load badges.",
"retry": "Retry",
"create": "New Badge",
"no_items": "No badges yet.",
"id": "ID",
"name": "Name",
"description": "Description",
"icon": "Icon",
"actions": "Actions",
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"create_title": "Create Badge",
"edit_title": "Edit Badge",
"name_placeholder": "Badge name",
"desc_placeholder": "Badge description",
"icon_placeholder": "Icon URL",
"icon_hint": "Optional. The icon URL for the badge.",
"icon_preview": "Icon preview",
"create_btn": "Create",
"delete_title": "Delete Badge",
"delete_confirm": "Are you sure you want to delete this badge?",
"delete_btn": "Delete",
"created": "Badge created!",
"updated": "Badge updated!",
"deleted": "Badge deleted!",
"save_error": "Failed to save badge.",
"delete_error": "Failed to delete badge.",
"unknown_error": "Unknown error"
},
"levels": {
"title": "Levels",
"subtitle": "Level configuration management — CRUD",
"loading": "Loading levels...",
"error": "Failed to load level configurations.",
"retry": "Retry",
"create": "New Level",
"no_items": "No level configurations yet.",
"level": "Level",
"rank": "Rank",
"min_points": "Min Points",
"type": "Type",
"actions": "Actions",
"penalty": "Penalty",
"normal": "Normal",
"tree_diagram": "Level Tree Diagram",
"no_tree_data": "No levels to display.",
"points": "points",
"edit_title": "Edit Level",
"create_title": "Create Level",
"level_number": "Level Number",
"rank_name": "Rank Name",
"min_points_label": "Minimum Points",
"is_penalty": "Penalty Level",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"updated": "Level updated!",
"created": "Level created!",
"duplicate_level": "A configuration with this level number already exists.",
"save_error": "Failed to save level."
},
"point_rules": {
"title": "Point Rules",
"subtitle": "Gamification point rules management — CRUD",
"loading": "Loading point rules...",
"error": "Failed to load point rules.",
"retry": "Retry",
"create": "New Point Rule",
"no_items": "No point rules yet. Create one with the button above!",
"id": "ID",
"action_key": "Action Key",
"points": "Points",
"description": "Description",
"status": "Status",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"edit": "Edit",
"delete": "Delete (soft-delete)",
"edit_title": "Edit Point Rule",
"create_title": "New Point Rule",
"action_key_label": "Action Key",
"action_key_placeholder": "e.g. service_submitted",
"points_label": "Point Value",
"points_placeholder": "0",
"description_label": "Description",
"description_placeholder": "Short description of the rule...",
"is_active": "Active",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"updated": "Point rule updated!",
"created": "Point rule created!",
"deleted": "Point rule deactivated!",
"duplicate_key": "A rule with this action key already exists.",
"save_error": "Failed to save point rule.",
"delete_title": "Delete Point Rule",
"delete_confirm": "Are you sure you want to deactivate the rule",
"delete_btn": "Delete",
"deleting": "Deleting..."
},
"seasons": {
"title": "Seasons",
"subtitle": "Gamification season management",
"loading": "Loading seasons...",
"error": "Failed to load seasons.",
"retry": "Retry",
"create": "New Season",
"no_items": "No seasons created yet.",
"create_first": "Create First Season",
"active": "Active",
"inactive": "Inactive",
"created_at": "Created:",
"activate": "Activate",
"edit": "Edit",
"edit_title": "Edit Season",
"create_title": "New Season",
"name": "Name",
"name_placeholder": "e.g. 2026 Q1 Season",
"start_date": "Start Date",
"end_date": "End Date",
"activate_on_create": "Activate on creation",
"cancel": "Cancel",
"save": "Save",
"create_btn": "Create",
"activate_title": "Activate Season",
"activate_confirm": "Are you sure you want to activate",
"activate_warning": "This will deactivate all other seasons.",
"activating": "Activating...",
"activate_btn": "Activate",
"save_error": "Failed to save season."
},
"competitions": {
"title": "Competitions",
"subtitle": "Seasonal competition management",
"loading": "Loading competitions...",
"error": "Failed to load competitions.",
"retry": "Retry",
"create": "New Competition",
"no_items": "No competitions created yet.",
"create_first": "Create First Competition",
"season_filter": "Season Filter:",
"all_seasons": "All Seasons",
"active_badge": "(Active)",
"status_active": "Active",
"status_draft": "Draft",
"status_completed": "Completed",
"status_cancelled": "Cancelled",
"view_rules": "View Rules",
"edit": "Edit",
"cancel": "Cancel",
"save": "Save",
"create_title": "Create Competition",
"edit_title": "Edit Competition",
"name": "Name",
"name_placeholder": "e.g. Q1 Service Submission Contest",
"description": "Description",
"desc_placeholder": "Competition description...",
"season": "Season",
"select_season": "Select a season",
"start_date": "Start Date",
"end_date": "End Date",
"status": "Status",
"rules": "Rules (JSON)",
"rules_placeholder": "type: service_submission, points_multiplier: 1.5",
"invalid_json": "Invalid JSON format!",
"create_btn": "Create",
"season_id_label": "Season ID:",
"save_error": "Failed to save competition."
},
"leaderboard": {
"title": "Leaderboard",
"subtitle": "Gamification leaderboard admin view",
"search": "Search",
"level": "Level",
"min_xp": "Min XP",
"sort": "Sort",
"sort_xp": "By XP",
"sort_points": "By Points",
"sort_social": "By Social Points",
"sort_level": "By Level",
"filter": "Filter",
"reset": "Reset",
"descending": "Descending",
"ascending": "Ascending",
"user": "user",
"loading": "Loading leaderboard...",
"error": "Failed to load leaderboard.",
"retry": "Retry",
"no_results": "No results match your filters.",
"no_results_hint": "Try different filter criteria.",
"user_col": "User",
"email": "Email",
"xp": "XP",
"points": "Points",
"social": "Social",
"penalty": "Penalty",
"restriction": "Restriction",
"discoveries": "Discoveries",
"services": "Services",
"updated": "Last Updated",
"actions": "Actions",
"details": "Details",
"prev": "Previous",
"next": "Next",
"page_of": "/ page",
"restriction_mild": "Mild",
"restriction_moderate": "Moderate",
"restriction_severe": "Severe"
},
"users": {
"title": "Gamification Users",
"subtitle": "User gamification statistics management",
"loading": "Loading users...",
"error": "Failed to load user statistics.",
"retry": "Retry",
"no_results": "No user statistics to display.",
"level": "Level",
"all_levels": "All Levels",
"level_n": "Level {n}",
"min_xp": "Minimum XP",
"max_xp": "Maximum XP",
"penalty": "Penalty",
"any": "Any",
"only_penalized": "Only penalized",
"only_clean": "Only clean",
"filter": "Filter",
"reset": "Reset",
"count": "users",
"user_id": "User ID",
"xp": "XP",
"points": "Points",
"restriction": "Restriction",
"services": "Services",
"discoveries": "Discoveries",
"updated": "Last Updated",
"actions": "Actions",
"details": "Details",
"prev": "Previous",
"next": "Next",
"none": "None",
"restriction_mild": "Mild",
"restriction_moderate": "Moderate",
"restriction_severe": "Severe",
"user_detail": {
"back": "Back to list",
"title": "User Gamification Details",
"id_label": "ID:",
"loading": "Loading user...",
"error": "Failed to load user data.",
"retry": "Retry",
"not_found": "User not found.",
"total_xp": "Total XP",
"level": "Level",
"total_points": "Total Points",
"social_points": "Social Points",
"penalty_points": "Penalty Points",
"restriction_level": "Restriction Level",
"penalty_quota": "Penalty Quota",
"banned_until": "Banned Until",
"no": "No",
"services": "Services",
"discovered_places": "Discovered Places",
"validated_places": "Validated Places",
"added_providers": "Added Providers",
"penalty_form_title": "Assign Penalty",
"penalty_points_label": "Penalty Points",
"penalty_reason_label": "Reason",
"penalty_reason_placeholder": "Reason for penalty...",
"penalty_submit": "Assign Penalty",
"penalty_submitting": "Submitting...",
"penalty_success": "Penalty successfully assigned: {amount} points.",
"penalty_error": "Failed to assign penalty.",
"reward_form_title": "Assign Reward",
"reward_xp_label": "XP Reward",
"reward_social_label": "Social Points Reward",
"reward_reason_label": "Reason",
"reward_reason_placeholder": "Reason for reward...",
"reward_submit": "Assign Reward",
"reward_submitting": "Submitting...",
"reward_success": "Reward successfully assigned: {xp} XP, {social} social points.",
"reward_error": "Failed to assign reward.",
"ledger_title": "Points Ledger",
"ledger_count": "entries",
"ledger_loading": "Loading...",
"ledger_empty": "No ledger entries for this user yet.",
"ledger_date": "Date",
"ledger_points": "Points",
"ledger_penalty": "Penalty",
"ledger_xp": "XP",
"ledger_reason": "Reason",
"ledger_source": "Source"
}
}
}
}

View File

@@ -1,49 +0,0 @@
{
"back": "Back to list",
"title": "User Gamification Details",
"id_label": "ID:",
"loading": "Loading user...",
"error": "Failed to load user data.",
"retry": "Retry",
"not_found": "User not found.",
"total_xp": "Total XP",
"level": "Level",
"total_points": "Total Points",
"social_points": "Social Points",
"penalty_points": "Penalty Points",
"restriction_level": "Restriction Level",
"penalty_quota": "Penalty Quota",
"banned_until": "Banned Until",
"no": "No",
"services": "Services",
"discovered_places": "Discovered Places",
"validated_places": "Validated Places",
"added_providers": "Added Providers",
"penalty_form_title": "Assign Penalty",
"penalty_points_label": "Penalty Points",
"penalty_reason_label": "Reason",
"penalty_reason_placeholder": "Reason for penalty...",
"penalty_submit": "Assign Penalty",
"penalty_submitting": "Submitting...",
"penalty_success": "Penalty successfully assigned: {amount} points.",
"penalty_error": "Failed to assign penalty.",
"reward_form_title": "Assign Reward",
"reward_xp_label": "XP Reward",
"reward_social_label": "Social Points Reward",
"reward_reason_label": "Reason",
"reward_reason_placeholder": "Reason for reward...",
"reward_submit": "Assign Reward",
"reward_submitting": "Submitting...",
"reward_success": "Reward successfully assigned: {xp} XP, {social} social points.",
"reward_error": "Failed to assign reward.",
"ledger_title": "Points Ledger",
"ledger_count": "entries",
"ledger_loading": "Loading...",
"ledger_empty": "No ledger entries for this user yet.",
"ledger_date": "Date",
"ledger_points": "Points",
"ledger_penalty": "Penalty",
"ledger_xp": "XP",
"ledger_reason": "Reason",
"ledger_source": "Source"
}

View File

@@ -1,35 +0,0 @@
{
"title": "Gamification Users",
"subtitle": "User gamification statistics management",
"loading": "Loading users...",
"error": "Failed to load user statistics.",
"retry": "Retry",
"no_results": "No user statistics to display.",
"level": "Level",
"all_levels": "All Levels",
"level_n": "Level {n}",
"min_xp": "Minimum XP",
"max_xp": "Maximum XP",
"penalty": "Penalty",
"any": "Any",
"only_penalized": "Only penalized",
"only_clean": "Only clean",
"filter": "Filter",
"reset": "Reset",
"count": "users",
"user_id": "User ID",
"xp": "XP",
"points": "Points",
"restriction": "Restriction",
"services": "Services",
"discoveries": "Discoveries",
"updated": "Last Updated",
"actions": "Actions",
"details": "Details",
"prev": "Previous",
"next": "Next",
"none": "None",
"restriction_mild": "Mild",
"restriction_moderate": "Moderate",
"restriction_severe": "Severe"
}

View File

@@ -1,24 +0,0 @@
{
"ltv": "Lifetime Value (LTV)",
"mrr": "Monthly Revenue (MRR)",
"monthly_recurring": "Monthly recurring revenue",
"outstanding": "Outstanding",
"all_settled": "All invoices settled",
"earned_credits": "Earned Credits",
"contribution_xp": "XP earned from platform contributions",
"invited_companies": "Invited Companies",
"companies_joined": "Companies joined via your invite",
"validated_garages": "Validated Garages",
"garages_confirmed": "Garages successfully verified",
"utilization": "Utilization",
"vehicle_quota": "Vehicle Quota",
"employee_quota": "Employee Quota",
"utilized": "utilized",
"marketplace_engagement": "Marketplace & Engagement",
"avg_rating": "Average Rating",
"reviews": "Reviews",
"reviews_count": "reviews",
"last_activity": "Last Activity",
"last_login_mock": "2 hours ago",
"services_30d": "Services Completed (30 days)"
}

View File

@@ -1,83 +0,0 @@
{
"loading": "Loading garage details...",
"retry": "Retry",
"back": "Back to garages",
"company_info": "Company Info",
"company_name": "Company Name",
"display_name": "Display Name",
"org_type": "Organization Type",
"tax_number": "Tax Number",
"reg_number": "Registration Number",
"created_at": "Created At",
"address": "Address",
"subscription_info": "Subscription Status",
"tier_name": "Package",
"tier_level": "Level",
"level_n": "Level {n}",
"expires_at": "Expires",
"asset_limit": "Vehicle Limit",
"vehicles": "vehicles",
"no_subscription": "No active subscription",
"contact_info": "Contact Info",
"contact_name": "Contact Name",
"contact_role": "Role",
"contact_department": "Department",
"contact_phone": "Contact Phone",
"no_contact": "No primary contact person",
"edit_details": "Edit Details",
"save": "Save",
"generalTab": "General",
"employeesTab": "Employees",
"fleetTab": "Fleet",
"analyticsTab": "Analytics",
"subscriptionTab": "Subscription",
"editBasicTab": "Basic Info",
"editContactTab": "Contact",
"editAddressTab": "Addresses",
"zip": "ZIP Code",
"city": "City",
"street_name": "Street",
"street_type": "Street Type",
"house_number": "House Number",
"coming_soon": "This feature will be available soon...",
"missing_data_title": "Missing Data",
"missing_data_desc": "The garage profile is incomplete. Please fill in the missing fields.",
"missing_data_activation_required": "To activate the garage, the following data must be provided:",
"missing_tax_number": "Tax Number",
"missing_billing": "Billing Address",
"missing_contact_email": "Contact Email",
"missing_contact_phone": "Contact Phone",
"missing_data_count": "{count} fields missing",
"contact_person_name": "Contact Person Name",
"contact_email": "Contact Email",
"billing_address": "Billing Address",
"billing_zip": "ZIP Code",
"billing_city": "City",
"billing_street_name": "Street",
"billing_street_type": "Street Type",
"billing_house_number": "House Number",
"notification_address": "Notification Address",
"notification_zip": "ZIP Code",
"notification_city": "City",
"notification_street_name": "Street",
"notification_street_type": "Street Type",
"notification_house_number": "House Number",
"tab_basic": "Basic Info",
"tab_contact": "Contact",
"tab_addresses": "Addresses",
"basic_info": "Basic Info",
"contact_section": "Contact Person",
"addresses_section": "Addresses",
"vehicles_used": "Vehicles",
"branches_used": "Branches",
"employees_used": "Employees",
"branches": "branches",
"employees": "employees",
"status": "Status",
"subscription_history": "Subscription History",
"history_date": "Date",
"history_package": "Package",
"history_action": "Action",
"history_status": "Status",
"no_subscription_history": "No subscription history yet."
}

View File

@@ -1,37 +0,0 @@
{
"title": "Employees",
"total_count": "{count} employees",
"name": "Name",
"email_phone": "Email / Phone",
"role": "Role",
"status": "Status",
"actions": "Actions",
"edit_role": "Edit",
"remove": "Remove",
"no_employees": "No employees assigned to this garage.",
"edit_role_title": "Edit Employee Role",
"editRoleTitle": "Edit Employee Role",
"editRoleFor": "Change role for {name}",
"cancel": "Cancel",
"save": "Save Changes",
"saveRole": "Save Role",
"saving": "Saving...",
"status_active": "Active",
"status_inactive": "Inactive",
"remove_title": "Remove Employee",
"removeTitle": "Remove Employee",
"remove_confirm": "Are you sure you want to remove \"{name}\" from this garage?",
"removeConfirm": "Are you sure you want to remove \"{name}\" from this garage?",
"remove_btn": "Remove",
"removing": "Removing...",
"add_employee": "Add Employee",
"addTitle": "Add New Employee",
"email": "Email Address",
"emailPlaceholder": "Enter email address...",
"roleMember": "Member",
"roleManager": "Manager",
"roleAdmin": "Admin",
"roleOwner": "Owner",
"add": "Add Employee",
"adding": "Adding..."
}

View File

@@ -1,13 +0,0 @@
{
"title": "Fleet",
"total_vehicles": "{count} vehicles total",
"plate": "Plate",
"brand": "Brand",
"model": "Model",
"brand_model": "Brand & Model",
"year": "Year",
"status": "Status",
"vin": "VIN",
"no_vehicles": "No vehicles assigned to this garage yet.",
"no_fleet_access": "You don't have permission to view fleet data."
}

View File

@@ -1,4 +1,5 @@
{ {
"garages": {
"title": "Garages CRM", "title": "Garages CRM",
"subtitle": "Garage list, package management and status changes", "subtitle": "Garage list, package management and status changes",
"total_garages": "Total Garages", "total_garages": "Total Garages",
@@ -49,5 +50,163 @@
"addon_vehicles_expiration": "Extra Vehicle Expiration", "addon_vehicles_expiration": "Extra Vehicle Expiration",
"addon_branches_expiration": "Extra Branch Expiration", "addon_branches_expiration": "Extra Branch Expiration",
"addon_users_expiration": "Extra Employee Expiration", "addon_users_expiration": "Extra Employee Expiration",
"addon_expiration_hint": "If empty, existing expiration dates remain." "addon_expiration_hint": "If empty, existing expiration dates remain.",
"details": {
"loading": "Loading garage details...",
"retry": "Retry",
"back": "Back to garages",
"company_info": "Company Info",
"company_name": "Company Name",
"display_name": "Display Name",
"org_type": "Organization Type",
"tax_number": "Tax Number",
"reg_number": "Registration Number",
"created_at": "Created At",
"address": "Address",
"subscription_info": "Subscription Status",
"tier_name": "Package",
"tier_level": "Level",
"level_n": "Level {n}",
"expires_at": "Expires",
"asset_limit": "Vehicle Limit",
"vehicles": "vehicles",
"no_subscription": "No active subscription",
"contact_info": "Contact Info",
"contact_name": "Contact Name",
"contact_role": "Role",
"contact_department": "Department",
"contact_phone": "Contact Phone",
"no_contact": "No primary contact person",
"edit_details": "Edit Details",
"save": "Save",
"generalTab": "General",
"employeesTab": "Employees",
"fleetTab": "Fleet",
"analyticsTab": "Analytics",
"subscriptionTab": "Subscription",
"editBasicTab": "Basic Info",
"editContactTab": "Contact",
"editAddressTab": "Addresses",
"zip": "ZIP Code",
"city": "City",
"street_name": "Street",
"street_type": "Street Type",
"house_number": "House Number",
"coming_soon": "This feature will be available soon...",
"missing_data_title": "Missing Data",
"missing_data_desc": "The garage profile is incomplete. Please fill in the missing fields.",
"missing_data_activation_required": "To activate the garage, the following data must be provided:",
"missing_tax_number": "Tax Number",
"missing_billing": "Billing Address",
"missing_contact_email": "Contact Email",
"missing_contact_phone": "Contact Phone",
"missing_data_count": "{count} fields missing",
"contact_person_name": "Contact Person Name",
"contact_email": "Contact Email",
"billing_address": "Billing Address",
"billing_zip": "ZIP Code",
"billing_city": "City",
"billing_street_name": "Street",
"billing_street_type": "Street Type",
"billing_house_number": "House Number",
"notification_address": "Notification Address",
"notification_zip": "ZIP Code",
"notification_city": "City",
"notification_street_name": "Street",
"notification_street_type": "Street Type",
"notification_house_number": "House Number",
"tab_basic": "Basic Info",
"tab_contact": "Contact",
"tab_addresses": "Addresses",
"basic_info": "Basic Info",
"contact_section": "Contact Person",
"addresses_section": "Addresses",
"vehicles_used": "Vehicles",
"branches_used": "Branches",
"employees_used": "Employees",
"branches": "branches",
"employees": "employees",
"status": "Status",
"subscription_history": "Subscription History",
"history_date": "Date",
"history_package": "Package",
"history_action": "Action",
"history_status": "Status",
"no_subscription_history": "No subscription history yet."
},
"employees": {
"title": "Employees",
"total_count": "{count} employees",
"name": "Name",
"email_phone": "Email / Phone",
"role": "Role",
"status": "Status",
"actions": "Actions",
"edit_role": "Edit",
"remove": "Remove",
"no_employees": "No employees assigned to this garage.",
"edit_role_title": "Edit Employee Role",
"editRoleTitle": "Edit Employee Role",
"editRoleFor": "Change role for {name}",
"cancel": "Cancel",
"save": "Save Changes",
"saveRole": "Save Role",
"saving": "Saving...",
"status_active": "Active",
"status_inactive": "Inactive",
"remove_title": "Remove Employee",
"removeTitle": "Remove Employee",
"remove_confirm": "Are you sure you want to remove \"{name}\" from this garage?",
"removeConfirm": "Are you sure you want to remove \"{name}\" from this garage?",
"remove_btn": "Remove",
"removing": "Removing...",
"add_employee": "Add Employee",
"addTitle": "Add New Employee",
"email": "Email Address",
"emailPlaceholder": "Enter email address...",
"roleMember": "Member",
"roleManager": "Manager",
"roleAdmin": "Admin",
"roleOwner": "Owner",
"add": "Add Employee",
"adding": "Adding..."
},
"fleet": {
"title": "Fleet",
"total_vehicles": "{count} vehicles total",
"plate": "Plate",
"brand": "Brand",
"model": "Model",
"brand_model": "Brand & Model",
"year": "Year",
"status": "Status",
"vin": "VIN",
"no_vehicles": "No vehicles assigned to this garage yet.",
"no_fleet_access": "You don't have permission to view fleet data."
},
"analytics": {
"ltv": "Lifetime Value (LTV)",
"mrr": "Monthly Revenue (MRR)",
"monthly_recurring": "Monthly recurring revenue",
"outstanding": "Outstanding",
"all_settled": "All invoices settled",
"earned_credits": "Earned Credits",
"contribution_xp": "XP earned from platform contributions",
"invited_companies": "Invited Companies",
"companies_joined": "Companies joined via your invite",
"validated_garages": "Validated Garages",
"garages_confirmed": "Garages successfully verified",
"utilization": "Utilization",
"vehicle_quota": "Vehicle Quota",
"employee_quota": "Employee Quota",
"utilized": "utilized",
"marketplace_engagement": "Marketplace & Engagement",
"avg_rating": "Average Rating",
"reviews": "Reviews",
"reviews_count": "reviews",
"last_activity": "Last Activity",
"last_login_mock": "2 hours ago",
"services_30d": "Services Completed (30 days)"
}
}
} }

View File

@@ -1,40 +0,0 @@
{
"title": "Leaderboard",
"subtitle": "Gamification leaderboard admin view",
"search": "Search",
"level": "Level",
"min_xp": "Min XP",
"sort": "Sort",
"sort_xp": "By XP",
"sort_points": "By Points",
"sort_social": "By Social Points",
"sort_level": "By Level",
"filter": "Filter",
"reset": "Reset",
"descending": "Descending",
"ascending": "Ascending",
"user": "user",
"loading": "Loading leaderboard...",
"error": "Failed to load leaderboard.",
"retry": "Retry",
"no_results": "No results match your filters.",
"no_results_hint": "Try different filter criteria.",
"user_col": "User",
"email": "Email",
"xp": "XP",
"points": "Points",
"social": "Social",
"penalty": "Penalty",
"restriction": "Restriction",
"discoveries": "Discoveries",
"services": "Services",
"updated": "Last Updated",
"actions": "Actions",
"details": "Details",
"prev": "Previous",
"next": "Next",
"page_of": "/ page",
"restriction_mild": "Mild",
"restriction_moderate": "Moderate",
"restriction_severe": "Severe"
}

View File

@@ -1,29 +0,0 @@
{
"title": "Points Ledger",
"subtitle": "Gamification points ledger browser",
"loading": "Loading ledger...",
"error": "Failed to load ledger.",
"user_id": "User ID",
"date_from": "Date From",
"date_to": "Date To",
"reason": "Reason",
"search": "Search",
"clear": "Clear",
"csv": "CSV",
"no_results": "No results match your filters.",
"no_results_hint": "Try different filter criteria.",
"id": "ID",
"user": "User",
"points": "Points",
"penalty": "Penalty",
"xp": "XP",
"source": "Source",
"date": "Date",
"prev": "Previous",
"next": "Next",
"of": "of",
"showing": "showing",
"filtered": "(filtered)",
"no_csv_data": "No data for CSV export.",
"csv_downloaded": "CSV download started."
}

View File

@@ -1,32 +0,0 @@
{
"title": "Levels",
"subtitle": "Level configuration management — CRUD",
"loading": "Loading levels...",
"error": "Failed to load level configurations.",
"retry": "Retry",
"create": "New Level",
"no_items": "No level configurations yet.",
"level": "Level",
"rank": "Rank",
"min_points": "Min Points",
"type": "Type",
"actions": "Actions",
"penalty": "Penalty",
"normal": "Normal",
"tree_diagram": "Level Tree Diagram",
"no_tree_data": "No levels to display.",
"points": "points",
"edit_title": "Edit Level",
"create_title": "Create Level",
"level_number": "Level Number",
"rank_name": "Rank Name",
"min_points_label": "Minimum Points",
"is_penalty": "Penalty Level",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"updated": "Level updated!",
"created": "Level created!",
"duplicate_level": "A configuration with this level number already exists.",
"save_error": "Failed to save level."
}

View File

@@ -1,4 +1,5 @@
{ {
"menu": {
"center": "Center", "center": "Center",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"providers": "Providers", "providers": "Providers",
@@ -32,4 +33,5 @@
"system": "System", "system": "System",
"permissions": "Permissions", "permissions": "Permissions",
"system_logs": "System Logs" "system_logs": "System Logs"
}
} }

View File

@@ -1,68 +0,0 @@
{
"title": "Package Management",
"subtitle": "Manage subscription packages and features — Free, Premium, Enterprise",
"loading": "Loading packages...",
"retry": "Retry",
"create_new": "New Package",
"no_packages": "No packages yet",
"no_packages_desc": "Create your first subscription package to get started.",
"create_first": "Create First Package",
"popular": "Popular",
"fallback_badge": "Fallback",
"trial_badge": "Trial",
"month": "mo",
"feature_flags": "Available Features",
"edit": "Edit",
"duplicate": "Duplicate",
"delete": "Delete",
"set_as_default": "Set as Default",
"set_as_default_success": "set as default package!",
"edit_title": "Edit Package",
"create_title": "Create Package",
"cancel": "Cancel",
"save": "Save Changes",
"create": "Create",
"save_success": "Package updated successfully!",
"delete_title": "Delete Package",
"delete_confirm": "Are you sure you want to delete \"{name}\"?",
"delete_btn": "Delete",
"allowances": "Allowances",
"max_vehicles": "Max Vehicles",
"max_garages": "Max Garages",
"max_users": "Max Users",
"monthly_credits": "Monthly Credits",
"tier_level": "Level",
"form_name": "System Name",
"form_name_placeholder": "e.g. premium_2024",
"form_display_name": "Display Name",
"form_display_name_placeholder": "e.g. Premium Plus",
"form_type": "Type",
"type_private": "Private",
"type_corporate": "Corporate",
"form_tier_level": "Tier Level",
"form_tier_level_hint": "Higher number = more features and higher rank",
"form_monthly_price": "Monthly Price",
"form_yearly_price": "Yearly Price",
"form_currency": "Currency",
"form_subtitle": "Subtitle / Description",
"form_subtitle_placeholder": "e.g. Ideal for small businesses",
"form_badge": "Badge Text",
"form_badge_placeholder": "e.g. Most Popular",
"form_is_custom": "Custom Package (not public)",
"singleton_rules": "Singleton Rules",
"form_is_default_fallback": "Default Fallback Package — expired subscriptions fall back to this",
"form_trial_days": "Trial Days on Signup",
"form_trial_days_hint": "New organizations get this many free days. Only one package can have > 0.",
"per_region_pricing": "Per-Region Pricing",
"smart_calculator": "Smart Price Calculator",
"calc_net_price": "Net Price",
"calc_vat_rate": "VAT Rate",
"calc_currency": "Currency",
"calc_net": "Net",
"calc_vat": "VAT",
"calc_gross": "Gross",
"calc_apply": "Apply to selected region",
"add_zone": "Add New Zone",
"add_zone_select": "Select country code...",
"add_zone_already_exists": "This region already exists in the package pricing!"
}

View File

@@ -1,26 +0,0 @@
{
"title": "System Parameters",
"subtitle": "Gamification category system parameters",
"loading": "Loading parameters...",
"error": "Failed to load system parameters.",
"retry": "Retry",
"no_items": "No gamification system parameters.",
"key": "Key",
"value": "Value",
"category": "Category",
"scope": "Scope",
"scope_id": "Scope ID",
"actions": "Actions",
"edit": "Edit",
"edit_title": "Edit Parameter",
"value_label": "Value",
"value_placeholder": "Parameter value (JSON or plain text)",
"json_detected": "🔹 JSON format detected",
"text_value": "🔸 Text value",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"invalid_json": "Invalid JSON format: ",
"save_error": "Failed to save parameter.",
"updated": "updated!"
}

View File

@@ -1,28 +0,0 @@
{
"title": "Permissions Management",
"subtitle": "Permission Matrix — Role-based access control",
"loading": "Loading permissions...",
"error_title": "Failed to load permissions",
"retry": "Retry",
"save_changes": "Save Changes",
"discard": "Discard",
"modified_count": "{count} changes",
"domain_filter": "Domain Filter (Rows)",
"role_filter": "Role Filter (Columns)",
"select_all": "Select All",
"deselect_all": "Deselect All",
"all_domains": "All",
"permission": "Permission",
"no_permissions": "No permissions in this domain.",
"current_tier": "Current Package",
"expires": "Expires",
"superadmin": "Superadmin",
"superadmin_desc": "Full system access — users, billing, robots and system configuration.",
"garages": "Garages",
"garages_desc": "Garage-level access — vehicle management, service booking, customer data.",
"feature_flags": "Feature Flags",
"feature_flags_desc": "Subscription features — based on your current plan ({tier}).",
"rbac_title": "RBAC Permission Matrix",
"rbac_desc": "Role-based access control — which roles can perform which admin actions.",
"action": "Action"
}

View File

@@ -1,110 +0,0 @@
{
"title": "Person Management",
"subtitle": "Person list, search, filters and details",
"loading": "Loading persons...",
"retry": "Retry",
"search_placeholder": "Search by name, phone or email...",
"all_ghost_statuses": "All ghost statuses",
"ghost_no": "Not ghost",
"ghost_yes": "Ghost",
"all_active_statuses": "All active statuses",
"active_yes": "Active",
"active_no": "Inactive",
"all_user_statuses": "All user statuses",
"has_user_yes": "Has user",
"has_user_no": "No user",
"all_merged_statuses": "All merged statuses",
"merged_no": "Not merged",
"merged_yes": "Merged",
"total_persons": "Total Persons",
"active_persons": "Active Persons",
"ghost_persons": "Ghost Persons",
"merged_persons": "Merged Persons",
"filtered_results": "Filtered results",
"name": "Name",
"phone": "Phone",
"birth_date": "Birth Date",
"status": "Status",
"users_count": "Users",
"active_user": "Active User",
"address": "Address",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"ghost": "Ghost",
"merged": "Merged",
"view": "View",
"no_results": "No persons match your search.",
"showing": "Showing",
"prev": "Previous",
"next": "Next",
"details": {
"loading": "Loading person details...",
"retry": "Retry",
"back": "Back to list",
"sales_agent": "Sales Agent",
"edit": "Edit",
"personal_info": "Personal Information",
"person_id": "Person ID",
"uuid": "UUID",
"last_name": "Last Name",
"first_name": "First Name",
"birth_place": "Place of Birth",
"mothers_name": "Mother's Name",
"mothers_last_name": "Mother's Last Name",
"mothers_first_name": "Mother's First Name",
"address": "Address",
"zip": "ZIP Code",
"city": "City",
"street": "Street",
"house_number": "House Number",
"stairwell": "Stairwell",
"floor": "Floor",
"door": "Door",
"parcel_id": "Parcel ID (HRSZ)",
"full_address": "Full Address",
"system_info": "System Information",
"is_sales_agent": "Sales Agent",
"lifetime_xp": "Lifetime XP",
"penalty_points": "Penalty Points",
"social_reputation": "Social Reputation",
"created_at": "Created At",
"updated_at": "Updated At",
"deleted_at": "Deleted At",
"identity_docs": "Identity Documents",
"ice_contact": "ICE Contact",
"connected_users": "Connected Users",
"users_count": "{count} user(s) connected",
"no_users": "No users connected to this person.",
"email": "Email",
"role": "Role",
"plan": "Plan",
"language": "Language",
"registered_at": "Registered At",
"memberships": "Memberships",
"memberships_count": "{count} membership(s)",
"no_memberships": "No memberships.",
"org_name": "Organization Name",
"org_type": "Type",
"org_role": "Role",
"member_status": "Status",
"verified": "Verified",
"joined_at": "Joined At",
"verified_yes": "Yes",
"verified_no": "No",
"owned_orgs": "Owned Organizations",
"owned_orgs_count": "{count} organization(s)",
"no_owned_orgs": "No owned organizations.",
"merge_history": "Merge History",
"no_merge_history": "No merge history.",
"merged_into": "Merged into",
"merged_at": "Merged At",
"edit_title": "Edit Person",
"status_section": "Status",
"is_active": "Active",
"is_ghost": "Ghost",
"cancel": "Cancel",
"saving": "Saving...",
"save": "Save"
}
}

View File

@@ -1,40 +0,0 @@
{
"title": "Point Rules",
"subtitle": "Gamification point rules management — CRUD",
"loading": "Loading point rules...",
"error": "Failed to load point rules.",
"retry": "Retry",
"create": "New Point Rule",
"no_items": "No point rules yet. Create one with the button above!",
"id": "ID",
"action_key": "Action Key",
"points": "Points",
"description": "Description",
"status": "Status",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"edit": "Edit",
"delete": "Delete (soft-delete)",
"edit_title": "Edit Point Rule",
"create_title": "New Point Rule",
"action_key_label": "Action Key",
"action_key_placeholder": "e.g. service_submitted",
"points_label": "Point Value",
"points_placeholder": "0",
"description_label": "Description",
"description_placeholder": "Short description of the rule...",
"is_active": "Active",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"updated": "Point rule updated!",
"created": "Point rule created!",
"deleted": "Point rule deactivated!",
"duplicate_key": "A rule with this action key already exists.",
"save_error": "Failed to save point rule.",
"delete_title": "Delete Point Rule",
"delete_confirm": "Are you sure you want to deactivate the rule",
"delete_btn": "Delete",
"deleting": "Deleting..."
}

View File

@@ -1,4 +1,5 @@
{ {
"providers": {
"title": "Providers", "title": "Providers",
"subtitle": "Provider listing, search and moderation", "subtitle": "Provider listing, search and moderation",
"loading": "Loading providers...", "loading": "Loading providers...",
@@ -277,4 +278,5 @@
"edit_raw_data_title": "Raw Data", "edit_raw_data_title": "Raw Data",
"edit_audit_trail_title": "Audit Trail", "edit_audit_trail_title": "Audit Trail",
"edit_rejection_reason_title": "Rejection Reason" "edit_rejection_reason_title": "Rejection Reason"
}
} }

View File

@@ -1,31 +0,0 @@
{
"title": "Seasons",
"subtitle": "Gamification season management",
"loading": "Loading seasons...",
"error": "Failed to load seasons.",
"retry": "Retry",
"create": "New Season",
"no_items": "No seasons created yet.",
"create_first": "Create First Season",
"active": "Active",
"inactive": "Inactive",
"created_at": "Created:",
"activate": "Activate",
"edit": "Edit",
"edit_title": "Edit Season",
"create_title": "New Season",
"name": "Name",
"name_placeholder": "e.g. 2026 Q1 Season",
"start_date": "Start Date",
"end_date": "End Date",
"activate_on_create": "Activate on creation",
"cancel": "Cancel",
"save": "Save",
"create_btn": "Create",
"activate_title": "Activate Season",
"activate_confirm": "Are you sure you want to activate",
"activate_warning": "This will deactivate all other seasons.",
"activating": "Activating...",
"activate_btn": "Activate",
"save_error": "Failed to save season."
}

View File

@@ -0,0 +1,181 @@
{
"system": {
"config": {
"title": "System Config",
"subtitle": "Gamification master configuration editor",
"loading": "Loading configuration...",
"error": "Failed to load configuration.",
"last_save": "Last saved:",
"saving": "Saving...",
"save": "Save Configuration",
"section_xp": "XP Logic",
"section_conversion": "Conversion Logic",
"section_penalty": "Penalty Logic",
"section_rewards": "Level Rewards",
"calculator": "Impact Calculator",
"calculator_desc": "Adjust parameters to see estimated impact",
"estimated_results": "Estimated Results",
"daily_xp": "Daily XP",
"xp_for_level": "XP Needed for Level Up",
"social_to_credit": "Social → Credit",
"days_to_level": "Days to Level Up",
"raw_json": "Raw JSON Editor",
"apply_json": "Apply JSON",
"updated": "Configuration updated!",
"save_error": "Failed to save configuration.",
"json_applied": "JSON applied to form fields!",
"invalid_json": "Invalid JSON: "
},
"params": {
"title": "System Parameters",
"subtitle": "Gamification category system parameters",
"loading": "Loading parameters...",
"error": "Failed to load system parameters.",
"retry": "Retry",
"no_items": "No gamification system parameters.",
"key": "Key",
"value": "Value",
"category": "Category",
"scope": "Scope",
"scope_id": "Scope ID",
"actions": "Actions",
"edit": "Edit",
"edit_title": "Edit Parameter",
"value_label": "Value",
"value_placeholder": "Parameter value (JSON or plain text)",
"json_detected": "🔹 JSON format detected",
"text_value": "🔸 Text value",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"invalid_json": "Invalid JSON format: ",
"save_error": "Failed to save parameter.",
"updated": "updated!"
},
"ledger": {
"title": "Points Ledger",
"subtitle": "Gamification points ledger browser",
"loading": "Loading ledger...",
"error": "Failed to load ledger.",
"user_id": "User ID",
"date_from": "Date From",
"date_to": "Date To",
"reason": "Reason",
"search": "Search",
"clear": "Clear",
"csv": "CSV",
"no_results": "No results match your filters.",
"no_results_hint": "Try different filter criteria.",
"id": "ID",
"user": "User",
"points": "Points",
"penalty": "Penalty",
"xp": "XP",
"source": "Source",
"date": "Date",
"prev": "Previous",
"next": "Next",
"of": "of",
"showing": "showing",
"filtered": "(filtered)",
"no_csv_data": "No data for CSV export.",
"csv_downloaded": "CSV download started."
},
"packages": {
"title": "Package Management",
"subtitle": "Manage subscription packages and features — Free, Premium, Enterprise",
"loading": "Loading packages...",
"retry": "Retry",
"create_new": "New Package",
"no_packages": "No packages yet",
"no_packages_desc": "Create your first subscription package to get started.",
"create_first": "Create First Package",
"popular": "Popular",
"fallback_badge": "Fallback",
"trial_badge": "Trial",
"month": "mo",
"feature_flags": "Available Features",
"edit": "Edit",
"duplicate": "Duplicate",
"delete": "Delete",
"set_as_default": "Set as Default",
"set_as_default_success": "set as default package!",
"edit_title": "Edit Package",
"create_title": "Create Package",
"cancel": "Cancel",
"save": "Save Changes",
"create": "Create",
"save_success": "Package updated successfully!",
"delete_title": "Delete Package",
"delete_confirm": "Are you sure you want to delete \"{name}\"?",
"delete_btn": "Delete",
"allowances": "Allowances",
"max_vehicles": "Max Vehicles",
"max_garages": "Max Garages",
"max_users": "Max Users",
"monthly_credits": "Monthly Credits",
"tier_level": "Level",
"form_name": "System Name",
"form_name_placeholder": "e.g. premium_2024",
"form_display_name": "Display Name",
"form_display_name_placeholder": "e.g. Premium Plus",
"form_type": "Type",
"type_private": "Private",
"type_corporate": "Corporate",
"form_tier_level": "Tier Level",
"form_tier_level_hint": "Higher number = more features and higher rank",
"form_monthly_price": "Monthly Price",
"form_yearly_price": "Yearly Price",
"form_currency": "Currency",
"form_subtitle": "Subtitle / Description",
"form_subtitle_placeholder": "e.g. Ideal for small businesses",
"form_badge": "Badge Text",
"form_badge_placeholder": "e.g. Most Popular",
"form_is_custom": "Custom Package (not public)",
"singleton_rules": "Singleton Rules",
"form_is_default_fallback": "Default Fallback Package — expired subscriptions fall back to this",
"form_trial_days": "Trial Days on Signup",
"form_trial_days_hint": "New organizations get this many free days. Only one package can have > 0.",
"per_region_pricing": "Per-Region Pricing",
"smart_calculator": "Smart Price Calculator",
"calc_net_price": "Net Price",
"calc_vat_rate": "VAT Rate",
"calc_currency": "Currency",
"calc_net": "Net",
"calc_vat": "VAT",
"calc_gross": "Gross",
"calc_apply": "Apply to selected region",
"add_zone": "Add New Zone",
"add_zone_select": "Select country code...",
"add_zone_already_exists": "This region already exists in the package pricing!"
},
"permissions": {
"title": "Permissions Management",
"subtitle": "Permission Matrix — Role-based access control",
"loading": "Loading permissions...",
"error_title": "Failed to load permissions",
"retry": "Retry",
"save_changes": "Save Changes",
"discard": "Discard",
"modified_count": "{count} changes",
"domain_filter": "Domain Filter (Rows)",
"role_filter": "Role Filter (Columns)",
"select_all": "Select All",
"deselect_all": "Deselect All",
"all_domains": "All",
"permission": "Permission",
"no_permissions": "No permissions in this domain.",
"current_tier": "Current Package",
"expires": "Expires",
"superadmin": "Superadmin",
"superadmin_desc": "Full system access — users, billing, robots and system configuration.",
"garages": "Garages",
"garages_desc": "Garage-level access — vehicle management, service booking, customer data.",
"feature_flags": "Feature Flags",
"feature_flags_desc": "Subscription features — based on your current plan ({tier}).",
"rbac_title": "RBAC Permission Matrix",
"rbac_desc": "Role-based access control — which roles can perform which admin actions.",
"action": "Action"
}
}
}

View File

@@ -1,52 +0,0 @@
{
"loading": "Loading user details...",
"retry": "Retry",
"back": "Back to Users",
"edit": "Edit User",
"edit_user": "Edit User",
"profile_tab": "Profile",
"memberships_tab": "Memberships",
"account_info": "Account Information",
"user_id": "User ID",
"registration_date": "Registration Date",
"last_login": "Last Login",
"ui_mode": "UI Mode",
"region": "Region",
"currency": "Currency",
"subscription_info": "Subscription Information",
"subscription_expires": "Expires At",
"scope_level": "Scope Level",
"scope_id": "Scope ID",
"max_vehicles": "Max Vehicles",
"max_garages": "Max Garages",
"personal_info": "Personal Information",
"last_name": "Last Name",
"first_name": "First Name",
"phone": "Phone",
"birth_place": "Birth Place",
"birth_date": "Birth Date",
"mothers_name": "Mother's Name",
"mothers_first_name": "Mother's First Name",
"address": "Address",
"zip": "ZIP Code",
"city": "City",
"street": "Street",
"house_number": "House Number",
"no_person_record": "No personal information record found for this user.",
"memberships": "Organization Memberships",
"memberships_count": "{count} membership(s)",
"no_memberships": "This user is not a member of any organization.",
"org_name": "Organization",
"org_role": "Role",
"member_status": "Status",
"joined_at": "Joined",
"verified": "Verified",
"verified_yes": "Yes",
"verified_no": "No",
"preferred_language": "Preferred Language",
"is_active": "Active",
"is_vip": "VIP",
"cancel": "Cancel",
"save": "Save Changes",
"saving": "Saving..."
}

View File

@@ -1,4 +1,5 @@
{ {
"users": {
"title": "User Management", "title": "User Management",
"subtitle": "User list, search, filters and bulk actions", "subtitle": "User list, search, filters and bulk actions",
"loading": "Loading users...", "loading": "Loading users...",
@@ -42,5 +43,179 @@
"no_results": "No users match your search.", "no_results": "No users match your search.",
"showing": "Showing", "showing": "Showing",
"prev": "Previous", "prev": "Previous",
"next": "Next" "next": "Next",
"details": {
"loading": "Loading user details...",
"retry": "Retry",
"back": "Back to Users",
"edit": "Edit User",
"edit_user": "Edit User",
"profile_tab": "Profile",
"memberships_tab": "Memberships",
"account_info": "Account Information",
"user_id": "User ID",
"registration_date": "Registration Date",
"last_login": "Last Login",
"ui_mode": "UI Mode",
"region": "Region",
"currency": "Currency",
"subscription_info": "Subscription Information",
"subscription_expires": "Expires At",
"scope_level": "Scope Level",
"scope_id": "Scope ID",
"max_vehicles": "Max Vehicles",
"max_garages": "Max Garages",
"personal_info": "Personal Information",
"last_name": "Last Name",
"first_name": "First Name",
"phone": "Phone",
"birth_place": "Birth Place",
"birth_date": "Birth Date",
"mothers_name": "Mother's Name",
"mothers_first_name": "Mother's First Name",
"address": "Address",
"zip": "ZIP Code",
"city": "City",
"street": "Street",
"house_number": "House Number",
"no_person_record": "No personal information record found for this user.",
"memberships": "Organization Memberships",
"memberships_count": "{count} membership(s)",
"no_memberships": "This user is not a member of any organization.",
"org_name": "Organization",
"org_role": "Role",
"member_status": "Status",
"joined_at": "Joined",
"verified": "Verified",
"verified_yes": "Yes",
"verified_no": "No",
"preferred_language": "Preferred Language",
"is_active": "Active",
"is_vip": "VIP",
"cancel": "Cancel",
"save": "Save Changes",
"saving": "Saving...",
"danger_zone": "Danger Zone",
"danger_zone_warning": "This action is irreversible. Once you delete this user, all their data will be permanently removed.",
"danger_zone_button": "Delete User Permanently",
"danger_zone_confirm_label": "Type the user's email to confirm:",
"danger_zone_confirm_placeholder": "Enter email address...",
"danger_zone_confirm_button": "I understand, permanently delete this user",
"danger_zone_cancel": "Cancel",
"danger_zone_success": "User has been permanently deleted.",
"danger_zone_error_financial": "Cannot delete: User has financial or production records.",
"danger_zone_error_not_superadmin": "Only Superadmins can perform hard delete operations.",
"danger_zone_error_generic": "Failed to delete user: {message}"
},
"persons": {
"title": "Person Management",
"subtitle": "Person list, search, filters and details",
"loading": "Loading persons...",
"retry": "Retry",
"search_placeholder": "Search by name, phone or email...",
"all_ghost_statuses": "All ghost statuses",
"ghost_no": "Not ghost",
"ghost_yes": "Ghost",
"all_active_statuses": "All active statuses",
"active_yes": "Active",
"active_no": "Inactive",
"all_user_statuses": "All user statuses",
"has_user_yes": "Has user",
"has_user_no": "No user",
"all_merged_statuses": "All merged statuses",
"merged_no": "Not merged",
"merged_yes": "Merged",
"total_persons": "Total Persons",
"active_persons": "Active Persons",
"ghost_persons": "Ghost Persons",
"merged_persons": "Merged Persons",
"filtered_results": "Filtered results",
"name": "Name",
"phone": "Phone",
"birth_date": "Birth Date",
"status": "Status",
"users_count": "Users",
"active_user": "Active User",
"address": "Address",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"ghost": "Ghost",
"merged": "Merged",
"view": "View",
"no_results": "No persons match your search.",
"showing": "Showing",
"prev": "Previous",
"next": "Next",
"details": {
"loading": "Loading person details...",
"retry": "Retry",
"back": "Back to list",
"sales_agent": "Sales Agent",
"edit": "Edit",
"personal_info": "Personal Information",
"person_id": "Person ID",
"uuid": "UUID",
"last_name": "Last Name",
"first_name": "First Name",
"birth_place": "Place of Birth",
"mothers_name": "Mother's Name",
"mothers_last_name": "Mother's Last Name",
"mothers_first_name": "Mother's First Name",
"address": "Address",
"zip": "ZIP Code",
"city": "City",
"street": "Street",
"house_number": "House Number",
"stairwell": "Stairwell",
"floor": "Floor",
"door": "Door",
"parcel_id": "Parcel ID (HRSZ)",
"full_address": "Full Address",
"system_info": "System Information",
"is_sales_agent": "Sales Agent",
"lifetime_xp": "Lifetime XP",
"penalty_points": "Penalty Points",
"social_reputation": "Social Reputation",
"created_at": "Created At",
"updated_at": "Updated At",
"deleted_at": "Deleted At",
"identity_docs": "Identity Documents",
"ice_contact": "ICE Contact",
"connected_users": "Connected Users",
"users_count": "{count} user(s) connected",
"no_users": "No users connected to this person.",
"email": "Email",
"role": "Role",
"plan": "Plan",
"language": "Language",
"registered_at": "Registered At",
"memberships": "Memberships",
"memberships_count": "{count} membership(s)",
"no_memberships": "No memberships.",
"org_name": "Organization Name",
"org_type": "Type",
"org_role": "Role",
"member_status": "Status",
"verified": "Verified",
"joined_at": "Joined At",
"verified_yes": "Yes",
"verified_no": "No",
"owned_orgs": "Owned Organizations",
"owned_orgs_count": "{count} organization(s)",
"no_owned_orgs": "No owned organizations.",
"merge_history": "Merge History",
"no_merge_history": "No merge history.",
"merged_into": "Merged into",
"merged_at": "Merged At",
"edit_title": "Edit Person",
"status_section": "Status",
"is_active": "Active",
"is_ghost": "Ghost",
"cancel": "Cancel",
"saving": "Saving...",
"save": "Save"
}
}
}
} }

View File

@@ -1,35 +0,0 @@
{
"title": "Kitüntetések",
"subtitle": "Badge-ek kezelése — CRUD",
"loading": "Kitüntetések betöltése...",
"error": "Nem sikerült betölteni a kitüntetéseket.",
"retry": "Újrapróbálkozás",
"create": "Új kitüntetés",
"no_items": "Még nincsenek kitüntetések.",
"id": "ID",
"name": "Név",
"description": "Leírás",
"icon": "Ikon",
"actions": "Műveletek",
"edit": "Szerkesztés",
"delete": "Törlés",
"cancel": "Mégse",
"save": "Mentés",
"create_title": "Kitüntetés létrehozása",
"edit_title": "Kitüntetés szerkesztése",
"name_placeholder": "Kitüntetés neve",
"desc_placeholder": "Kitüntetés leírása",
"icon_placeholder": "Ikon URL",
"icon_hint": "Opcionális. Az ikon URL-je a kitüntetéshez.",
"icon_preview": "Ikon előnézet",
"create_btn": "Létrehozás",
"delete_title": "Kitüntetés törlése",
"delete_confirm": "Biztosan törölni szeretnéd ezt a kitüntetést?",
"delete_btn": "Törlés",
"created": "Kitüntetés létrehozva!",
"updated": "Kitüntetés frissítve!",
"deleted": "Kitüntetés törölve!",
"save_error": "Nem sikerült menteni a kitüntetést.",
"delete_error": "Nem sikerült törölni a kitüntetést.",
"unknown_error": "Ismeretlen hiba"
}

View File

@@ -1,38 +0,0 @@
{
"title": "Versenyek",
"subtitle": "Szezonális versenyek kezelése",
"loading": "Versenyek betöltése...",
"error": "Nem sikerült betölteni a versenyeket.",
"retry": "Újrapróbálkozás",
"create": "Új Verseny",
"no_items": "Még nincsenek versenyek létrehozva.",
"create_first": "Első Verseny Létrehozása",
"season_filter": "Szezon szűrő:",
"all_seasons": "Összes szezon",
"active_badge": "(Aktív)",
"status_active": "Aktív",
"status_draft": "Draft",
"status_completed": "Befejezett",
"status_cancelled": "Törölt",
"view_rules": "Szabályok megtekintése",
"edit": "Szerkesztés",
"cancel": "Mégse",
"save": "Mentés",
"create_title": "Verseny létrehozása",
"edit_title": "Verseny szerkesztése",
"name": "Név",
"name_placeholder": "Pl. Q1 Szerviz Beküldő Verseny",
"description": "Leírás",
"desc_placeholder": "Verseny leírása...",
"season": "Szezon",
"select_season": "Válassz szezont",
"start_date": "Kezdő dátum",
"end_date": "Záró dátum",
"status": "Státusz",
"rules": "Szabályok (JSON)",
"rules_placeholder": "type: service_submission, points_multiplier: 1.5",
"invalid_json": "Érvénytelen JSON formátum!",
"create_btn": "Létrehozás",
"season_id_label": "Szezon ID:",
"save_error": "Hiba történt a mentés során."
}

View File

@@ -1,26 +0,0 @@
{
"title": "Rendszer Konfig",
"subtitle": "Gamification master konfiguráció szerkesztése",
"loading": "Konfiguráció betöltése...",
"error": "Nem sikerült betölteni a konfigurációt.",
"last_save": "Utolsó mentés:",
"saving": "Mentés...",
"save": "Konfiguráció mentése",
"section_xp": "XP Logika",
"section_conversion": "Konverziós Logika",
"section_penalty": "Büntetési Logika",
"section_rewards": "Szint Jutalmak",
"calculator": "Hatás Kalkulátor",
"calculator_desc": "Állítsd be a paramétereket a becsült hatás megtekintéséhez",
"estimated_results": "Becsült Eredmények",
"daily_xp": "Napi XP",
"xp_for_level": "Szintlépéshez Szükséges XP",
"social_to_credit": "Social → Credit",
"days_to_level": "Szintlépés Napok Száma",
"raw_json": "Nyers JSON Szerkesztő",
"apply_json": "JSON Alkalmazása",
"updated": "Konfiguráció frissítve!",
"save_error": "Hiba történt a mentés során.",
"json_applied": "JSON alkalmazva a form mezőkre!",
"invalid_json": "Érvénytelen JSON: "
}

View File

@@ -1,4 +1,5 @@
{ {
"dashboard": {
"title": "Gamification HQ", "title": "Gamification HQ",
"subtitle": "Gamification rendszer áttekintő irányítópultja", "subtitle": "Gamification rendszer áttekintő irányítópultja",
"loading": "Dashboard betöltése...", "loading": "Dashboard betöltése...",
@@ -20,4 +21,5 @@
"level": "Szint", "level": "Szint",
"xp": "XP", "xp": "XP",
"points": "pont" "points": "pont"
}
} }

View File

@@ -0,0 +1,304 @@
{
"gamification": {
"badges": {
"title": "Kitüntetések",
"subtitle": "Badge-ek kezelése — CRUD",
"loading": "Kitüntetések betöltése...",
"error": "Nem sikerült betölteni a kitüntetéseket.",
"retry": "Újrapróbálkozás",
"create": "Új kitüntetés",
"no_items": "Még nincsenek kitüntetések.",
"id": "ID",
"name": "Név",
"description": "Leírás",
"icon": "Ikon",
"actions": "Műveletek",
"edit": "Szerkesztés",
"delete": "Törlés",
"cancel": "Mégse",
"save": "Mentés",
"create_title": "Kitüntetés létrehozása",
"edit_title": "Kitüntetés szerkesztése",
"name_placeholder": "Kitüntetés neve",
"desc_placeholder": "Kitüntetés leírása",
"icon_placeholder": "Ikon URL",
"icon_hint": "Opcionális. Az ikon URL-je a kitüntetéshez.",
"icon_preview": "Ikon előnézet",
"create_btn": "Létrehozás",
"delete_title": "Kitüntetés törlése",
"delete_confirm": "Biztosan törölni szeretnéd ezt a kitüntetést?",
"delete_btn": "Törlés",
"created": "Kitüntetés létrehozva!",
"updated": "Kitüntetés frissítve!",
"deleted": "Kitüntetés törölve!",
"save_error": "Nem sikerült menteni a kitüntetést.",
"delete_error": "Nem sikerült törölni a kitüntetést.",
"unknown_error": "Ismeretlen hiba"
},
"levels": {
"title": "Szintek",
"subtitle": "Szint konfigurációk kezelése — CRUD",
"loading": "Szintek betöltése...",
"error": "Nem sikerült betölteni a szint konfigurációkat.",
"retry": "Újrapróbálkozás",
"create": "Új szint",
"no_items": "Még nincsenek szint konfigurációk.",
"level": "Szint",
"rank": "Rang",
"min_points": "Min. Pont",
"type": "Típus",
"actions": "Műveletek",
"penalty": "Büntető",
"normal": "Normál",
"tree_diagram": "Szint Fa Diagram",
"no_tree_data": "Nincsenek szintek megjelenítéshez.",
"points": "pont",
"edit_title": "Szint szerkesztése",
"create_title": "Szint létrehozása",
"level_number": "Szint Száma",
"rank_name": "Rang Neve",
"min_points_label": "Minimum Pont",
"is_penalty": "Büntető szint",
"cancel": "Mégse",
"save": "Mentés",
"saving": "Mentés...",
"updated": "Szint frissítve!",
"created": "Szint létrehozva!",
"duplicate_level": "Ezzel a szint számmal már létezik konfiguráció.",
"save_error": "Hiba történt a mentés során."
},
"point_rules": {
"title": "Pontszabályok",
"subtitle": "Gamification pontszabályok kezelése — CRUD",
"loading": "Pontszabályok betöltése...",
"error": "Nem sikerült betölteni a pontszabályokat.",
"retry": "Újrapróbálkozás",
"create": "Új pontszabály",
"no_items": "Még nincsenek pontszabályok. Hozz létre egyet a fenti gombbal!",
"id": "ID",
"action_key": "Akció Kulcs",
"points": "Pont",
"description": "Leírás",
"status": "Státusz",
"actions": "Műveletek",
"active": "Aktív",
"inactive": "Inaktív",
"edit": "Szerkesztés",
"delete": "Törlés (soft-delete)",
"edit_title": "Pontszabály szerkesztése",
"create_title": "Új pontszabály",
"action_key_label": "Akció Kulcs",
"action_key_placeholder": "pl. service_submitted",
"points_label": "Pontérték",
"points_placeholder": "0",
"description_label": "Leírás",
"description_placeholder": "Rövid leírás a szabályról...",
"is_active": "Aktív",
"cancel": "Mégse",
"save": "Mentés",
"saving": "Mentés...",
"updated": "Pontszabály frissítve!",
"created": "Pontszabály létrehozva!",
"deleted": "Pontszabály deaktiválva!",
"duplicate_key": "Egy szabály ezzel a kulccsal már létezik.",
"save_error": "Nem sikerült menteni a pontszabályt.",
"delete_title": "Pontszabály törlése",
"delete_confirm": "Biztosan deaktiválod a következő szabályt:",
"delete_btn": "Törlés",
"deleting": "Törlés..."
},
"seasons": {
"title": "Szezonok",
"subtitle": "Gamification szezonkezelés",
"loading": "Szezonok betöltése...",
"error": "Nem sikerült betölteni a szezonokat.",
"retry": "Újra",
"create": "Új Szezon",
"no_items": "Még nincsenek szezonok létrehozva.",
"create_first": "Első Szezon Létrehozása",
"active": "Aktív",
"inactive": "Inaktív",
"created_at": "Létrehozva:",
"activate": "Aktiválás",
"edit": "Szerkesztés",
"edit_title": "Szezon szerkesztése",
"create_title": "Új Szezon",
"name": "Név",
"name_placeholder": "pl. 2026 Q1 Szezon",
"start_date": "Kezdő dátum",
"end_date": "Záró dátum",
"activate_on_create": "Aktiválás létrehozáskor",
"cancel": "Mégse",
"save": "Mentés",
"create_btn": "Létrehozás",
"activate_title": "Szezon aktiválása",
"activate_confirm": "Biztosan aktiválni szeretnéd",
"activate_warning": "Ez deaktiválja az összes többi szezont.",
"activating": "Aktiválás...",
"activate_btn": "Aktiválás",
"save_error": "Nem sikerült menteni a szezont."
},
"competitions": {
"title": "Versenyek",
"subtitle": "Szezonális versenyek kezelése",
"loading": "Versenyek betöltése...",
"error": "Nem sikerült betölteni a versenyeket.",
"retry": "Újrapróbálkozás",
"create": "Új Verseny",
"no_items": "Még nincsenek versenyek létrehozva.",
"create_first": "Első Verseny Létrehozása",
"season_filter": "Szezon szűrő:",
"all_seasons": "Összes szezon",
"active_badge": "(Aktív)",
"status_active": "Aktív",
"status_draft": "Draft",
"status_completed": "Befejezett",
"status_cancelled": "Törölt",
"view_rules": "Szabályok megtekintése",
"edit": "Szerkesztés",
"cancel": "Mégse",
"save": "Mentés",
"create_title": "Verseny létrehozása",
"edit_title": "Verseny szerkesztése",
"name": "Név",
"name_placeholder": "Pl. Q1 Szerviz Beküldő Verseny",
"description": "Leírás",
"desc_placeholder": "Verseny leírása...",
"season": "Szezon",
"select_season": "Válassz szezont",
"start_date": "Kezdő dátum",
"end_date": "Záró dátum",
"status": "Státusz",
"rules": "Szabályok (JSON)",
"rules_placeholder": "type: service_submission, points_multiplier: 1.5",
"invalid_json": "Érvénytelen JSON formátum!",
"create_btn": "Létrehozás",
"season_id_label": "Szezon ID:",
"save_error": "Hiba történt a mentés során."
},
"leaderboard": {
"title": "Ranglista",
"subtitle": "Gamification ranglista admin nézet",
"search": "Keresés",
"level": "Szint",
"min_xp": "Minimum XP",
"sort": "Rendezés",
"sort_xp": "XP szerint",
"sort_points": "Pontok szerint",
"sort_social": "Szociális pontok szerint",
"sort_level": "Szint szerint",
"filter": "Szűrés",
"reset": "Visszaállítás",
"descending": "Csökkenő",
"ascending": "Növekvő",
"user": "felhasználó",
"loading": "Ranglista betöltése...",
"error": "Nem sikerült betölteni a ranglistát.",
"retry": "Újrapróbálkozás",
"no_results": "Nincs találat a megadott szűrőkkel.",
"no_results_hint": "Próbálj más szűrési feltételeket használni.",
"user_col": "Felhasználó",
"email": "Email",
"xp": "XP",
"points": "Pontok",
"social": "Szociális",
"penalty": "Büntetés",
"restriction": "Restrikció",
"discoveries": "Felfedezések",
"services": "Szolgáltatások",
"updated": "Utolsó módosítás",
"actions": "Műveletek",
"details": "Részletek",
"prev": "Előző",
"next": "Következő",
"page_of": "/ oldal",
"restriction_mild": "Enyhe",
"restriction_moderate": "Közepes",
"restriction_severe": "Súlyos"
},
"users": {
"title": "Gamification Felhasználók",
"subtitle": "Felhasználói gamification statisztikák kezelése",
"loading": "Felhasználók betöltése...",
"error": "Nem sikerült betölteni a felhasználói statisztikákat.",
"retry": "Újra",
"no_results": "Nincsenek megjeleníthető felhasználói statisztikák.",
"level": "Szint",
"all_levels": "Minden Szint",
"level_n": "{n}. szint",
"min_xp": "Minimum XP",
"max_xp": "Maximum XP",
"penalty": "Büntetés",
"any": "Bármelyik",
"only_penalized": "Csak büntetettek",
"only_clean": "Csak tiszták",
"filter": "Szűrés",
"reset": "Visszaállítás",
"count": "felhasználó",
"user_id": "Felhasználó ID",
"xp": "XP",
"points": "Pontok",
"restriction": "Korlátozás",
"services": "Szolgáltatások",
"discoveries": "Felfedezések",
"updated": "Utolsó frissítés",
"actions": "Műveletek",
"details": "Részletek",
"prev": "Előző",
"next": "Következő",
"none": "Nincs",
"restriction_mild": "Enyhe",
"restriction_moderate": "Mérsékelt",
"restriction_severe": "Súlyos",
"user_detail": {
"back": "Vissza a listához",
"title": "Felhasználó Gamification Részletei",
"id_label": "ID:",
"loading": "Felhasználó betöltése...",
"error": "Nem sikerült betölteni a felhasználói adatokat.",
"retry": "Újra",
"not_found": "Felhasználó nem található.",
"total_xp": "Összes XP",
"level": "Szint",
"total_points": "Összes Pont",
"social_points": "Szociális Pontok",
"penalty_points": "Büntető Pontok",
"restriction_level": "Korlátozás Szintje",
"penalty_quota": "Büntetési Kvóta",
"banned_until": "Kitiltva eddig:",
"no": "Nincs",
"services": "Szolgáltatások",
"discovered_places": "Felfedezett Helyek",
"validated_places": "Validált Helyek",
"added_providers": "Hozzáadott Szolgáltatók",
"penalty_form_title": "Büntetés Kiosztása",
"penalty_points_label": "Büntető Pontok",
"penalty_reason_label": "Indok",
"penalty_reason_placeholder": "Büntetés indoka...",
"penalty_submit": "Büntetés Kiosztása",
"penalty_submitting": "Küldés...",
"penalty_success": "Büntetés sikeresen kiosztva: {amount} pont.",
"penalty_error": "Nem sikerült kiosztani a büntetést.",
"reward_form_title": "Jutalom Kiosztása",
"reward_xp_label": "XP Jutalom",
"reward_social_label": "Szociális Pont Jutalom",
"reward_reason_label": "Indok",
"reward_reason_placeholder": "Jutalom indoka...",
"reward_submit": "Jutalom Kiosztása",
"reward_submitting": "Küldés...",
"reward_success": "Jutalom sikeresen kiosztva: {xp} XP, {social} szociális pont.",
"reward_error": "Nem sikerült kiosztani a jutalmat.",
"ledger_title": "Pont Napló",
"ledger_count": "bejegyzés",
"ledger_loading": "Betöltés...",
"ledger_empty": "Még nincsenek naplóbejegyzések ehhez a felhasználóhoz.",
"ledger_date": "Dátum",
"ledger_points": "Pontok",
"ledger_penalty": "Büntetés",
"ledger_xp": "XP",
"ledger_reason": "Indok",
"ledger_source": "Forrás"
}
}
}
}

View File

@@ -1,49 +0,0 @@
{
"back": "Vissza a listához",
"title": "Felhasználó Gamification Részletei",
"id_label": "ID:",
"loading": "Felhasználó betöltése...",
"error": "Nem sikerült betölteni a felhasználói adatokat.",
"retry": "Újra",
"not_found": "Felhasználó nem található.",
"total_xp": "Összes XP",
"level": "Szint",
"total_points": "Összes Pont",
"social_points": "Szociális Pontok",
"penalty_points": "Büntető Pontok",
"restriction_level": "Korlátozás Szintje",
"penalty_quota": "Büntetési Kvóta",
"banned_until": "Kitiltva eddig:",
"no": "Nincs",
"services": "Szolgáltatások",
"discovered_places": "Felfedezett Helyek",
"validated_places": "Validált Helyek",
"added_providers": "Hozzáadott Szolgáltatók",
"penalty_form_title": "Büntetés Kiosztása",
"penalty_points_label": "Büntető Pontok",
"penalty_reason_label": "Indok",
"penalty_reason_placeholder": "Büntetés indoka...",
"penalty_submit": "Büntetés Kiosztása",
"penalty_submitting": "Küldés...",
"penalty_success": "Büntetés sikeresen kiosztva: {amount} pont.",
"penalty_error": "Nem sikerült kiosztani a büntetést.",
"reward_form_title": "Jutalom Kiosztása",
"reward_xp_label": "XP Jutalom",
"reward_social_label": "Szociális Pont Jutalom",
"reward_reason_label": "Indok",
"reward_reason_placeholder": "Jutalom indoka...",
"reward_submit": "Jutalom Kiosztása",
"reward_submitting": "Küldés...",
"reward_success": "Jutalom sikeresen kiosztva: {xp} XP, {social} szociális pont.",
"reward_error": "Nem sikerült kiosztani a jutalmat.",
"ledger_title": "Pont Napló",
"ledger_count": "bejegyzés",
"ledger_loading": "Betöltés...",
"ledger_empty": "Még nincsenek naplóbejegyzések ehhez a felhasználóhoz.",
"ledger_date": "Dátum",
"ledger_points": "Pontok",
"ledger_penalty": "Büntetés",
"ledger_xp": "XP",
"ledger_reason": "Indok",
"ledger_source": "Forrás"
}

View File

@@ -1,35 +0,0 @@
{
"title": "Gamification Felhasználók",
"subtitle": "Felhasználói gamification statisztikák kezelése",
"loading": "Felhasználók betöltése...",
"error": "Nem sikerült betölteni a felhasználói statisztikákat.",
"retry": "Újra",
"no_results": "Nincsenek megjeleníthető felhasználói statisztikák.",
"level": "Szint",
"all_levels": "Minden Szint",
"level_n": "{n}. szint",
"min_xp": "Minimum XP",
"max_xp": "Maximum XP",
"penalty": "Büntetés",
"any": "Bármelyik",
"only_penalized": "Csak büntetettek",
"only_clean": "Csak tiszták",
"filter": "Szűrés",
"reset": "Visszaállítás",
"count": "felhasználó",
"user_id": "Felhasználó ID",
"xp": "XP",
"points": "Pontok",
"restriction": "Korlátozás",
"services": "Szolgáltatások",
"discoveries": "Felfedezések",
"updated": "Utolsó frissítés",
"actions": "Műveletek",
"details": "Részletek",
"prev": "Előző",
"next": "Következő",
"none": "Nincs",
"restriction_mild": "Enyhe",
"restriction_moderate": "Mérsékelt",
"restriction_severe": "Súlyos"
}

View File

@@ -1,24 +0,0 @@
{
"ltv": "Élettartam Érték (LTV)",
"mrr": "Havi Bevétel (MRR)",
"monthly_recurring": "Havi ismétlődő bevétel",
"outstanding": "Kintlévőség",
"all_settled": "Minden számla rendezve",
"earned_credits": "Szerzett Kreditek",
"contribution_xp": "Platform építésével gyűjtött XP",
"invited_companies": "Meghívott Cégek",
"companies_joined": "Cég csatlakozott a meghívásodra",
"validated_garages": "Validált Garázsok",
"garages_confirmed": "Garázs sikeresen hitelesítve",
"utilization": "Kihasználtság",
"vehicle_quota": "Jármű Kvóta",
"employee_quota": "Alkalmazotti Kvóta",
"utilized": "kihasználva",
"marketplace_engagement": "Piactér & Aktivitás",
"avg_rating": "Átlagos Értékelés",
"reviews": "Értékelések",
"reviews_count": "értékelés",
"last_activity": "Utolsó Aktivitás",
"last_login_mock": "2 órája",
"services_30d": "Elvégzett Szervizek (30 nap)"
}

View File

@@ -1,83 +0,0 @@
{
"loading": "Garázs adatainak betöltése...",
"retry": "Újra",
"back": "Vissza a garázsokhoz",
"company_info": "Cég adatok",
"company_name": "Cégnév",
"display_name": "Megjelenítési név",
"org_type": "Szervezet típusa",
"tax_number": "Adószám",
"reg_number": "Cégjegyzékszám",
"created_at": "Létrehozva",
"address": "Cím",
"subscription_info": "Előfizetés státusza",
"tier_name": "Csomag",
"tier_level": "Szint",
"level_n": "{n}. szint",
"expires_at": "Lejárat",
"asset_limit": "Járműkorlát",
"vehicles": "jármű",
"no_subscription": "Nincs aktív előfizetés",
"contact_info": "Elérhetőség",
"contact_name": "Kapcsolattartó neve",
"contact_role": "Szerepkör",
"contact_department": "Osztály",
"contact_phone": "Kapcsolattartó telefon",
"no_contact": "Nincs elsődleges kapcsolattartó",
"edit_details": "Adatok Szerkesztése",
"save": "Mentés",
"generalTab": "Általános",
"employeesTab": "Dolgozók",
"fleetTab": "Flotta",
"analyticsTab": "Analitika",
"subscriptionTab": "Előfizetés",
"editBasicTab": "Alapadatok",
"editContactTab": "Kapcsolat",
"editAddressTab": "Címek",
"zip": "Irányítószám",
"city": "Város",
"street_name": "Utca",
"street_type": "Utca típusa",
"house_number": "Házszám",
"coming_soon": "Ez a funkció hamarosan elérhető lesz...",
"missing_data_title": "Hiányzó adatok",
"missing_data_desc": "A garázs profilja hiányos. Kérjük, egészítse ki a hiányzó adatokat.",
"missing_data_activation_required": "A garázs aktiválásához az alábbi adatok pótlása kötelező:",
"missing_tax_number": "Adószám",
"missing_billing": "Számlázási cím",
"missing_contact_email": "Kapcsolattartó e-mail",
"missing_contact_phone": "Kapcsolattartó telefon",
"missing_data_count": "{count} mező hiányzik",
"contact_person_name": "Kapcsolattartó neve",
"contact_email": "Kapcsolattartó e-mail",
"billing_address": "Számlázási cím",
"billing_zip": "Irányítószám",
"billing_city": "Város",
"billing_street_name": "Utca",
"billing_street_type": "Utca típusa",
"billing_house_number": "Házszám",
"notification_address": "Értesítési cím",
"notification_zip": "Irányítószám",
"notification_city": "Város",
"notification_street_name": "Utca",
"notification_street_type": "Utca típusa",
"notification_house_number": "Házszám",
"tab_basic": "Alapadatok",
"tab_contact": "Kapcsolat",
"tab_addresses": "Címek",
"basic_info": "Alapadatok",
"contact_section": "Kapcsolattartó",
"addresses_section": "Címek",
"vehicles_used": "Járművek",
"branches_used": "Telephelyek",
"employees_used": "Dolgozók",
"branches": "telephely",
"employees": "dolgozó",
"status": "Státusz",
"subscription_history": "Előfizetési Előzmények",
"history_date": "Dátum",
"history_package": "Csomag",
"history_action": "Művelet",
"history_status": "Státusz",
"no_subscription_history": "Még nincsenek előfizetési előzmények."
}

View File

@@ -1,37 +0,0 @@
{
"title": "Dolgozók",
"total_count": "Összesen {count} fő",
"name": "Név",
"email_phone": "E-mail / Telefon",
"role": "Szerepkör",
"status": "Státusz",
"actions": "Műveletek",
"edit_role": "Szerepkör szerkesztése",
"remove": "Eltávolítás",
"no_employees": "Még nincsenek tagok ehhez a garázshoz.",
"edit_role_title": "Tag szerepkörének szerkesztése",
"editRoleTitle": "Tag szerepkörének szerkesztése",
"editRoleFor": "{name} szerepkörének módosítása",
"cancel": "Mégse",
"save": "Mentés",
"saveRole": "Szerepkör mentése",
"saving": "Mentés...",
"status_active": "Aktív",
"status_inactive": "Inaktív",
"remove_title": "Tag eltávolítása",
"removeTitle": "Tag eltávolítása",
"remove_confirm": "Biztosan eltávolítod {name} tagot a garázsból?",
"removeConfirm": "Biztosan eltávolítod {name} tagot a garázsból?",
"remove_btn": "Eltávolítás",
"removing": "Eltávolítás...",
"add_employee": "Új dolgozó hozzáadása",
"addTitle": "Új dolgozó hozzáadása",
"email": "E-mail cím",
"emailPlaceholder": "Add meg az e-mail címet...",
"roleMember": "Tag",
"roleManager": "Menedzser",
"roleAdmin": "Admin",
"roleOwner": "Tulajdonos",
"add": "Hozzáadás",
"adding": "Hozzáadás..."
}

View File

@@ -1,13 +0,0 @@
{
"title": "Flotta",
"total_vehicles": "Összesen {count} jármű",
"plate": "Rendszám",
"brand": "Márka",
"model": "Modell",
"brand_model": "Márka & Modell",
"year": "Évjárat",
"status": "Státusz",
"vin": "Alvázszám",
"no_vehicles": "Ehhez a garázshoz még nincsenek járművek rendelve.",
"no_fleet_access": "Nincs jogosultságod a flotta adatok megtekintéséhez."
}

View File

@@ -1,4 +1,5 @@
{ {
"garages": {
"title": "Garázsok CRM", "title": "Garázsok CRM",
"subtitle": "Garázsok listája, csomagkezelés és státuszmódosítás", "subtitle": "Garázsok listája, csomagkezelés és státuszmódosítás",
"total_garages": "Összes Garázs", "total_garages": "Összes Garázs",
@@ -49,5 +50,163 @@
"addon_vehicles_expiration": "Extra Jármű lejárati dátuma", "addon_vehicles_expiration": "Extra Jármű lejárati dátuma",
"addon_branches_expiration": "Extra Telephely lejárati dátuma", "addon_branches_expiration": "Extra Telephely lejárati dátuma",
"addon_users_expiration": "Extra Dolgozó lejárati dátuma", "addon_users_expiration": "Extra Dolgozó lejárati dátuma",
"addon_expiration_hint": "Ha üres, a meglévő lejárati dátumok megmaradnak." "addon_expiration_hint": "Ha üres, a meglévő lejárati dátumok megmaradnak.",
"details": {
"loading": "Garázs adatainak betöltése...",
"retry": "Újra",
"back": "Vissza a garázsokhoz",
"company_info": "Cég adatok",
"company_name": "Cégnév",
"display_name": "Megjelenítési név",
"org_type": "Szervezet típusa",
"tax_number": "Adószám",
"reg_number": "Cégjegyzékszám",
"created_at": "Létrehozva",
"address": "Cím",
"subscription_info": "Előfizetés státusza",
"tier_name": "Csomag",
"tier_level": "Szint",
"level_n": "{n}. szint",
"expires_at": "Lejárat",
"asset_limit": "Járműkorlát",
"vehicles": "jármű",
"no_subscription": "Nincs aktív előfizetés",
"contact_info": "Elérhetőség",
"contact_name": "Kapcsolattartó neve",
"contact_role": "Szerepkör",
"contact_department": "Osztály",
"contact_phone": "Kapcsolattartó telefon",
"no_contact": "Nincs elsődleges kapcsolattartó",
"edit_details": "Adatok Szerkesztése",
"save": "Mentés",
"generalTab": "Általános",
"employeesTab": "Dolgozók",
"fleetTab": "Flotta",
"analyticsTab": "Analitika",
"subscriptionTab": "Előfizetés",
"editBasicTab": "Alapadatok",
"editContactTab": "Kapcsolat",
"editAddressTab": "Címek",
"zip": "Irányítószám",
"city": "Város",
"street_name": "Utca",
"street_type": "Utca típusa",
"house_number": "Házszám",
"coming_soon": "Ez a funkció hamarosan elérhető lesz...",
"missing_data_title": "Hiányzó adatok",
"missing_data_desc": "A garázs profilja hiányos. Kérjük, egészítse ki a hiányzó adatokat.",
"missing_data_activation_required": "A garázs aktiválásához az alábbi adatok pótlása kötelező:",
"missing_tax_number": "Adószám",
"missing_billing": "Számlázási cím",
"missing_contact_email": "Kapcsolattartó e-mail",
"missing_contact_phone": "Kapcsolattartó telefon",
"missing_data_count": "{count} mező hiányzik",
"contact_person_name": "Kapcsolattartó neve",
"contact_email": "Kapcsolattartó e-mail",
"billing_address": "Számlázási cím",
"billing_zip": "Irányítószám",
"billing_city": "Város",
"billing_street_name": "Utca",
"billing_street_type": "Utca típusa",
"billing_house_number": "Házszám",
"notification_address": "Értesítési cím",
"notification_zip": "Irányítószám",
"notification_city": "Város",
"notification_street_name": "Utca",
"notification_street_type": "Utca típusa",
"notification_house_number": "Házszám",
"tab_basic": "Alapadatok",
"tab_contact": "Kapcsolat",
"tab_addresses": "Címek",
"basic_info": "Alapadatok",
"contact_section": "Kapcsolattartó",
"addresses_section": "Címek",
"vehicles_used": "Járművek",
"branches_used": "Telephelyek",
"employees_used": "Dolgozók",
"branches": "telephely",
"employees": "dolgozó",
"status": "Státusz",
"subscription_history": "Előfizetési Előzmények",
"history_date": "Dátum",
"history_package": "Csomag",
"history_action": "Művelet",
"history_status": "Státusz",
"no_subscription_history": "Még nincsenek előfizetési előzmények."
},
"employees": {
"title": "Dolgozók",
"total_count": "Összesen {count} fő",
"name": "Név",
"email_phone": "E-mail / Telefon",
"role": "Szerepkör",
"status": "Státusz",
"actions": "Műveletek",
"edit_role": "Szerepkör szerkesztése",
"remove": "Eltávolítás",
"no_employees": "Még nincsenek tagok ehhez a garázshoz.",
"edit_role_title": "Tag szerepkörének szerkesztése",
"editRoleTitle": "Tag szerepkörének szerkesztése",
"editRoleFor": "{name} szerepkörének módosítása",
"cancel": "Mégse",
"save": "Mentés",
"saveRole": "Szerepkör mentése",
"saving": "Mentés...",
"status_active": "Aktív",
"status_inactive": "Inaktív",
"remove_title": "Tag eltávolítása",
"removeTitle": "Tag eltávolítása",
"remove_confirm": "Biztosan eltávolítod {name} tagot a garázsból?",
"removeConfirm": "Biztosan eltávolítod {name} tagot a garázsból?",
"remove_btn": "Eltávolítás",
"removing": "Eltávolítás...",
"add_employee": "Új dolgozó hozzáadása",
"addTitle": "Új dolgozó hozzáadása",
"email": "E-mail cím",
"emailPlaceholder": "Add meg az e-mail címet...",
"roleMember": "Tag",
"roleManager": "Menedzser",
"roleAdmin": "Admin",
"roleOwner": "Tulajdonos",
"add": "Hozzáadás",
"adding": "Hozzáadás..."
},
"fleet": {
"title": "Flotta",
"total_vehicles": "Összesen {count} jármű",
"plate": "Rendszám",
"brand": "Márka",
"model": "Modell",
"brand_model": "Márka & Modell",
"year": "Évjárat",
"status": "Státusz",
"vin": "Alvázszám",
"no_vehicles": "Ehhez a garázshoz még nincsenek járművek rendelve.",
"no_fleet_access": "Nincs jogosultságod a flotta adatok megtekintéséhez."
},
"analytics": {
"ltv": "Élettartam Érték (LTV)",
"mrr": "Havi Bevétel (MRR)",
"monthly_recurring": "Havi ismétlődő bevétel",
"outstanding": "Kintlévőség",
"all_settled": "Minden számla rendezve",
"earned_credits": "Szerzett Kreditek",
"contribution_xp": "Platform építésével gyűjtött XP",
"invited_companies": "Meghívott Cégek",
"companies_joined": "Cég csatlakozott a meghívásodra",
"validated_garages": "Validált Garázsok",
"garages_confirmed": "Garázs sikeresen hitelesítve",
"utilization": "Kihasználtság",
"vehicle_quota": "Jármű Kvóta",
"employee_quota": "Alkalmazotti Kvóta",
"utilized": "kihasználva",
"marketplace_engagement": "Piactér & Aktivitás",
"avg_rating": "Átlagos Értékelés",
"reviews": "Értékelések",
"reviews_count": "értékelés",
"last_activity": "Utolsó Aktivitás",
"last_login_mock": "2 órája",
"services_30d": "Elvégzett Szervizek (30 nap)"
}
}
} }

View File

@@ -1,40 +0,0 @@
{
"title": "Ranglista",
"subtitle": "Gamification ranglista admin nézet",
"search": "Keresés",
"level": "Szint",
"min_xp": "Minimum XP",
"sort": "Rendezés",
"sort_xp": "XP szerint",
"sort_points": "Pontok szerint",
"sort_social": "Szociális pontok szerint",
"sort_level": "Szint szerint",
"filter": "Szűrés",
"reset": "Visszaállítás",
"descending": "Csökkenő",
"ascending": "Növekvő",
"user": "felhasználó",
"loading": "Ranglista betöltése...",
"error": "Nem sikerült betölteni a ranglistát.",
"retry": "Újrapróbálkozás",
"no_results": "Nincs találat a megadott szűrőkkel.",
"no_results_hint": "Próbálj más szűrési feltételeket használni.",
"user_col": "Felhasználó",
"email": "Email",
"xp": "XP",
"points": "Pontok",
"social": "Szociális",
"penalty": "Büntetés",
"restriction": "Restrikció",
"discoveries": "Felfedezések",
"services": "Szolgáltatások",
"updated": "Utolsó módosítás",
"actions": "Műveletek",
"details": "Részletek",
"prev": "Előző",
"next": "Következő",
"page_of": "/ oldal",
"restriction_mild": "Enyhe",
"restriction_moderate": "Közepes",
"restriction_severe": "Súlyos"
}

View File

@@ -1,29 +0,0 @@
{
"title": "Pontnapló",
"subtitle": "Gamification pontnapló böngészése",
"loading": "Pontnapló betöltése...",
"error": "Nem sikerült betölteni a pontnaplót.",
"user_id": "User ID",
"date_from": "Dátumtól",
"date_to": "Dátumig",
"reason": "Ok",
"search": "Keresés",
"clear": "Törlés",
"csv": "CSV",
"no_results": "Nincs találat a megadott szűrőkkel.",
"no_results_hint": "Próbálj más szűrési feltételeket használni.",
"id": "ID",
"user": "User",
"points": "Pont",
"penalty": "Büntetés",
"xp": "XP",
"source": "Forrás",
"date": "Dátum",
"prev": "Előző",
"next": "Következő",
"of": "/",
"showing": "mutatva",
"filtered": "(szűrve)",
"no_csv_data": "Nincs adat a CSV exportáláshoz.",
"csv_downloaded": "CSV letöltés elindítva."
}

View File

@@ -1,32 +0,0 @@
{
"title": "Szintek",
"subtitle": "Szint konfigurációk kezelése — CRUD",
"loading": "Szintek betöltése...",
"error": "Nem sikerült betölteni a szint konfigurációkat.",
"retry": "Újrapróbálkozás",
"create": "Új szint",
"no_items": "Még nincsenek szint konfigurációk.",
"level": "Szint",
"rank": "Rang",
"min_points": "Min. Pont",
"type": "Típus",
"actions": "Műveletek",
"penalty": "Büntető",
"normal": "Normál",
"tree_diagram": "Szint Fa Diagram",
"no_tree_data": "Nincsenek szintek megjelenítéshez.",
"points": "pont",
"edit_title": "Szint szerkesztése",
"create_title": "Szint létrehozása",
"level_number": "Szint Száma",
"rank_name": "Rang Neve",
"min_points_label": "Minimum Pont",
"is_penalty": "Büntető szint",
"cancel": "Mégse",
"save": "Mentés",
"saving": "Mentés...",
"updated": "Szint frissítve!",
"created": "Szint létrehozva!",
"duplicate_level": "Ezzel a szint számmal már létezik konfiguráció.",
"save_error": "Hiba történt a mentés során."
}

View File

@@ -1,4 +1,5 @@
{ {
"menu": {
"center": "Központ", "center": "Központ",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"providers": "Szolgáltatók", "providers": "Szolgáltatók",
@@ -32,4 +33,5 @@
"system": "Rendszer", "system": "Rendszer",
"permissions": "Jogosultságok", "permissions": "Jogosultságok",
"system_logs": "Rendszernaplók" "system_logs": "Rendszernaplók"
}
} }

View File

@@ -1,68 +0,0 @@
{
"title": "Csomagok Kezelése",
"subtitle": "Előfizetési csomagok és funkciók kezelése — Free, Premium, Enterprise",
"loading": "Csomagok betöltése...",
"retry": "Újra",
"create_new": "Új Csomag",
"no_packages": "Még nincsenek csomagok",
"no_packages_desc": "Hozd létre az első előfizetési csomagot a kezdéshez.",
"create_first": "Első Csomag Létrehozása",
"popular": "Népszerű",
"fallback_badge": "Visszaesési",
"trial_badge": "Próba",
"month": "hó",
"feature_flags": "Elérhető Funkciók",
"edit": "Szerkesztés",
"duplicate": "Másolás",
"delete": "Törlés",
"set_as_default": "Beállítás alapértelmezettként",
"set_as_default_success": "alapértelmezett csomagként beállítva!",
"edit_title": "Csomag Szerkesztése",
"create_title": "Csomag Létrehozása",
"cancel": "Mégse",
"save": "Változtatások Mentése",
"create": "Létrehozás",
"save_success": "Csomag sikeresen frissítve!",
"delete_title": "Csomag Törlése",
"delete_confirm": "Biztosan törölni szeretnéd a \"{name}\" csomagot?",
"delete_btn": "Törlés",
"allowances": "Korlátok",
"max_vehicles": "Max Jármű",
"max_garages": "Max Garázs",
"max_users": "Max Dolgozó",
"monthly_credits": "Havi Kredit",
"tier_level": "Szint",
"form_name": "Rendszer Név",
"form_name_placeholder": "pl. premium_2024",
"form_display_name": "Megjelenítési Név",
"form_display_name_placeholder": "pl. Premium Plus",
"form_type": "Típus",
"type_private": "Magán",
"type_corporate": "Vállalati",
"form_tier_level": "Szintszám",
"form_tier_level_hint": "Magasabb szám = több funkció és magasabb rang",
"form_monthly_price": "Havi Díj",
"form_yearly_price": "Éves Díj",
"form_currency": "Pénznem",
"form_subtitle": "Felirat / Leírás",
"form_subtitle_placeholder": "pl. Ideális kisvállalkozásoknak",
"form_badge": "Jelvény Szöveg",
"form_badge_placeholder": "pl. Legnépszerűbb",
"form_is_custom": "Egyedi Csomag (nem publikus)",
"singleton_rules": "Egyediségi Szabályok",
"form_is_default_fallback": "Alapértelmezett Visszaesési Csomag — lejárt előfizetés esetén erre vált",
"form_trial_days": "Próba Napok Regisztrációnál",
"form_trial_days_hint": "Az új szervezetek ennyi ingyenes napot kapnak. Csak egy csomagnál lehet > 0.",
"per_region_pricing": "Régiók szerinti árazás",
"smart_calculator": "Okos Árkalkulátor",
"calc_net_price": "Nettó ár",
"calc_vat_rate": "ÁFA kulcs",
"calc_currency": "Pénznem",
"calc_net": "Nettó",
"calc_vat": "ÁFA",
"calc_gross": "Bruttó",
"calc_apply": "Alkalmaz a kiválasztott régióra",
"add_zone": "Új Zóna Hozzáadása",
"add_zone_select": "Válassz országkódot...",
"add_zone_already_exists": "Ez a régió már létezik a csomag árazásában!"
}

View File

@@ -1,26 +0,0 @@
{
"title": "Rendszerparaméterek",
"subtitle": "Gamification kategóriájú rendszerparaméterek",
"loading": "Paraméterek betöltése...",
"error": "Nem sikerült betölteni a rendszerparamétereket.",
"retry": "Újrapróbálkozás",
"no_items": "Nincsenek gamification rendszerparaméterek.",
"key": "Kulcs",
"value": "Érték",
"category": "Kategória",
"scope": "Hatósugár",
"scope_id": "Scope ID",
"actions": "Műveletek",
"edit": "Szerkesztés",
"edit_title": "Paraméter szerkesztése",
"value_label": "Érték",
"value_placeholder": "Paraméter értéke (JSON vagy sima szöveg)",
"json_detected": "🔹 JSON formátum érzékelve",
"text_value": "🔸 Szöveges érték",
"cancel": "Mégse",
"save": "Mentés",
"saving": "Mentés...",
"invalid_json": "Érvénytelen JSON formátum: ",
"save_error": "Hiba történt a mentés során.",
"updated": "frissítve!"
}

View File

@@ -1,28 +0,0 @@
{
"title": "Jogosultság Kezelés",
"subtitle": "Jogosultsági Mátrix — Szerepkör alapú hozzáférés-vezérlés",
"loading": "Jogosultságok betöltése...",
"error_title": "Nem sikerült betölteni a jogosultságokat",
"retry": "Újra",
"save_changes": "Változtatások Mentése",
"discard": "Elvetés",
"modified_count": "{count} módosítás",
"domain_filter": "Tartomány Szűrő (Sorok)",
"role_filter": "Szerepkör Szűrő (Oszlopok)",
"select_all": "Összes kiválasztása",
"deselect_all": "Összes elrejtése",
"all_domains": "Minden",
"permission": "Engedély",
"no_permissions": "Nincsenek engedélyek ebben a tartományban.",
"current_tier": "Aktuális Csomag",
"expires": "Lejárat",
"superadmin": "Superadmin",
"superadmin_desc": "Teljes rendszer hozzáférés — felhasználók, számlázás, robotok és rendszerkonfiguráció.",
"garages": "Garázsok",
"garages_desc": "Garázs szintű hozzáférés — járműkezelés, szervizfoglalás, ügyféladatok.",
"feature_flags": "Feature Flags",
"feature_flags_desc": "Előfizetési funkciók — a jelenlegi csomagod alapján ({tier}).",
"rbac_title": "RBAC Jogosultsági Mátrix",
"rbac_desc": "Szerepkör alapú hozzáférés-vezérlés — mely szerepkörök mely admin műveleteket végezhetik.",
"action": "Művelet"
}

View File

@@ -1,110 +0,0 @@
{
"title": "Személyek Kezelése",
"subtitle": "Személyek listája, keresés, szűrők és részletek",
"loading": "Személyek betöltése...",
"retry": "Újra",
"search_placeholder": "Keresés név, telefonszám vagy e-mail alapján...",
"all_ghost_statuses": "Összes szellem státusz",
"ghost_no": "Nem szellem",
"ghost_yes": "Szellem",
"all_active_statuses": "Összes aktív státusz",
"active_yes": "Aktív",
"active_no": "Inaktív",
"all_user_statuses": "Összes felhasználói státusz",
"has_user_yes": "Van felhasználó",
"has_user_no": "Nincs felhasználó",
"all_merged_statuses": "Összes egyesítési státusz",
"merged_no": "Nincs egyesítve",
"merged_yes": "Egyesítve",
"total_persons": "Összes Személy",
"active_persons": "Aktív Személyek",
"ghost_persons": "Szellem Személyek",
"merged_persons": "Egyesített Személyek",
"filtered_results": "Szűrt eredmények",
"name": "Név",
"phone": "Telefonszám",
"birth_date": "Születési Dátum",
"status": "Státusz",
"users_count": "Felhasználók",
"active_user": "Aktív Felhasználó",
"address": "Cím",
"actions": "Műveletek",
"active": "Aktív",
"inactive": "Inaktív",
"ghost": "Szellem",
"merged": "Egyesítve",
"view": "Megtekintés",
"no_results": "Nincs a keresésnek megfelelő személy.",
"showing": "Mutató",
"prev": "Előző",
"next": "Következő",
"details": {
"loading": "Személy adatainak betöltése...",
"retry": "Újra",
"back": "Vissza a listához",
"sales_agent": "Értékesítési Ügynök",
"edit": "Szerkesztés",
"personal_info": "Személyes Adatok",
"person_id": "Személy ID",
"uuid": "UUID",
"last_name": "Vezetéknév",
"first_name": "Keresztnév",
"birth_place": "Születési Hely",
"mothers_name": "Anyja Neve",
"mothers_last_name": "Anyja Vezetékneve",
"mothers_first_name": "Anyja Keresztneve",
"address": "Cím",
"zip": "Irányítószám",
"city": "Város",
"street": "Utca",
"house_number": "Házszám",
"stairwell": "Lépcsőház",
"floor": "Emelet",
"door": "Ajtó",
"parcel_id": "Helyrajzi Szám (HRSZ)",
"full_address": "Teljes Cím",
"system_info": "Rendszer Információk",
"is_sales_agent": "Értékesítési Ügynök",
"lifetime_xp": "Élettapasztalat (XP)",
"penalty_points": "Büntetőpontok",
"social_reputation": "Társadalmi Megítélés",
"created_at": "Létrehozva",
"updated_at": "Frissítve",
"deleted_at": "Törölve",
"identity_docs": "Személyazonosító Okmányok",
"ice_contact": "Vészhelyzeti Kapcsolattartó",
"connected_users": "Kapcsolódó Felhasználók",
"users_count": "{count} felhasználó kapcsolódik",
"no_users": "Ehhez a személyhez nem kapcsolódik felhasználó.",
"email": "E-mail",
"role": "Szerepkör",
"plan": "Csomag",
"language": "Nyelv",
"registered_at": "Regisztráció Dátuma",
"memberships": "Tagságok",
"memberships_count": "{count} tagság",
"no_memberships": "Nincs tagság.",
"org_name": "Szervezet Neve",
"org_type": "Típus",
"org_role": "Szerepkör",
"member_status": "Státusz",
"verified": "Ellenőrzött",
"joined_at": "Csatlakozás Dátuma",
"verified_yes": "Igen",
"verified_no": "Nem",
"owned_orgs": "Tulajdonolt Szervezetek",
"owned_orgs_count": "{count} szervezet",
"no_owned_orgs": "Nincs tulajdonolt szervezet.",
"merge_history": "Egyesítési Előzmények",
"no_merge_history": "Nincs egyesítési előzmény.",
"merged_into": "Egyesítve ebbe:",
"merged_at": "Egyesítés Dátuma",
"edit_title": "Személy Szerkesztése",
"status_section": "Státusz",
"is_active": "Aktív",
"is_ghost": "Szellem",
"cancel": "Mégse",
"saving": "Mentés...",
"save": "Mentés"
}
}

View File

@@ -1,40 +0,0 @@
{
"title": "Pontszabályok",
"subtitle": "Gamification pontszabályok kezelése — CRUD",
"loading": "Pontszabályok betöltése...",
"error": "Nem sikerült betölteni a pontszabályokat.",
"retry": "Újrapróbálkozás",
"create": "Új pontszabály",
"no_items": "Még nincsenek pontszabályok. Hozz létre egyet a fenti gombbal!",
"id": "ID",
"action_key": "Akció Kulcs",
"points": "Pont",
"description": "Leírás",
"status": "Státusz",
"actions": "Műveletek",
"active": "Aktív",
"inactive": "Inaktív",
"edit": "Szerkesztés",
"delete": "Törlés (soft-delete)",
"edit_title": "Pontszabály szerkesztése",
"create_title": "Új pontszabály",
"action_key_label": "Akció Kulcs",
"action_key_placeholder": "pl. service_submitted",
"points_label": "Pontérték",
"points_placeholder": "0",
"description_label": "Leírás",
"description_placeholder": "Rövid leírás a szabályról...",
"is_active": "Aktív",
"cancel": "Mégse",
"save": "Mentés",
"saving": "Mentés...",
"updated": "Pontszabály frissítve!",
"created": "Pontszabály létrehozva!",
"deleted": "Pontszabály deaktiválva!",
"duplicate_key": "Egy szabály ezzel a kulccsal már létezik.",
"save_error": "Nem sikerült menteni a pontszabályt.",
"delete_title": "Pontszabály törlése",
"delete_confirm": "Biztosan deaktiválod a következő szabályt:",
"delete_btn": "Törlés",
"deleting": "Törlés..."
}

View File

@@ -1,4 +1,5 @@
{ {
"providers": {
"title": "Szolgáltatók", "title": "Szolgáltatók",
"subtitle": "Szolgáltatók listázása, keresése és moderálása", "subtitle": "Szolgáltatók listázása, keresése és moderálása",
"loading": "Szolgáltatók betöltése...", "loading": "Szolgáltatók betöltése...",
@@ -277,4 +278,5 @@
"edit_raw_data_title": "Nyers adatok (raw_data)", "edit_raw_data_title": "Nyers adatok (raw_data)",
"edit_audit_trail_title": "Audit Trail", "edit_audit_trail_title": "Audit Trail",
"edit_rejection_reason_title": "Elutasítás indoklása" "edit_rejection_reason_title": "Elutasítás indoklása"
}
} }

View File

@@ -1,31 +0,0 @@
{
"title": "Szezonok",
"subtitle": "Gamification szezonkezelés",
"loading": "Szezonok betöltése...",
"error": "Nem sikerült betölteni a szezonokat.",
"retry": "Újra",
"create": "Új Szezon",
"no_items": "Még nincsenek szezonok létrehozva.",
"create_first": "Első Szezon Létrehozása",
"active": "Aktív",
"inactive": "Inaktív",
"created_at": "Létrehozva:",
"activate": "Aktiválás",
"edit": "Szerkesztés",
"edit_title": "Szezon szerkesztése",
"create_title": "Új Szezon",
"name": "Név",
"name_placeholder": "pl. 2026 Q1 Szezon",
"start_date": "Kezdő dátum",
"end_date": "Záró dátum",
"activate_on_create": "Aktiválás létrehozáskor",
"cancel": "Mégse",
"save": "Mentés",
"create_btn": "Létrehozás",
"activate_title": "Szezon aktiválása",
"activate_confirm": "Biztosan aktiválni szeretnéd",
"activate_warning": "Ez deaktiválja az összes többi szezont.",
"activating": "Aktiválás...",
"activate_btn": "Aktiválás",
"save_error": "Nem sikerült menteni a szezont."
}

View File

@@ -0,0 +1,181 @@
{
"system": {
"config": {
"title": "Rendszer Konfig",
"subtitle": "Gamification master konfiguráció szerkesztése",
"loading": "Konfiguráció betöltése...",
"error": "Nem sikerült betölteni a konfigurációt.",
"last_save": "Utolsó mentés:",
"saving": "Mentés...",
"save": "Konfiguráció mentése",
"section_xp": "XP Logika",
"section_conversion": "Konverziós Logika",
"section_penalty": "Büntetési Logika",
"section_rewards": "Szint Jutalmak",
"calculator": "Hatás Kalkulátor",
"calculator_desc": "Állítsd be a paramétereket a becsült hatás megtekintéséhez",
"estimated_results": "Becsült Eredmények",
"daily_xp": "Napi XP",
"xp_for_level": "Szintlépéshez Szükséges XP",
"social_to_credit": "Social → Credit",
"days_to_level": "Szintlépés Napok Száma",
"raw_json": "Nyers JSON Szerkesztő",
"apply_json": "JSON Alkalmazása",
"updated": "Konfiguráció frissítve!",
"save_error": "Hiba történt a mentés során.",
"json_applied": "JSON alkalmazva a form mezőkre!",
"invalid_json": "Érvénytelen JSON: "
},
"params": {
"title": "Rendszerparaméterek",
"subtitle": "Gamification kategóriájú rendszerparaméterek",
"loading": "Paraméterek betöltése...",
"error": "Nem sikerült betölteni a rendszerparamétereket.",
"retry": "Újrapróbálkozás",
"no_items": "Nincsenek gamification rendszerparaméterek.",
"key": "Kulcs",
"value": "Érték",
"category": "Kategória",
"scope": "Hatósugár",
"scope_id": "Scope ID",
"actions": "Műveletek",
"edit": "Szerkesztés",
"edit_title": "Paraméter szerkesztése",
"value_label": "Érték",
"value_placeholder": "Paraméter értéke (JSON vagy sima szöveg)",
"json_detected": "🔹 JSON formátum érzékelve",
"text_value": "🔸 Szöveges érték",
"cancel": "Mégse",
"save": "Mentés",
"saving": "Mentés...",
"invalid_json": "Érvénytelen JSON formátum: ",
"save_error": "Hiba történt a mentés során.",
"updated": "frissítve!"
},
"ledger": {
"title": "Pontnapló",
"subtitle": "Gamification pontnapló böngészése",
"loading": "Pontnapló betöltése...",
"error": "Nem sikerült betölteni a pontnaplót.",
"user_id": "User ID",
"date_from": "Dátumtól",
"date_to": "Dátumig",
"reason": "Ok",
"search": "Keresés",
"clear": "Törlés",
"csv": "CSV",
"no_results": "Nincs találat a megadott szűrőkkel.",
"no_results_hint": "Próbálj más szűrési feltételeket használni.",
"id": "ID",
"user": "User",
"points": "Pont",
"penalty": "Büntetés",
"xp": "XP",
"source": "Forrás",
"date": "Dátum",
"prev": "Előző",
"next": "Következő",
"of": "/",
"showing": "mutatva",
"filtered": "(szűrve)",
"no_csv_data": "Nincs adat a CSV exportáláshoz.",
"csv_downloaded": "CSV letöltés elindítva."
},
"packages": {
"title": "Csomagok Kezelése",
"subtitle": "Előfizetési csomagok és funkciók kezelése — Free, Premium, Enterprise",
"loading": "Csomagok betöltése...",
"retry": "Újra",
"create_new": "Új Csomag",
"no_packages": "Még nincsenek csomagok",
"no_packages_desc": "Hozd létre az első előfizetési csomagot a kezdéshez.",
"create_first": "Első Csomag Létrehozása",
"popular": "Népszerű",
"fallback_badge": "Visszaesési",
"trial_badge": "Próba",
"month": "hó",
"feature_flags": "Elérhető Funkciók",
"edit": "Szerkesztés",
"duplicate": "Másolás",
"delete": "Törlés",
"set_as_default": "Beállítás alapértelmezettként",
"set_as_default_success": "alapértelmezett csomagként beállítva!",
"edit_title": "Csomag Szerkesztése",
"create_title": "Csomag Létrehozása",
"cancel": "Mégse",
"save": "Változtatások Mentése",
"create": "Létrehozás",
"save_success": "Csomag sikeresen frissítve!",
"delete_title": "Csomag Törlése",
"delete_confirm": "Biztosan törölni szeretnéd a \"{name}\" csomagot?",
"delete_btn": "Törlés",
"allowances": "Korlátok",
"max_vehicles": "Max Jármű",
"max_garages": "Max Garázs",
"max_users": "Max Dolgozó",
"monthly_credits": "Havi Kredit",
"tier_level": "Szint",
"form_name": "Rendszer Név",
"form_name_placeholder": "pl. premium_2024",
"form_display_name": "Megjelenítési Név",
"form_display_name_placeholder": "pl. Premium Plus",
"form_type": "Típus",
"type_private": "Magán",
"type_corporate": "Vállalati",
"form_tier_level": "Szintszám",
"form_tier_level_hint": "Magasabb szám = több funkció és magasabb rang",
"form_monthly_price": "Havi Díj",
"form_yearly_price": "Éves Díj",
"form_currency": "Pénznem",
"form_subtitle": "Felirat / Leírás",
"form_subtitle_placeholder": "pl. Ideális kisvállalkozásoknak",
"form_badge": "Jelvény Szöveg",
"form_badge_placeholder": "pl. Legnépszerűbb",
"form_is_custom": "Egyedi Csomag (nem publikus)",
"singleton_rules": "Egyediségi Szabályok",
"form_is_default_fallback": "Alapértelmezett Visszaesési Csomag — lejárt előfizetés esetén erre vált",
"form_trial_days": "Próba Napok Regisztrációnál",
"form_trial_days_hint": "Az új szervezetek ennyi ingyenes napot kapnak. Csak egy csomagnál lehet > 0.",
"per_region_pricing": "Régiók szerinti árazás",
"smart_calculator": "Okos Árkalkulátor",
"calc_net_price": "Nettó ár",
"calc_vat_rate": "ÁFA kulcs",
"calc_currency": "Pénznem",
"calc_net": "Nettó",
"calc_vat": "ÁFA",
"calc_gross": "Bruttó",
"calc_apply": "Alkalmaz a kiválasztott régióra",
"add_zone": "Új Zóna Hozzáadása",
"add_zone_select": "Válassz országkódot...",
"add_zone_already_exists": "Ez a régió már létezik a csomag árazásában!"
},
"permissions": {
"title": "Jogosultság Kezelés",
"subtitle": "Jogosultsági Mátrix — Szerepkör alapú hozzáférés-vezérlés",
"loading": "Jogosultságok betöltése...",
"error_title": "Nem sikerült betölteni a jogosultságokat",
"retry": "Újra",
"save_changes": "Változtatások Mentése",
"discard": "Elvetés",
"modified_count": "{count} módosítás",
"domain_filter": "Tartomány Szűrő (Sorok)",
"role_filter": "Szerepkör Szűrő (Oszlopok)",
"select_all": "Összes kiválasztása",
"deselect_all": "Összes elrejtése",
"all_domains": "Minden",
"permission": "Engedély",
"no_permissions": "Nincsenek engedélyek ebben a tartományban.",
"current_tier": "Aktuális Csomag",
"expires": "Lejárat",
"superadmin": "Superadmin",
"superadmin_desc": "Teljes rendszer hozzáférés — felhasználók, számlázás, robotok és rendszerkonfiguráció.",
"garages": "Garázsok",
"garages_desc": "Garázs szintű hozzáférés — járműkezelés, szervizfoglalás, ügyféladatok.",
"feature_flags": "Feature Flags",
"feature_flags_desc": "Előfizetési funkciók — a jelenlegi csomagod alapján ({tier}).",
"rbac_title": "RBAC Jogosultsági Mátrix",
"rbac_desc": "Szerepkör alapú hozzáférés-vezérlés — mely szerepkörök mely admin műveleteket végezhetik.",
"action": "Művelet"
}
}
}

View File

@@ -1,52 +0,0 @@
{
"loading": "Felhasználó adatainak betöltése...",
"retry": "Újra",
"back": "Vissza a felhasználókhoz",
"edit": "Felhasználó szerkesztése",
"edit_user": "Felhasználó szerkesztése",
"profile_tab": "Profil",
"memberships_tab": "Tagságok",
"account_info": "Fiók Információk",
"user_id": "Felhasználó ID",
"registration_date": "Regisztráció Dátuma",
"last_login": "Utolsó Belépés",
"ui_mode": "UI Mód",
"region": "Régió",
"currency": "Pénznem",
"subscription_info": "Előfizetés Információk",
"subscription_expires": "Lejárat Dátuma",
"scope_level": "Hatósugár Szint",
"scope_id": "Hatósugár ID",
"max_vehicles": "Max Járművek",
"max_garages": "Max Garázsok",
"personal_info": "Személyes Információk",
"last_name": "Vezetéknév",
"first_name": "Keresztnév",
"phone": "Telefon",
"birth_place": "Születési Hely",
"birth_date": "Születési Dátum",
"mothers_name": "Anyja Neve",
"mothers_first_name": "Anyja Keresztneve",
"address": "Cím",
"zip": "Irányítószám",
"city": "Város",
"street": "Utca",
"house_number": "Házszám",
"no_person_record": "Nem található személyes adat ehhez a felhasználóhoz.",
"memberships": "Szervezeti Tagságok",
"memberships_count": "{count} tagság",
"no_memberships": "Ez a felhasználó nem tagja egyetlen szervezetnek sem.",
"org_name": "Szervezet",
"org_role": "Szerepkör",
"member_status": "Státusz",
"joined_at": "Csatlakozott",
"verified": "Hitelesítve",
"verified_yes": "Igen",
"verified_no": "Nem",
"preferred_language": "Előnyben Részesített Nyelv",
"is_active": "Aktív",
"is_vip": "VIP",
"cancel": "Mégse",
"save": "Változtatások Mentése",
"saving": "Mentés..."
}

View File

@@ -1,4 +1,5 @@
{ {
"users": {
"title": "Felhasználó Kezelés", "title": "Felhasználó Kezelés",
"subtitle": "Felhasználói lista, keresés, szűrők és csoportos műveletek", "subtitle": "Felhasználói lista, keresés, szűrők és csoportos műveletek",
"loading": "Felhasználók betöltése...", "loading": "Felhasználók betöltése...",
@@ -42,5 +43,179 @@
"no_results": "Nincs a keresésnek megfelelő felhasználó.", "no_results": "Nincs a keresésnek megfelelő felhasználó.",
"showing": "Mutató", "showing": "Mutató",
"prev": "Előző", "prev": "Előző",
"next": "Következő" "next": "Következő",
"details": {
"loading": "Felhasználó adatainak betöltése...",
"retry": "Újra",
"back": "Vissza a felhasználókhoz",
"edit": "Felhasználó szerkesztése",
"edit_user": "Felhasználó szerkesztése",
"profile_tab": "Profil",
"memberships_tab": "Tagságok",
"account_info": "Fiók Információk",
"user_id": "Felhasználó ID",
"registration_date": "Regisztráció Dátuma",
"last_login": "Utolsó Belépés",
"ui_mode": "UI Mód",
"region": "Régió",
"currency": "Pénznem",
"subscription_info": "Előfizetés Információk",
"subscription_expires": "Lejárat Dátuma",
"scope_level": "Hatósugár Szint",
"scope_id": "Hatósugár ID",
"max_vehicles": "Max Járművek",
"max_garages": "Max Garázsok",
"personal_info": "Személyes Információk",
"last_name": "Vezetéknév",
"first_name": "Keresztnév",
"phone": "Telefon",
"birth_place": "Születési Hely",
"birth_date": "Születési Dátum",
"mothers_name": "Anyja Neve",
"mothers_first_name": "Anyja Keresztneve",
"address": "Cím",
"zip": "Irányítószám",
"city": "Város",
"street": "Utca",
"house_number": "Házszám",
"no_person_record": "Nem található személyes adat ehhez a felhasználóhoz.",
"memberships": "Szervezeti Tagságok",
"memberships_count": "{count} tagság",
"no_memberships": "Ez a felhasználó nem tagja egyetlen szervezetnek sem.",
"org_name": "Szervezet",
"org_role": "Szerepkör",
"member_status": "Státusz",
"joined_at": "Csatlakozott",
"verified": "Hitelesítve",
"verified_yes": "Igen",
"verified_no": "Nem",
"preferred_language": "Előnyben Részesített Nyelv",
"is_active": "Aktív",
"is_vip": "VIP",
"cancel": "Mégse",
"save": "Változtatások Mentése",
"saving": "Mentés...",
"danger_zone": "Veszélyes Zóna",
"danger_zone_warning": "Ez a művelet visszafordíthatatlan. A felhasználó összes adata véglegesen eltávolításra kerül.",
"danger_zone_button": "Felhasználó végleges törlése",
"danger_zone_confirm_label": "A megerősítéshez gépeld be a felhasználó e-mail címét:",
"danger_zone_confirm_placeholder": "Add meg az e-mail címet...",
"danger_zone_confirm_button": "Megértettem, a felhasználó végleges törlése",
"danger_zone_cancel": "Mégse",
"danger_zone_success": "A felhasználó véglegesen törölve lett.",
"danger_zone_error_financial": "Nem törölhető: A felhasználónak pénzügyi rekordjai vannak.",
"danger_zone_error_not_superadmin": "Csak Superadminok végezhetnek végleges törlést.",
"danger_zone_error_generic": "Sikertelen törlés: {message}"
},
"persons": {
"title": "Személyek Kezelése",
"subtitle": "Személyek listája, keresés, szűrők és részletek",
"loading": "Személyek betöltése...",
"retry": "Újra",
"search_placeholder": "Keresés név, telefonszám vagy e-mail alapján...",
"all_ghost_statuses": "Összes szellem státusz",
"ghost_no": "Nem szellem",
"ghost_yes": "Szellem",
"all_active_statuses": "Összes aktív státusz",
"active_yes": "Aktív",
"active_no": "Inaktív",
"all_user_statuses": "Összes felhasználói státusz",
"has_user_yes": "Van felhasználó",
"has_user_no": "Nincs felhasználó",
"all_merged_statuses": "Összes egyesítési státusz",
"merged_no": "Nincs egyesítve",
"merged_yes": "Egyesítve",
"total_persons": "Összes Személy",
"active_persons": "Aktív Személyek",
"ghost_persons": "Szellem Személyek",
"merged_persons": "Egyesített Személyek",
"filtered_results": "Szűrt eredmények",
"name": "Név",
"phone": "Telefonszám",
"birth_date": "Születési Dátum",
"status": "Státusz",
"users_count": "Felhasználók",
"active_user": "Aktív Felhasználó",
"address": "Cím",
"actions": "Műveletek",
"active": "Aktív",
"inactive": "Inaktív",
"ghost": "Szellem",
"merged": "Egyesítve",
"view": "Megtekintés",
"no_results": "Nincs a keresésnek megfelelő személy.",
"showing": "Mutató",
"prev": "Előző",
"next": "Következő",
"details": {
"loading": "Személy adatainak betöltése...",
"retry": "Újra",
"back": "Vissza a listához",
"sales_agent": "Értékesítési Ügynök",
"edit": "Szerkesztés",
"personal_info": "Személyes Adatok",
"person_id": "Személy ID",
"uuid": "UUID",
"last_name": "Vezetéknév",
"first_name": "Keresztnév",
"birth_place": "Születési Hely",
"mothers_name": "Anyja Neve",
"mothers_last_name": "Anyja Vezetékneve",
"mothers_first_name": "Anyja Keresztneve",
"address": "Cím",
"zip": "Irányítószám",
"city": "Város",
"street": "Utca",
"house_number": "Házszám",
"stairwell": "Lépcsőház",
"floor": "Emelet",
"door": "Ajtó",
"parcel_id": "Helyrajzi Szám (HRSZ)",
"full_address": "Teljes Cím",
"system_info": "Rendszer Információk",
"is_sales_agent": "Értékesítési Ügynök",
"lifetime_xp": "Élettapasztalat (XP)",
"penalty_points": "Büntetőpontok",
"social_reputation": "Társadalmi Megítélés",
"created_at": "Létrehozva",
"updated_at": "Frissítve",
"deleted_at": "Törölve",
"identity_docs": "Személyazonosító Okmányok",
"ice_contact": "Vészhelyzeti Kapcsolattartó",
"connected_users": "Kapcsolódó Felhasználók",
"users_count": "{count} felhasználó kapcsolódik",
"no_users": "Ehhez a személyhez nem kapcsolódik felhasználó.",
"email": "E-mail",
"role": "Szerepkör",
"plan": "Csomag",
"language": "Nyelv",
"registered_at": "Regisztráció Dátuma",
"memberships": "Tagságok",
"memberships_count": "{count} tagság",
"no_memberships": "Nincs tagság.",
"org_name": "Szervezet Neve",
"org_type": "Típus",
"org_role": "Szerepkör",
"member_status": "Státusz",
"verified": "Ellenőrzött",
"joined_at": "Csatlakozás Dátuma",
"verified_yes": "Igen",
"verified_no": "Nem",
"owned_orgs": "Tulajdonolt Szervezetek",
"owned_orgs_count": "{count} szervezet",
"no_owned_orgs": "Nincs tulajdonolt szervezet.",
"merge_history": "Egyesítési Előzmények",
"no_merge_history": "Nincs egyesítési előzmény.",
"merged_into": "Egyesítve ebbe:",
"merged_at": "Egyesítés Dátuma",
"edit_title": "Személy Szerkesztése",
"status_section": "Státusz",
"is_active": "Aktív",
"is_ghost": "Szellem",
"cancel": "Mégse",
"saving": "Mentés...",
"save": "Mentés"
}
}
}
} }

View File

@@ -21,27 +21,15 @@ export default defineNuxtConfig({
{ {
code: 'hu', iso: 'hu-HU', name: 'Magyar', code: 'hu', iso: 'hu-HU', name: 'Magyar',
files: [ files: [
'hu/common.json', 'hu/menu.json', 'hu/providers.json', 'hu/garages.json', 'hu/common.json', 'hu/menu.json', 'hu/dashboard.json', 'hu/providers.json',
'hu/dashboard.json', 'hu/config.json', 'hu/badges.json', 'hu/levels.json', 'hu/garages.json', 'hu/users.json', 'hu/gamification.json', 'hu/system.json',
'hu/point_rules.json', 'hu/seasons.json', 'hu/competitions.json',
'hu/leaderboard.json', 'hu/ledger.json', 'hu/params.json', 'hu/packages.json',
'hu/persons.json', 'hu/permissions.json', 'hu/users.json', 'hu/user_detail.json',
'hu/gamification_users.json', 'hu/gamification_user_detail.json',
'hu/garages-details.json', 'hu/garages-employees.json',
'hu/garages-fleet.json', 'hu/garages-analytics.json',
], ],
}, },
{ {
code: 'en', iso: 'en-GB', name: 'English', code: 'en', iso: 'en-GB', name: 'English',
files: [ files: [
'en/common.json', 'en/menu.json', 'en/providers.json', 'en/garages.json', 'en/common.json', 'en/menu.json', 'en/dashboard.json', 'en/providers.json',
'en/dashboard.json', 'en/config.json', 'en/badges.json', 'en/levels.json', 'en/garages.json', 'en/users.json', 'en/gamification.json', 'en/system.json',
'en/point_rules.json', 'en/seasons.json', 'en/competitions.json',
'en/leaderboard.json', 'en/ledger.json', 'en/params.json', 'en/packages.json',
'en/persons.json', 'en/permissions.json', 'en/users.json', 'en/user_detail.json',
'en/gamification_users.json', 'en/gamification_user_detail.json',
'en/garages-details.json', 'en/garages-employees.json',
'en/garages-fleet.json', 'en/garages-analytics.json',
], ],
}, },
{ code: 'de', iso: 'de-DE', file: 'de.json', name: 'Deutsch' }, { code: 'de', iso: 'de-DE', file: 'de.json', name: 'Deutsch' },

View File

@@ -265,6 +265,32 @@
</svg> </svg>
<p class="text-sm text-slate-400">{{ $t('users.details.no_person_record') }}</p> <p class="text-sm text-slate-400">{{ $t('users.details.no_person_record') }}</p>
</div> </div>
<!-- ============================================================ -->
<!-- Danger Zone (Superadmin only) -->
<!-- ============================================================ -->
<div v-if="isSuperadmin" class="border border-red-500/30 bg-red-500/5 rounded-xl p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-lg bg-red-500/10 text-red-400">
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-red-400">{{ $t('users.details.danger_zone') }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ $t('users.details.danger_zone_warning') }}</p>
</div>
</div>
<button
@click="openDangerModal"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600/20 text-red-300 hover:bg-red-600/30 border border-red-500/30 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="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" />
</svg>
{{ $t('users.details.danger_zone_button') }}
</button>
</div>
</div> </div>
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -570,6 +596,73 @@
</div> </div>
</div> </div>
<!-- ============================================================ -->
<!-- Danger Zone Modal (Double Opt-in) -->
<!-- ============================================================ -->
<div
v-if="showDangerModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="closeDangerModal"></div>
<!-- Modal -->
<div class="relative w-full max-w-md bg-slate-800 border border-red-500/30 rounded-2xl shadow-2xl">
<!-- Header -->
<div class="px-6 py-4 border-b border-red-500/20 flex items-center gap-3">
<div class="p-2 rounded-lg bg-red-500/10 text-red-400">
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-red-400">{{ $t('users.details.danger_zone') }}</h3>
</div>
<!-- Body -->
<div class="p-6 space-y-4">
<p class="text-sm text-slate-300">
{{ $t('users.details.danger_zone_warning') }}
</p>
<div class="bg-slate-700/50 rounded-lg p-3">
<p class="text-xs text-slate-400 mb-1">{{ $t('users.details.danger_zone_confirm_label') }}</p>
<p class="text-sm text-white font-mono">{{ user?.email }}</p>
</div>
<div>
<input
v-model="dangerConfirmEmail"
type="email"
:placeholder="$t('users.details.danger_zone_confirm_placeholder')"
class="w-full px-3 py-2 text-sm bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500"
/>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-slate-700 flex items-center justify-end gap-3">
<button
@click="closeDangerModal"
:disabled="dangerDeleting"
class="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white bg-slate-700/50 hover:bg-slate-700 rounded-lg transition disabled:opacity-50"
>
{{ $t('users.details.danger_zone_cancel') }}
</button>
<button
@click="executeHardDelete"
:disabled="dangerConfirmEmail !== user?.email || dangerDeleting"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 text-white hover:bg-red-500 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="dangerDeleting" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ $t('users.details.danger_zone_confirm_button') }}
</button>
</div>
</div>
</div>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- Notification Toast --> <!-- Notification Toast -->
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -634,6 +727,11 @@ const editForm = ref({
person_birth_date: '', person_birth_date: '',
}) })
// Danger Zone Modal
const showDangerModal = ref(false)
const dangerConfirmEmail = ref('')
const dangerDeleting = ref(false)
// Notification // Notification
const notification = ref({ show: false, type: 'success' as 'success' | 'error', message: '' }) const notification = ref({ show: false, type: 'success' as 'success' | 'error', message: '' })
@@ -691,6 +789,69 @@ const hasEditPermission = computed(() => {
return !!auth.user?.system_capabilities?.['users:edit'] return !!auth.user?.system_capabilities?.['users:edit']
}) })
const isSuperadmin = computed(() => {
return auth.user?.role === 'SUPERADMIN'
})
// Danger Zone functions
function openDangerModal() {
dangerConfirmEmail.value = ''
showDangerModal.value = true
}
function closeDangerModal() {
showDangerModal.value = false
dangerConfirmEmail.value = ''
}
async function executeHardDelete() {
if (dangerConfirmEmail.value !== user.value?.email) return
dangerDeleting.value = true
try {
const tokenCookie = useCookie('access_token')
const token = (tokenCookie.value || auth.token || '').trim()
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
}
const res = await fetch(`/api/v1/admin/users/${userId}/hard`, {
method: 'DELETE',
headers,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({}))
const detail = errData.detail || ''
if (res.status === 403) {
throw new Error('danger_zone_error_not_superadmin')
}
if (res.status === 400 && detail.includes('financial')) {
throw new Error('danger_zone_error_financial')
}
throw new Error(detail || `Delete failed: ${res.status}`)
}
closeDangerModal()
showNotification('success', 'danger_zone_success')
// Redirect to users list after a short delay
setTimeout(() => {
router.push('/users')
}, 2000)
} catch (e: any) {
const msg = e.message || 'danger_zone_error_generic'
// Check if it's a translation key
if (msg.startsWith('danger_zone_error')) {
showNotification('error', msg)
} else {
showNotification('error', 'danger_zone_error_generic')
}
} finally {
dangerDeleting.value = false
}
}
// API calls // API calls
async function fetchUser() { async function fetchUser() {
loading.value = true loading.value = true
@@ -858,7 +1019,9 @@ async function saveEdit() {
// Notification helper // Notification helper
function showNotification(type: 'success' | 'error', message: string) { function showNotification(type: 'success' | 'error', message: string) {
notification.value = { show: true, type, message } // If message is a translation key, translate it
const translated = $t(message.startsWith('danger_zone') ? `users.details.${message}` : message)
notification.value = { show: true, type, message: translated }
setTimeout(() => { setTimeout(() => {
notification.value.show = false notification.value.show = false
}, 4000) }, 4000)

View File

@@ -58,18 +58,48 @@
<!-- Scrollable Form Body --> <!-- Scrollable Form Body -->
<div class="flex-1 overflow-y-auto p-6"> <div class="flex-1 overflow-y-auto p-6">
<!-- <!--
STEP 1: Alapadatok (Vehicle, Category, Date) STEP 1: Szolgáltató & Alapadatok (Provider, Vehicle, Category, Date)
--> -->
<div v-if="currentStep === 0" class="space-y-5"> <div v-if="currentStep === 0" class="space-y-5">
<!-- Vehicle (read-only display) --> <!-- Provider Typeahead (Step 1 - first field) -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.provider') }}</label>
<ProviderAutocomplete
v-model="selectedProvider"
@select="onProviderSelect"
@clear="onProviderClear"
@open-quick-add="onOpenQuickAdd"
/>
<p class="mt-1 text-xs text-slate-400">{{ t('costWizard.providerHint') }}</p>
</div>
<!-- P0 EPIC: Global Vehicle Selection (when no vehicle prop) -->
<div v-if="userVehicles.length > 0">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectVehicle') }}</label>
<select
v-model="selectedVehicleId"
required
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option value="" disabled>{{ t('costWizard.selectVehicle') }}...</option>
<option
v-for="v in userVehicles"
:key="v.id"
:value="v.id"
>
{{ vehicleDisplayName(v) }}
</option>
</select>
</div>
<!-- Fallback: read-only display when vehicle is passed via prop -->
<div v-else-if="props.vehicle">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectVehicle') }}</label> <label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectVehicle') }}</label>
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600"> <div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span> <span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
</div> </div>
</div> </div>
<!-- Main Category --> <!-- P0 EPIC: Grouped Categories (Main + Subcategories in optgroup) -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectCategory') }}</label> <label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectCategory') }}</label>
<select <select
@@ -89,8 +119,8 @@
</select> </select>
</div> </div>
<!-- Sub Category --> <!-- P0 EPIC: Grouped Subcategories (shown when a main category is selected) -->
<div v-if="subCategories.length > 0"> <div v-if="groupedSubCategories.length > 0">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectSubCategory') }}</label> <label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectSubCategory') }}</label>
<select <select
v-model="form.subCategory" v-model="form.subCategory"
@@ -98,13 +128,20 @@
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all" class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
> >
<option value="" disabled>{{ t('costWizard.selectSubCategory') }}...</option> <option value="" disabled>{{ t('costWizard.selectSubCategory') }}...</option>
<!-- Group subcategories by their logical grouping -->
<optgroup
v-for="group in groupedSubCategories"
:key="group.label"
:label="group.label"
>
<option <option
v-for="sub in subCategories" v-for="sub in group.items"
:key="sub.id" :key="sub.id"
:value="sub.id" :value="sub.id"
> >
{{ sub.name }} {{ sub.name }}
</option> </option>
</optgroup>
</select> </select>
</div> </div>
@@ -119,9 +156,9 @@
/> />
</div> </div>
<!-- Mileage (shown for fuel/refueling) --> <!-- P0 EPIC: Universal Odometer (always visible, not just for fuel) -->
<div v-if="form.mainCategory === '1'" class="pt-2 border-t border-slate-100"> <div class="pt-2 border-t border-slate-100">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Megtett km (óraállás)</label> <label class="block text-sm font-semibold text-slate-700 mb-1.5">Km óra állás <span class="text-xs text-slate-400 font-normal">(opcionális)</span></label>
<input <input
v-model.number="form.mileageAtCost" v-model.number="form.mileageAtCost"
type="number" type="number"
@@ -130,12 +167,17 @@
placeholder="pl. 123456" placeholder="pl. 123456"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all" class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/> />
<p class="mt-1 text-xs text-slate-400">Add meg a jármű óraállását tankoláskor</p> <!-- Odometer hint: show last known mileage for the selected vehicle -->
<p v-if="odometerHint !== null" class="mt-1 text-xs text-slate-500 flex items-center gap-1">
<span>📏</span> Előző állás: <strong>{{ odometerHint.toLocaleString() }} km</strong>
</p>
<p v-else-if="odometerLoading" class="mt-1 text-xs text-slate-400 italic">Km óra állás betöltése...</p>
<p v-else class="mt-1 text-xs text-slate-400">Add meg a jármű óraállását a költség rögzítésekor</p>
</div> </div>
<!-- Liters (shown for fuel/refueling) --> <!-- Liters / kWh (shown only for fuel/charging subcategories) -->
<div v-if="form.mainCategory === '1'" class="pt-2"> <div v-if="isFuelCategoryVisible" class="pt-2">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Mennyiség (liter)</label> <label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ fuelUnitLabel }}</label>
<input <input
v-model.number="form.liters" v-model.number="form.liters"
type="number" type="number"
@@ -144,7 +186,7 @@
placeholder="pl. 45.5" placeholder="pl. 45.5"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all" class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/> />
<p class="mt-1 text-xs text-slate-400">Tankolt üzemanyag mennyisége literben</p> <p class="mt-1 text-xs text-slate-400">{{ fuelUnitHint }}</p>
</div> </div>
</div> </div>
@@ -214,56 +256,9 @@
</div> </div>
<!-- <!--
STEP 3: Admin & Szállító (Vendor, Invoice, Deadline) STEP 3: Számla adatok (Invoice, Payment, Deadline)
--> -->
<div v-if="currentStep === 2" class="space-y-5"> <div v-if="currentStep === 2" class="space-y-5">
<!-- Vendor autocomplete with free-text fallback -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.vendor') }}</label>
<div class="relative">
<input
v-model="vendorSearch"
type="text"
:placeholder="t('costWizard.vendorPlaceholder')"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
@input="onVendorSearchInput"
@focus="showVendorDropdown = true"
@blur="onVendorBlur"
/>
<!-- Dropdown suggestions -->
<div
v-if="showVendorDropdown && filteredVendors.length > 0"
class="absolute z-20 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
>
<button
v-for="vendor in filteredVendors"
:key="vendor.organization_id"
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50 hover:text-sf-accent transition-colors border-b border-slate-100 last:border-b-0"
@mousedown.prevent="selectVendor(vendor)"
>
<span class="font-medium">{{ vendor.display_name || vendor.name }}</span>
<span v-if="vendor.tax_number" class="ml-2 text-xs text-slate-400">({{ vendor.tax_number }})</span>
</button>
</div>
</div>
<!-- Selected vendor badge -->
<div
v-if="selectedVendor"
class="mt-2 inline-flex items-center gap-2 rounded-full bg-sf-accent/10 border border-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent"
>
<span>{{ selectedVendor.display_name || selectedVendor.name }}</span>
<button
class="ml-1 text-sf-accent/60 hover:text-sf-accent"
@click="clearVendor"
>
</button>
</div>
<p v-else-if="vendorSearch && !selectedVendor" class="mt-1 text-xs text-slate-400">
{{ t('costWizard.vendorPlaceholder') }}
</p>
</div>
<!-- Invoice Number --> <!-- Invoice Number -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.invoiceNumber') }}</label> <label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.invoiceNumber') }}</label>
@@ -412,6 +407,14 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Provider Quick Add Modal (Teleported) -->
<ProviderQuickAddModal
:is-open="showQuickAddModal"
:prefill-name="pendingNewProviderName"
@close="onQuickAddClose"
@saved="onQuickAddSaved"
/>
</Teleport> </Teleport>
</template> </template>
@@ -420,13 +423,18 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useCostStore } from '../../stores/cost' import { useCostStore } from '../../stores/cost'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle'
import api from '../../api/axios' import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle' import type { Vehicle } from '../../stores/vehicle'
import { getLocalDateString } from '../../services/dateUtils' import { getLocalDateString } from '../../services/dateUtils'
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
const { t } = useI18n() const { t } = useI18n()
const costStore = useCostStore() const costStore = useCostStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
// ── Props & Emits ── // ── Props & Emits ──
const props = defineProps<{ const props = defineProps<{
@@ -468,27 +476,98 @@ const form = reactive({
liters: null as number | null, liters: null as number | null,
}) })
// ── Vendor State ── // ── Provider State (using ProviderAutocomplete) ──
interface VendorOption { const selectedProvider = ref<ProviderSearchResult | null>(null)
organization_id: number
name: string function onProviderSelect(provider: ProviderSearchResult) {
display_name: string | null selectedProvider.value = provider
tax_number: string | null
} }
const vendorSearch = ref('') function onProviderClear() {
const vendors = ref<VendorOption[]>([]) selectedProvider.value = null
const filteredVendors = ref<VendorOption[]>([]) }
const selectedVendor = ref<VendorOption | null>(null)
const showVendorDropdown = ref(false)
/** Normalize a provider search result or organization object to VendorOption */ // ── P0 BUGFIX (2026-07-10): Provider Quick Add Modal ──
function toVendorOption(item: any): VendorOption { const showQuickAddModal = ref(false)
return { const pendingNewProviderName = ref('')
organization_id: item.organization_id ?? item.id,
name: item.name || '', /**
display_name: item.display_name || item.name || null, * Called when ProviderAutocomplete emits 'open-quick-add'.
tax_number: item.tax_number || null, * Opens the ProviderQuickAddModal so the user can create a new provider.
*/
function onOpenQuickAdd() {
// Pre-fill with the current search query if available
pendingNewProviderName.value = selectedProvider.value?.name || ''
showQuickAddModal.value = true
}
/**
* Called when ProviderQuickAddModal emits 'close' (user dismissed without saving).
*/
function onQuickAddClose() {
showQuickAddModal.value = false
pendingNewProviderName.value = ''
}
/**
* Called when ProviderQuickAddModal emits 'saved' after successful creation.
* Auto-selects the newly created provider.
*/
function onQuickAddSaved(provider: { id: number; name: string }) {
showQuickAddModal.value = false
pendingNewProviderName.value = ''
// Auto-select the newly created provider
selectedProvider.value = {
id: provider.id,
name: provider.name,
category: provider.category || null,
specialization: [],
city: provider.city || null,
address: provider.address || null,
source: 'crowd_added',
is_verified: false,
rating: null,
}
}
// ── P0 EPIC: Global Vehicle Selection ──
const selectedVehicleId = ref<string>('')
const userVehicles = ref<Vehicle[]>([])
/**
* Resolve the effective vehicle for the expense.
* Priority:
* 1. If a vehicle prop is passed, use it.
* 2. If the user selected a vehicle from the dropdown, find it in the list.
* 3. Fallback to null.
*/
const effectiveVehicle = computed<Vehicle | null>(() => {
if (props.vehicle) return props.vehicle
if (selectedVehicleId.value) {
return userVehicles.value.find(v => v.id === selectedVehicleId.value) || null
}
return null
})
function vehicleDisplayName(v: Vehicle): string {
const plate = v.license_plate || ''
const brand = v.brand || ''
const model = v.model || ''
return `${plate} (${brand} ${model})`.trim() || v.name || v.id
}
async function fetchUserVehicles() {
try {
// If the vehicle store already has vehicles, use them
if (vehicleStore.vehicles.length > 0) {
userVehicles.value = vehicleStore.vehicles
} else {
await vehicleStore.fetchVehicles()
userVehicles.value = vehicleStore.vehicles
}
} catch (err) {
console.error('[CostEntryWizard] Failed to fetch vehicles:', err)
userVehicles.value = []
} }
} }
@@ -505,9 +584,47 @@ interface CategoryOption {
parent_id: number | null parent_id: number | null
} }
interface SubCategoryGroup {
label: string
items: CategoryOption[]
}
const mainCategories = ref<CategoryOption[]>([]) const mainCategories = ref<CategoryOption[]>([])
const subCategories = ref<CategoryOption[]>([]) const subCategories = ref<CategoryOption[]>([])
// ── P0 EPIC: Grouped Subcategories ──
/**
* Groups subcategories logically by their parent category name.
* This creates <optgroup> sections in the UI for better UX.
*/
const groupedSubCategories = computed<SubCategoryGroup[]>(() => {
if (subCategories.value.length === 0) return []
// Build a map of parent_id -> parent_name from mainCategories
const parentMap = new Map<number, string>()
for (const cat of mainCategories.value) {
parentMap.set(cat.id, cat.name)
}
// Group subcategories by their parent_id
const groups = new Map<string, CategoryOption[]>()
for (const sub of subCategories.value) {
const parentName = parentMap.get(sub.parent_id ?? 0) || 'Egyéb'
if (!groups.has(parentName)) {
groups.set(parentName, [])
}
groups.get(parentName)!.push(sub)
}
// Convert to array of SubCategoryGroup, sorted by parent name
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, items]) => ({
label,
items: items.sort((a, b) => a.name.localeCompare(b.name)),
}))
})
// ── Computed ── // ── Computed ──
const vehicleLabel = computed(() => { const vehicleLabel = computed(() => {
if (!props.vehicle) return t('dashboard.noVehicleSelected') if (!props.vehicle) return t('dashboard.noVehicleSelected')
@@ -517,6 +634,95 @@ const vehicleLabel = computed(() => {
return `${plate} (${brand} ${model})`.trim() return `${plate} (${brand} ${model})`.trim()
}) })
// ── P0 EPIC: Universal Odometer Hint ──
const odometerHint = ref<number | null>(null)
const odometerLoading = ref(false)
/**
* Fetch the last known odometer reading for the selected vehicle.
* Re-uses the same logic from SimpleFuelModal: fetches from /assets/vehicles/{id}
* which returns current_mileage (synced from odometer_readings on expense creation).
*/
async function fetchLastOdometer() {
const vehicle = effectiveVehicle.value
if (!vehicle?.id) {
odometerHint.value = null
return
}
odometerLoading.value = true
try {
const res = await api.get(`/assets/vehicles/${vehicle.id}`)
const v = res.data as { current_mileage?: number | null }
if (v.current_mileage != null && v.current_mileage > 0) {
odometerHint.value = v.current_mileage
} else {
odometerHint.value = null
}
} catch {
odometerHint.value = null
} finally {
odometerLoading.value = false
}
}
// ── P0 BUGFIX: Fuel Category Detection ──
/**
* Strictly checks if the selected subcategory is a fuel or charging category.
* Only OPERATION_FUEL (Üzemanyag) and OPERATION_ELECTRIC (Elektromos töltés)
* should show the quantity/unit-price fields — NOT Insurance, Financing, etc.
*/
const FUEL_SUBCATEGORY_CODES = new Set(['OPERATION_FUEL', 'OPERATION_ELECTRIC'])
/**
* Resolves the selected subcategory's code from the flattened category list.
*/
const selectedSubCategoryCode = computed<string | null>(() => {
if (!form.subCategory) return null
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
const found = allCats.find((c) => c.id === Number(form.subCategory))
return found?.code || null
})
/**
* True when a fuel/charging subcategory is selected.
* Used to show/hide the quantity (Liter/kWh) and unit-price fields.
*/
const isFuelCategoryVisible = computed<boolean>(() => {
const code = selectedSubCategoryCode.value
return code !== null && FUEL_SUBCATEGORY_CODES.has(code)
})
/**
* Dynamic label for the quantity input.
* - Electric vehicle (fuel_type in ['elektromos','EV','BEV']) → "Töltött mennyiség (kWh)"
* - Everything else (benzin, dízel, etc.) → "Tankolt mennyiség (Liter)"
*/
const fuelUnitLabel = computed<string>(() => {
const vehicle = effectiveVehicle.value
if (!vehicle) return 'Mennyiség (liter)'
const ft = (vehicle.fuel_type || '').toLowerCase()
if (['elektromos', 'ev', 'bev'].includes(ft)) {
return 'Töltött mennyiség (kWh)'
}
return 'Tankolt mennyiség (Liter)'
})
/**
* Dynamic hint text below the quantity input.
*/
const fuelUnitHint = computed<string>(() => {
const vehicle = effectiveVehicle.value
if (!vehicle) return 'Tankolt üzemanyag mennyisége literben'
const ft = (vehicle.fuel_type || '').toLowerCase()
if (['elektromos', 'ev', 'bev'].includes(ft)) {
return 'Elektromos töltés mennyisége kWh-ban'
}
return 'Tankolt üzemanyag mennyisége literben'
})
// ── Stepper Methods ── // ── Stepper Methods ──
function stepClass(idx: number): string { function stepClass(idx: number): string {
if (idx < currentStep.value) return 'bg-sf-accent text-white' if (idx < currentStep.value) return 'bg-sf-accent text-white'
@@ -535,6 +741,10 @@ function validateStep(step: number): boolean {
errorMessage.value = null errorMessage.value = null
if (step === 0) { if (step === 0) {
if (!effectiveVehicle.value) {
errorMessage.value = 'Kérlek válassz járművet.'
return false
}
if (!form.mainCategory) { if (!form.mainCategory) {
errorMessage.value = 'Kérlek válassz kategóriát.' errorMessage.value = 'Kérlek válassz kategóriát.'
return false return false
@@ -615,19 +825,6 @@ function onMainCategoryChange() {
subCategories.value = allCats.filter( subCategories.value = allCats.filter(
(c) => c.parent_id === Number(form.mainCategory) (c) => c.parent_id === Number(form.mainCategory)
) )
// P0 FIX: Filter vendors by org_type when INSURANCE category is selected
// Insurance companies are stored as organizations with org_type='service_provider'
// in the fleet.organizations table. The /providers/search endpoint now supports
// org_type parameter to filter by organization type.
const catId = Number(form.mainCategory)
if (catId === 4) {
// INSURANCE category — show only insurance-related providers
fetchVendors('service_provider')
} else {
// Other categories — show all service providers
fetchVendors()
}
} }
// ── Financial Calculation Methods ── // ── Financial Calculation Methods ──
@@ -654,167 +851,80 @@ function onGrossAmountChange() {
} }
} }
// ── Vendor Methods ── // ── Handle Submit ──
async function fetchVendors(orgType?: string) {
try {
// Use /providers/search to list service providers (the /organizations endpoint
// does not support org_type=SERVICE_PROVIDER filtering — returns 404)
const params: Record<string, any> = { limit: 100 }
if (orgType) {
params.org_type = orgType
}
const res = await api.get('/providers/search', { params })
const data = res.data
// ProviderSearchResponse returns { results: [...], total, page, per_page }
const rawList = data.results || data.data || data.items || data || []
vendors.value = rawList.map(toVendorOption)
} catch (err) {
console.error('[CostEntryWizard] Failed to load vendors:', err)
vendors.value = []
}
}
function onVendorSearchInput() {
showVendorDropdown.value = true
const query = vendorSearch.value.toLowerCase().trim()
if (!query) {
filteredVendors.value = vendors.value
return
}
filteredVendors.value = vendors.value.filter(
(v) =>
(v.display_name || v.name).toLowerCase().includes(query) ||
(v.tax_number && v.tax_number.includes(query))
)
}
function selectVendor(vendor: VendorOption) {
selectedVendor.value = vendor
vendorSearch.value = vendor.display_name || vendor.name
showVendorDropdown.value = false
}
function clearVendor() {
selectedVendor.value = null
vendorSearch.value = ''
filteredVendors.value = vendors.value
}
function onVendorBlur() {
// Delay to allow click on dropdown item
setTimeout(() => {
showVendorDropdown.value = false
}, 200)
}
// ── File Methods ──
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) {
for (const file of Array.from(input.files)) {
attachedFiles.value.push(file)
}
}
// Reset input so same file can be re-selected
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function onFileDrop(event: DragEvent) {
isDragOver.value = false
if (event.dataTransfer?.files) {
for (const file of Array.from(event.dataTransfer.files)) {
attachedFiles.value.push(file)
}
}
}
function removeFile(idx: number) {
attachedFiles.value.splice(idx, 1)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// ── Submit ──
async function handleSubmit() { async function handleSubmit() {
if (!props.vehicle) { if (!validateStep(currentStep.value)) return
const vehicle = effectiveVehicle.value
if (!vehicle) {
errorMessage.value = 'Nincs kiválasztva jármű.' errorMessage.value = 'Nincs kiválasztva jármű.'
return return
} }
if (!validateStep(currentStep.value)) return
isSubmitting.value = true isSubmitting.value = true
errorMessage.value = null errorMessage.value = null
try { try {
// P0 FIX: Auto-set payment_deadline for CASH/CARD // P0 BUGFIX (2026-07-10): Fix provider payload wiring.
// If payment method is CASH or CARD, the deadline equals invoice_date or date // Determine provider fields (P0 Hybrid Vendor Refactor)
if (form.paymentMethod === 'CASH' || form.paymentMethod === 'CARD') { const hasSelectedProvider = selectedProvider.value?.id != null && selectedProvider.value.id > 0
form.paymentDeadline = form.invoiceDate || form.date || '' const providerSource = selectedProvider.value?.source || null
// P0 BUGFIX: Capture the provider name regardless of selection state.
// When a provider IS selected, use its name. When no provider is selected,
// the rawVendorName will be null (user typed nothing or cleared).
const rawVendorName = selectedProvider.value?.name || null
// P0 BUGFIX: Source-aware vendor field routing.
// - 'verified_org' → vendor_organization_id (KYC-verified organizations)
// - 'crowd_added' | 'staged_data' → service_provider_id (community/robot-added)
// - No selection → external_vendor_name (raw string, triggers Delayed New Provider flow)
let vendorOrganizationId: number | null = null
let serviceProviderId: number | null = null
let externalVendorName: string | null = null
if (hasSelectedProvider) {
if (providerSource === 'verified_org') {
vendorOrganizationId = selectedProvider.value!.id
} else if (providerSource === 'crowd_added' || providerSource === 'staged_data') {
serviceProviderId = selectedProvider.value!.id
} else {
// Fallback for unknown source: send as external_vendor_name
externalVendorName = rawVendorName
}
} else if (rawVendorName) {
// User typed a name but didn't select from dropdown
externalVendorName = rawVendorName
} }
// Determine category_id // Build payload matching backend AssetCostCreate schema
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
let categoryId = Number(form.subCategory || form.mainCategory) || 10
// Build payload matching AssetCostBase schema
const payload: Record<string, any> = { const payload: Record<string, any> = {
asset_id: props.vehicle.id, asset_id: vehicle.id,
category_id: categoryId, category_id: form.subCategory ? Number(form.subCategory) : Number(form.mainCategory),
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */ /** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
amount_gross: form.grossAmount > 0 ? form.grossAmount : form.netAmount, amount_gross: form.grossAmount > 0 ? form.grossAmount : Math.round(form.netAmount * (1 + form.vatRate / 100) * 100) / 100,
currency: form.currency, currency: form.currency,
date: new Date(form.date).toISOString(), date: new Date(form.date).toISOString(),
mileage_at_cost: form.mileageAtCost || null, mileage_at_cost: form.mileageAtCost != null && form.mileageAtCost > 0 ? form.mileageAtCost : null,
description: `Számla: ${form.invoiceNumber || 'N/A'}`, description: form.invoiceNumber ? `Számla: ${form.invoiceNumber}` : null,
// P0 FIX: vendor_organization_id and external_vendor_name at root level // P0 BUGFIX: Source-aware vendor fields at root level
// so the backend Pydantic schema writes them to the dedicated DB columns. vendor_organization_id: vendorOrganizationId,
vendor_organization_id: selectedVendor.value?.organization_id || null, service_provider_id: serviceProviderId,
external_vendor_name: selectedVendor.value external_vendor_name: externalVendorName,
? (selectedVendor.value.display_name || selectedVendor.value.name)
: (vendorSearch.value || null),
data: { data: {
mileage_at_cost: form.mileageAtCost || null,
liters: form.liters || null,
main_category_id: form.mainCategory || null,
sub_category_id: form.subCategory || null,
net_amount: form.netAmount, net_amount: form.netAmount,
vat_rate: form.vatRate, vat_rate: form.vatRate,
invoice_number: form.invoiceNumber || null, invoice_number: form.invoiceNumber || null,
invoice_date: form.invoiceDate ? new Date(form.invoiceDate).toISOString() : null, invoice_date: form.invoiceDate || null,
payment_deadline: form.paymentDeadline ? new Date(form.paymentDeadline).toISOString() : null,
payment_method: form.paymentMethod, payment_method: form.paymentMethod,
vendor_id: selectedVendor.value?.organization_id || null, payment_deadline: form.paymentDeadline || null,
vendor_name: selectedVendor.value liters: form.liters,
? (selectedVendor.value.display_name || selectedVendor.value.name) mileage_at_cost: form.mileageAtCost,
: (vendorSearch.value || null), vendor_id: selectedProvider.value?.id || null,
file_count: attachedFiles.value.length, vendor_name: selectedProvider.value?.name || null,
file_names: attachedFiles.value.map((f) => f.name), vendor_source: providerSource,
}, },
} }
// CRITICAL FIX: organization_id MUST be the user's active organization (fleet manager),
// NOT the vendor's organization. The vendor is stored separately via vendor_organization_id.
// If we set organization_id to the vendor's org, the expense becomes invisible
// when the user queries GET /expenses?organization_id=<their_org>.
const userOrgId = authStore.user?.active_organization_id
if (userOrgId != null) {
payload.organization_id = userOrgId
}
// P0 FIX: Distinguish between CREATE and UPDATE based on editCost prop.
// If editCost exists, call updateExpense(id, payload) via PUT.
// Otherwise, call addExpense(payload) via POST.
let result let result
if (props.editCost?.id) { if (props.editCost?.id) {
result = await costStore.updateExpense(props.editCost.id, payload) result = await costStore.updateExpense(props.editCost.id, payload)
@@ -826,20 +936,17 @@ async function handleSubmit() {
resetForm() resetForm()
emit('saved') emit('saved')
} else { } else {
errorMessage.value = costStore.lastError || t('costWizard.error') errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
} }
} catch (err: any) { } catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
t('costWizard.error')
errorMessage.value = message
console.error('[CostEntryWizard] Submit error:', err) console.error('[CostEntryWizard] Submit error:', err)
errorMessage.value = err?.response?.data?.detail || err?.message || 'Hiba történt a mentés során.'
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
} }
} }
// ── Reset Form ──
function resetForm() { function resetForm() {
form.date = getLocalDateString() form.date = getLocalDateString()
form.mainCategory = '' form.mainCategory = ''
@@ -854,147 +961,90 @@ function resetForm() {
form.paymentMethod = 'TRANSFER' form.paymentMethod = 'TRANSFER'
form.mileageAtCost = null form.mileageAtCost = null
form.liters = null form.liters = null
vendorSearch.value = '' selectedProvider.value = null
selectedVendor.value = null selectedVehicleId.value = ''
attachedFiles.value = [] attachedFiles.value = []
currentStep.value = 0
errorMessage.value = null errorMessage.value = null
currentStep.value = 0
} }
// ── Edit Cost Hydration ── // ── Edit Cost Hydration ──
/**
* Biztonságos dátum formázó: bármilyen ISO stringből (pl. "2026-06-15T00:00:00")
* vagy raw JSONB értékből kivágja az első 10 karaktert (YYYY-MM-DD).
* Ha a bemenet undefined/null/üres, üres stringet ad vissza.
*/
const safeFormatDate = (dateStr: string | undefined | null): string => {
if (!dateStr) return ''
return dateStr.split('T')[0].substring(0, 10)
}
watch(() => props.editCost, (cost) => { watch(() => props.editCost, (cost) => {
if (!cost) return if (!cost) return
// Hydrate form from existing expense data
const editData = cost form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
const data = editData.data || {} form.mainCategory = String(cost.category_id || '')
form.subCategory = String(cost.sub_category_id || '')
// ── Date: slice T for HTML5 date input ── form.netAmount = cost.data?.net_amount || cost.amount_net || 0
// Ensure we always get a valid YYYY-MM-DD string or empty string form.vatRate = cost.data?.vat_rate || 27
form.date = safeFormatDate(editData.date) form.grossAmount = cost.amount_gross || 0
form.currency = cost.currency || 'HUF'
// ── Invoice Number: check BOTH top-level and JSONB ── form.invoiceNumber = cost.data?.invoice_number || ''
form.invoiceNumber = editData.invoice_number || data.invoice_number || '' form.invoiceDate = cost.data?.invoice_date || ''
form.paymentDeadline = cost.data?.payment_deadline || ''
// ── Category ── form.paymentMethod = cost.data?.payment_method || 'TRANSFER'
if (editData.category_id) { form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null
form.mainCategory = String(editData.category_id) form.liters = cost.data?.liters || null
}
if (data.main_category_id) {
form.mainCategory = String(data.main_category_id)
}
if (data.sub_category_id) {
form.subCategory = String(data.sub_category_id)
}
// ── Financial ──
if (editData.amount_gross) {
form.grossAmount = Number(editData.amount_gross)
}
if (data.net_amount) {
form.netAmount = Number(data.net_amount)
}
if (data.vat_rate) {
form.vatRate = Number(data.vat_rate)
}
if (editData.currency) {
form.currency = editData.currency
}
// ── Dates (P0 FIX: robust hydration for YYYY-MM-DD) ──
// invoiceDate: extract from JSONB data.invoice_date or top-level
form.invoiceDate = safeFormatDate(data.invoice_date || editData.invoice_date)
// paymentDeadline: extract from JSONB data.payment_deadline or top-level
form.paymentDeadline = safeFormatDate(data.payment_deadline || editData.payment_deadline)
// ── Payment Method ──
if (data.payment_method) {
form.paymentMethod = data.payment_method
}
// ── Vendor Hydration: check BOTH top-level and JSONB ──
const vendorOrgId = editData.vendor_organization_id || data.vendor_id || null
const vendorName = editData.external_vendor_name || data.vendor_name || ''
if (vendorOrgId) {
// Try to find the vendor in the loaded list
const vendor = vendors.value.find(
(v) => v.organization_id === vendorOrgId
)
if (vendor) {
selectedVendor.value = vendor
vendorSearch.value = vendor.display_name || vendor.name
} else {
// Reconstruct from raw data so the UI shows the saved provider
selectedVendor.value = {
id: vendorOrgId,
organization_id: vendorOrgId,
name: vendorName,
display_name: vendorName || null,
tax_number: null,
} as any
vendorSearch.value = vendorName
}
} else if (vendorName) {
vendorSearch.value = vendorName
}
// ── Mileage & Liters ──
if (editData.mileage_at_cost || data.mileage_at_cost) {
form.mileageAtCost = Number(editData.mileage_at_cost || data.mileage_at_cost)
}
if (data.liters) {
form.liters = Number(data.liters)
}
// Reset step to beginning for editing
currentStep.value = 0
errorMessage.value = null
}, { immediate: true })
// ── Gross-to-Net Auto Calculation ──
// When the user types in the Gross Amount, automatically calculate and set the Net Amount
watch([() => form.grossAmount, () => form.vatRate], ([newGross, newVat]) => {
if (newGross > 0 && newVat > 0) {
form.netAmount = Math.round((newGross / (1 + newVat / 100)) * 100) / 100
}
}) })
// ── File Methods ──
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileDrop(e: DragEvent) {
isDragOver.value = false
const files = e.dataTransfer?.files
if (files) {
attachedFiles.value.push(...Array.from(files))
}
}
function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) {
attachedFiles.value.push(...Array.from(input.files))
}
}
function removeFile(idx: number) {
attachedFiles.value.splice(idx, 1)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
// ── Lifecycle ── // ── Lifecycle ──
watch(() => props.isOpen, (open) => { onMounted(() => {
if (open) {
currentStep.value = 0
form.date = getLocalDateString()
errorMessage.value = null
fetchCategories() fetchCategories()
// Load all vendors by default (no org_type filter) fetchUserVehicles()
fetchVendors()
}
}) })
onMounted(() => { // Fetch odometer hint when vehicle changes
if (props.isOpen) { watch(effectiveVehicle, () => {
fetchLastOdometer()
})
// Reset form when modal opens
watch(() => props.isOpen, (open) => {
if (open) {
if (!props.editCost) {
resetForm()
}
fetchUserVehicles()
fetchCategories() fetchCategories()
fetchVendors()
} }
}) })
</script> </script>
<style scoped> <style scoped>
@keyframes fadeInUp { @keyframes fade-in-up {
from { from {
opacity: 0; opacity: 0;
transform: translateY(20px) scale(0.96); transform: translateY(20px) scale(0.97);
} }
to { to {
opacity: 1; opacity: 1;
@@ -1002,6 +1052,6 @@ onMounted(() => {
} }
} }
.animate-fade-in-up { .animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards; animation: fade-in-up 0.25s ease-out;
} }
</style> </style>

View File

@@ -22,6 +22,17 @@
<!-- Form Body --> <!-- Form Body -->
<form @submit.prevent="handleSubmit" class="p-6 space-y-5"> <form @submit.prevent="handleSubmit" class="p-6 space-y-5">
<!-- Provider Typeahead (first field) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Szolgáltató</label>
<ProviderAutocomplete
v-model="selectedProvider"
@select="onProviderSelect"
@clear="onProviderClear"
/>
<p class="mt-1 text-xs text-slate-400">Válaszd ki a szolgáltatót, vagy hagyd üresen</p>
</div>
<!-- Vehicle selector (read-only display) --> <!-- Vehicle selector (read-only display) -->
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600"> <div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span> <span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
@@ -150,6 +161,8 @@ import { useCostStore } from '../../stores/cost'
import api from '../../api/axios' import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle' import type { Vehicle } from '../../stores/vehicle'
import { getLocalDateString } from '../../services/dateUtils' import { getLocalDateString } from '../../services/dateUtils'
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
const props = defineProps<{ const props = defineProps<{
isOpen: boolean isOpen: boolean
@@ -177,6 +190,17 @@ const form = reactive({
const errorMessage = ref<string | null>(null) const errorMessage = ref<string | null>(null)
// ── Provider State ──
const selectedProvider = ref<ProviderSearchResult | null>(null)
function onProviderSelect(provider: ProviderSearchResult) {
selectedProvider.value = provider
}
function onProviderClear() {
selectedProvider.value = null
}
// ── Category Cascade State ── // ── Category Cascade State ──
interface CategoryOption { interface CategoryOption {
id: number id: number
@@ -237,6 +261,26 @@ function onMainCategoryChange() {
) )
} }
// ── Gamification Toast ──
function showGamificationToast() {
const toast = document.createElement('div')
toast.className =
'fixed top-6 right-6 z-[99999] bg-emerald-600 text-white px-6 py-4 rounded-xl shadow-2xl text-sm font-medium animate-slide-in flex items-center gap-3'
toast.innerHTML = `
<span class="text-lg">🎉</span>
<div>
<p class="font-semibold">Költség sikeresen rögzítve!</p>
<p class="text-emerald-100 text-xs mt-0.5">A pontok jóváírásra kerültek.</p>
</div>
`
document.body.appendChild(toast)
setTimeout(() => {
toast.classList.add('animate-slide-out')
setTimeout(() => toast.remove(), 300)
}, 4000)
}
async function handleSubmit() { async function handleSubmit() {
if (!props.vehicle) { if (!props.vehicle) {
errorMessage.value = 'Nincs kiválasztva jármű.' errorMessage.value = 'Nincs kiválasztva jármű.'
@@ -261,7 +305,7 @@ async function handleSubmit() {
categoryId = Number(form.subCategory || form.mainCategory) || 10 categoryId = Number(form.subCategory || form.mainCategory) || 10
} }
const payload = { const payload: Record<string, any> = {
asset_id: props.vehicle.id, asset_id: props.vehicle.id,
// organization_id is omitted — backend auto-resolves from asset // organization_id is omitted — backend auto-resolves from asset
category_id: categoryId, category_id: categoryId,
@@ -271,17 +315,23 @@ async function handleSubmit() {
date: new Date(form.date).toISOString(), date: new Date(form.date).toISOString(),
mileage_at_cost: null, mileage_at_cost: null,
description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'), description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'),
// P0 FIX: vendor fields at root level
vendor_organization_id: selectedProvider.value?.id || null,
external_vendor_name: selectedProvider.value?.name || null,
data: { data: {
is_recurring: form.isRecurring, is_recurring: form.isRecurring,
category_id: categoryId, category_id: categoryId,
main_category_id: form.mainCategory || null, main_category_id: form.mainCategory || null,
sub_category_id: form.subCategory || null, sub_category_id: form.subCategory || null,
vendor_id: selectedProvider.value?.id || null,
vendor_name: selectedProvider.value?.name || null,
}, },
} }
const result = await costStore.addExpense(payload) const result = await costStore.addExpense(payload)
if (result) { if (result) {
showGamificationToast()
// Reset form // Reset form
form.date = getLocalDateString() form.date = getLocalDateString()
form.amount = 0 form.amount = 0
@@ -289,6 +339,7 @@ async function handleSubmit() {
form.subCategory = '' form.subCategory = ''
form.description = '' form.description = ''
form.isRecurring = false form.isRecurring = false
selectedProvider.value = null
emit('saved') emit('saved')
} else { } else {
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.' errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
@@ -304,6 +355,7 @@ watch(() => props.isOpen, (open) => {
if (open) { if (open) {
form.date = getLocalDateString() form.date = getLocalDateString()
errorMessage.value = null errorMessage.value = null
selectedProvider.value = null
} }
}) })

View File

@@ -21,6 +21,17 @@
<!-- Form Body --> <!-- Form Body -->
<form @submit.prevent="handleSubmit" class="p-6 space-y-5"> <form @submit.prevent="handleSubmit" class="p-6 space-y-5">
<!-- Provider Typeahead (first field) -->
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Szolgáltató</label>
<ProviderAutocomplete
v-model="selectedProvider"
@select="onProviderSelect"
@clear="onProviderClear"
/>
<p class="mt-1 text-xs text-slate-400">Válaszd ki a szolgáltatót, vagy hagyd üresen</p>
</div>
<!-- Vehicle selector (read-only display of selected vehicle) --> <!-- Vehicle selector (read-only display of selected vehicle) -->
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600"> <div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span> <span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
@@ -37,17 +48,48 @@
/> />
</div> </div>
<!-- Amount (HUF) --> <!-- -->
<div> <!-- MAGIC TRIANGLE: Amount Gross, Unit Price, Quantity -->
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg (HUF)</label> <!-- -->
<div class="rounded-lg bg-amber-50 border border-amber-200 p-3">
<p class="text-xs font-semibold text-amber-700 mb-2">💰 Ár háromszög tölts ki kettőt, a harmadik automatikusan számolódik</p>
<!-- Amount Gross with Currency Selector -->
<div class="mb-3">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg</label>
<div class="flex gap-2">
<input <input
v-model.number="form.amount" :value="form.amountGross"
@blur="onAmountGrossBlur"
type="number" type="number"
min="0" min="0"
step="1" step="0.01"
required required
placeholder="pl. 15000" placeholder="pl. 15000"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all" class="flex-1 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all no-spinner"
/>
<!-- P0 BUGFIX (2026-07-10): Currency selector -->
<select
v-model="form.currency"
class="w-20 rounded-xl border border-slate-300 bg-white px-2 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
>
<option value="HUF">HUF</option>
<option value="EUR">EUR</option>
</select>
</div>
</div>
<!-- Unit Price -->
<div class="mb-3">
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Egységár ({{ form.currency }}/L vagy {{ form.currency }}/kWh)</label>
<input
:value="form.unitPrice"
@blur="onUnitPriceBlur"
type="number"
min="0"
step="0.01"
placeholder="pl. 650"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all no-spinner"
/> />
</div> </div>
@@ -55,17 +97,19 @@
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Mennyiség (Liter / kWh)</label> <label class="block text-sm font-semibold text-slate-700 mb-1.5">Mennyiség (Liter / kWh)</label>
<input <input
v-model.number="form.quantity" :value="form.quantity"
@blur="onQuantityBlur"
type="number" type="number"
min="0" min="0"
step="0.1" step="0.01"
required required
placeholder="pl. 45.5" placeholder="pl. 45.5"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all" class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all no-spinner"
/> />
</div> </div>
</div>
<!-- Odometer --> <!-- Odometer with hint -->
<div> <div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Km óra állás</label> <label class="block text-sm font-semibold text-slate-700 mb-1.5">Km óra állás</label>
<input <input
@@ -76,6 +120,11 @@
placeholder="pl. 123456" placeholder="pl. 123456"
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all" class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
/> />
<!-- Odometer hint -->
<p v-if="odometerHint !== null" class="mt-1 text-xs text-slate-500 flex items-center gap-1">
<span>📏</span> Utolsó rögzített km óra állás: <strong>{{ odometerHint.toLocaleString() }} km</strong>
</p>
<p v-else-if="odometerLoading" class="mt-1 text-xs text-slate-400 italic">Km óra állás betöltése...</p>
</div> </div>
<!-- Error message --> <!-- Error message -->
@@ -106,6 +155,16 @@
</form> </form>
</div> </div>
</div> </div>
<!-- -->
<!-- DELAYED NEW PROVIDER: ProviderQuickAddModal -->
<!-- -->
<ProviderQuickAddModal
:is-open="showQuickAddModal"
:prefill-name="pendingNewProviderName"
@close="onQuickAddClose"
@saved="onQuickAddSaved"
/>
</Teleport> </Teleport>
</template> </template>
@@ -115,6 +174,9 @@ import { useCostStore } from '../../stores/cost'
import api from '../../api/axios' import api from '../../api/axios'
import type { Vehicle } from '../../stores/vehicle' import type { Vehicle } from '../../stores/vehicle'
import { getLocalDateString } from '../../services/dateUtils' import { getLocalDateString } from '../../services/dateUtils'
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
const props = defineProps<{ const props = defineProps<{
isOpen: boolean isOpen: boolean
@@ -128,15 +190,181 @@ const emit = defineEmits<{
const costStore = useCostStore() const costStore = useCostStore()
// ── Form State ──
const form = reactive({ const form = reactive({
date: getLocalDateString(), date: getLocalDateString(),
amount: 0, amountGross: 0,
unitPrice: 0,
quantity: 0, quantity: 0,
odometer: 0, odometer: 0,
currency: 'HUF' as string,
}) })
// ── Magic Triangle: Track which field was last manually edited ──
// P0 BUGFIX (2026-07-10): @blur events instead of @input events.
// A lastEditedField csak @blur-kor frissül, így nincs végtelen rekurzió.
const lastEditedField = ref<string | null>(null)
const errorMessage = ref<string | null>(null) const errorMessage = ref<string | null>(null)
// ── Provider State ──
const selectedProvider = ref<ProviderSearchResult | null>(null)
// P0 BUGFIX (2026-07-10): Fix stale provider state.
// A ProviderAutocomplete @select eseménye tiszta overwrite-öt végez.
// A selectedProvider ref teljesen felülíródik az új provider adataival.
function onProviderSelect(provider: ProviderSearchResult) {
selectedProvider.value = provider
}
function onProviderClear() {
selectedProvider.value = null
}
// ── Odometer Hint ──
const odometerHint = ref<number | null>(null)
const odometerLoading = ref(false)
// P0 BUGFIX (2026-07-10): Fix odometer hint — fetch from asset/vehicle endpoint
// instead of /expenses/{asset_id}. The asset endpoint returns current_mileage
// which is the Source of Truth (synced from odometer_readings on expense creation).
async function fetchLastOdometer() {
if (!props.vehicle?.id) {
odometerHint.value = null
return
}
odometerLoading.value = true
try {
// Fetch the asset/vehicle data which contains current_mileage
// This is the performant read-cache — synced automatically when expenses are created.
const res = await api.get(`/assets/vehicles/${props.vehicle.id}`)
// The response is the Vehicle object with current_mileage field
const vehicle = res.data as { current_mileage?: number | null }
if (vehicle.current_mileage != null && vehicle.current_mileage > 0) {
odometerHint.value = vehicle.current_mileage
} else {
odometerHint.value = null
}
} catch {
// Silently fail — odometer hint is a nice-to-have
odometerHint.value = null
} finally {
odometerLoading.value = false
}
}
// ── Magic Triangle: Blur Handlers ──
// P0 BUGFIX (2026-07-10): @blur events instead of @input events.
// A @input események végtelen rekurziót okoztak, mert a számítás
// visszaírta az értékeket, ami újabb @input eseményt triggerelt.
// A @blur esemény csak akkor fut le, amikor a user elhagyja a mezőt,
// így nincs rekurzió.
/**
* Kerekítés 2 tizedesjegyre.
* A Magic Triangle minden számítását ezzel végezzük,
* hogy elkerüljük a lebegőpontos pontatlanságokat.
*/
function roundTo2Decimals(val: number): number {
return Math.round(val * 100) / 100
}
/**
* @blur handler az Összeg (amountGross) mezőhöz.
* Beállítja a lastEditedField értékét, majd meghívja az autoCalculate()-t.
*/
function onAmountGrossBlur(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value) || 0
form.amountGross = val
lastEditedField.value = 'amount_gross'
autoCalculate()
}
/**
* @blur handler az Egységár (unitPrice) mezőhöz.
* Beállítja a lastEditedField értékét, majd meghívja az autoCalculate()-t.
*/
function onUnitPriceBlur(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value) || 0
form.unitPrice = val
lastEditedField.value = 'unit_price'
autoCalculate()
}
/**
* @blur handler a Mennyiség (quantity) mezőhöz.
* Beállítja a lastEditedField értékét, majd meghívja az autoCalculate()-t.
*/
function onQuantityBlur(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value) || 0
form.quantity = val
lastEditedField.value = 'quantity'
autoCalculate()
}
/**
* Magic Triangle: Automatikus számítás a három mező között.
*
* Logika:
* - A user mindig 2 mezőt tölt ki, a harmadik automatikusan számolódik.
* - A lastEditedField alapján tudjuk, melyik mezőt szerkesztette a user.
* - Ha a másik két mező közül legalább az egyik > 0, kiszámoljuk a harmadikat.
* - Prioritás: ha mindkét másik mező > 0, akkor a lastEditedField-et
* számoljuk újra a másik kettőből (pl. user javítja a quantity-t,
* akkor amountGross = unitPrice * quantity).
* - Ha csak az egyik másik mező > 0, akkor a hiányzó harmadikat számoljuk.
*
* Képletek:
* amountGross = unitPrice * quantity
* unitPrice = amountGross / quantity
* quantity = amountGross / unitPrice
*
* P0 BUGFIX (2026-07-10): @blur events — nincs rekurzió.
*/
function autoCalculate() {
const a = form.amountGross
const p = form.unitPrice
const q = form.quantity
// Hány mezőnek van értéke > 0?
const filledCount = [a > 0, p > 0, q > 0].filter(Boolean).length
// Ha már mindhárom ki van töltve, nincs mit számolni
if (filledCount >= 3) return
const edited = lastEditedField.value
if (edited === 'amount_gross') {
// User az összeget szerkesztette → számoljuk az egységárat VAGY a mennyiséget
if (q > 0 && a > 0) {
// Van mennyiség és összeg → egységár = összeg / mennyiség
form.unitPrice = roundTo2Decimals(a / q)
} else if (p > 0 && a > 0) {
// Van egységár és összeg → mennyiség = összeg / egységár
form.quantity = roundTo2Decimals(a / p)
}
} else if (edited === 'unit_price') {
// User az egységárat szerkesztette → számoljuk az összeget VAGY a mennyiséget
if (q > 0 && p > 0) {
// Van mennyiség és egységár → összeg = egységár * mennyiség
form.amountGross = roundTo2Decimals(p * q)
} else if (a > 0 && p > 0) {
// Van összeg és egységár → mennyiség = összeg / egységár
form.quantity = roundTo2Decimals(a / p)
}
} else if (edited === 'quantity') {
// User a mennyiséget szerkesztette → számoljuk az összeget VAGY az egységárat
if (p > 0 && q > 0) {
// Van egységár és mennyiség → összeg = egységár * mennyiség
form.amountGross = roundTo2Decimals(p * q)
} else if (a > 0 && q > 0) {
// Van összeg és mennyiség → egységár = összeg / mennyiség
form.unitPrice = roundTo2Decimals(a / q)
}
}
}
// ── Category Resolution ── // ── Category Resolution ──
const fuelCategoryId = ref<number>(1) // Default fallback const fuelCategoryId = ref<number>(1) // Default fallback
@@ -161,7 +389,16 @@ async function resolveFuelCategory() {
watch(() => props.isOpen, (open) => { watch(() => props.isOpen, (open) => {
if (open) { if (open) {
form.date = getLocalDateString() form.date = getLocalDateString()
form.amountGross = 0
form.unitPrice = 0
form.quantity = 0
form.odometer = 0
form.currency = 'HUF'
lastEditedField.value = null
errorMessage.value = null errorMessage.value = null
selectedProvider.value = null
// Fetch odometer hint on open
fetchLastOdometer()
} }
}) })
@@ -177,6 +414,30 @@ const vehicleLabel = computed(() => {
return `${plate} (${brand} ${model})`.trim() return `${plate} (${brand} ${model})`.trim()
}) })
// ── Delayed New Provider State ──
const showQuickAddModal = ref(false)
const pendingNewProviderName = ref('')
// ── Gamification Toast ──
function showGamificationToast() {
const toast = document.createElement('div')
toast.className =
'fixed top-6 right-6 z-[99999] bg-emerald-600 text-white px-6 py-4 rounded-xl shadow-2xl text-sm font-medium animate-slide-in flex items-center gap-3'
toast.innerHTML = `
<span class="text-lg">🎉</span>
<div>
<p class="font-semibold">Tankolás sikeresen rögzítve!</p>
<p class="text-emerald-100 text-xs mt-0.5">A pontok jóváírásra kerültek.</p>
</div>
`
document.body.appendChild(toast)
setTimeout(() => {
toast.classList.add('animate-slide-out')
setTimeout(() => toast.remove(), 300)
}, 4000)
}
async function handleSubmit() { async function handleSubmit() {
if (!props.vehicle) { if (!props.vehicle) {
errorMessage.value = 'Nincs kiválasztva jármű.' errorMessage.value = 'Nincs kiválasztva jármű.'
@@ -185,36 +446,103 @@ async function handleSubmit() {
errorMessage.value = null errorMessage.value = null
// Determine if we have a selected provider or a raw typed name
const hasSelectedProvider = selectedProvider.value?.id != null && selectedProvider.value.id > 0
const rawVendorName = selectedProvider.value?.name || null
// P0 BUGFIX (2026-07-10): Fix stale provider state.
// A vendor_organization_id-t csak akkor küldjük, ha a provider forrása 'verified_org'.
// Ha a forrás 'crowd_added', akkor service_provider_id-t küldünk.
// A backend FK-safe validációja is véd, de itt a frontend is helyesen küldi.
const providerSource = selectedProvider.value?.source || null
// Build payload: category auto-set to FUEL (resolved dynamically by code) // Build payload: category auto-set to FUEL (resolved dynamically by code)
// organization_id is omitted — backend auto-resolves from asset const payload: Record<string, any> = {
const payload = {
asset_id: props.vehicle.id, asset_id: props.vehicle.id,
category_id: fuelCategoryId.value, // FUEL category (resolved by code) category_id: fuelCategoryId.value, // FUEL category (resolved by code)
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */ /** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
amount_gross: form.amount, amount_gross: form.amountGross,
currency: 'HUF' as const, currency: form.currency,
date: new Date(form.date).toISOString(), date: new Date(form.date).toISOString(),
mileage_at_cost: form.odometer > 0 ? form.odometer : null, mileage_at_cost: form.odometer > 0 ? form.odometer : null,
description: `Tankolás: ${form.quantity} L/kWh`, description: `Tankolás: ${form.quantity} L/kWh`,
// P0 BUGFIX: vendor fields at root level - source-aware routing
vendor_organization_id: hasSelectedProvider && providerSource === 'verified_org' ? selectedProvider.value!.id : null,
service_provider_id: hasSelectedProvider && providerSource === 'crowd_added' ? selectedProvider.value!.id : null,
external_vendor_name: hasSelectedProvider ? null : rawVendorName,
data: { data: {
quantity: form.quantity, quantity: form.quantity,
unit_price: form.unitPrice,
fuel_type: props.vehicle.fuel_type || null, fuel_type: props.vehicle.fuel_type || null,
vendor_id: selectedProvider.value?.id || null,
vendor_name: selectedProvider.value?.name || null,
vendor_source: providerSource,
}, },
} }
const result = await costStore.addExpense(payload) const result = await costStore.addExpense(payload)
if (result) { if (result) {
// ── DELAYED NEW PROVIDER FLOW ──
// If the user typed a raw string (no selected provider with valid ID),
// save the expense first, then open ProviderQuickAddModal pre-filled
if (!hasSelectedProvider && rawVendorName && rawVendorName.trim().length >= 2) {
pendingNewProviderName.value = rawVendorName.trim()
showQuickAddModal.value = true
// Don't emit 'saved' yet — wait for the quick-add flow to complete
return
}
showGamificationToast()
// Reset form // Reset form
form.date = getLocalDateString() form.date = getLocalDateString()
form.amount = 0 form.amountGross = 0
form.unitPrice = 0
form.quantity = 0 form.quantity = 0
form.odometer = 0 form.odometer = 0
form.currency = 'HUF'
lastEditedField.value = null
selectedProvider.value = null
emit('saved') emit('saved')
} else { } else {
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.' errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
} }
} }
// ── Delayed New Provider: QuickAdd Modal Handlers ──
function onQuickAddClose() {
showQuickAddModal.value = false
pendingNewProviderName.value = ''
// The expense was already saved, so emit saved
showGamificationToast()
// Reset form
form.date = getLocalDateString()
form.amountGross = 0
form.unitPrice = 0
form.quantity = 0
form.odometer = 0
form.currency = 'HUF'
lastEditedField.value = null
selectedProvider.value = null
emit('saved')
}
function onQuickAddSaved(provider: { id: number; name: string }) {
showQuickAddModal.value = false
pendingNewProviderName.value = ''
// The expense was already saved, and now the provider is created too
showGamificationToast()
// Reset form
form.date = getLocalDateString()
form.amountGross = 0
form.unitPrice = 0
form.quantity = 0
form.odometer = 0
form.currency = 'HUF'
lastEditedField.value = null
selectedProvider.value = null
emit('saved')
}
</script> </script>
<style scoped> <style scoped>
@@ -231,4 +559,18 @@ async function handleSubmit() {
.animate-fade-in-up { .animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards; animation: fadeInUp 0.25s ease-out forwards;
} }
/* ── P0 BUGFIX (2026-07-09): Hide number input spinners ── */
/* Chrome, Safari, Edge, Opera */
input[type='number'].no-spinner::-webkit-inner-spin-button,
input[type='number'].no-spinner::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'].no-spinner {
-moz-appearance: textfield;
appearance: textfield;
}
</style> </style>

View File

@@ -124,7 +124,7 @@
<!-- Empty State (no results) --> <!-- Empty State (no results) -->
<div <div
v-if="showDropdown && filteredResults.length === 0 && searchQuery.length >= 2 && !isSearching" v-if="showDropdown && filteredResults.length === 0 && searchQuery.length >= 3 && !isSearching"
class="absolute z-50 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg p-4" class="absolute z-50 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg p-4"
> >
<p class="text-sm text-slate-500 text-center mb-3">{{ t('provider.autocompleteNoResults') }}</p> <p class="text-sm text-slate-500 text-center mb-3">{{ t('provider.autocompleteNoResults') }}</p>
@@ -221,7 +221,7 @@ function sourceLabel(source: string): string {
// ── Search API ── // ── Search API ──
async function performSearch(query: string) { async function performSearch(query: string) {
if (query.length < 2) { if (query.length < 3) {
results.value = [] results.value = []
showDropdown.value = false showDropdown.value = false
return return
@@ -229,14 +229,28 @@ async function performSearch(query: string) {
isSearching.value = true isSearching.value = true
try { try {
// P0 BUGFIX (2026-07-09): Enhanced error logging for debugging.
// The axios baseURL already includes /api/v1, so 'providers/search'
// correctly resolves to /api/v1/providers/search.
const response = await api.get('providers/search', { const response = await api.get('providers/search', {
params: { q: query, limit: 10 }, params: { q: query, limit: 10 },
}) })
results.value = response.data.results || [] results.value = response.data.results || []
highlightIndex.value = 0 highlightIndex.value = 0
showDropdown.value = true showDropdown.value = true
} catch (error) { } catch (error: any) {
console.error('[ProviderAutocomplete] Search error:', error) console.error('[ProviderAutocomplete] Search error:', error)
// P0 BUGFIX (2026-07-09): Log detailed error info for debugging
if (error.response) {
// Server responded with error status (4xx, 5xx)
console.warn(
`[ProviderAutocomplete] API Error: status=${error.response.status}, data=`,
error.response.data
)
} else if (error.request) {
// Request was made but no response received (network/CORS issue)
console.warn('[ProviderAutocomplete] Network error: no response received')
}
results.value = [] results.value = []
showDropdown.value = false showDropdown.value = false
} finally { } finally {

View File

@@ -539,6 +539,9 @@ interface SelectedTag {
// ── Props ── // ── Props ──
const props = defineProps<{ const props = defineProps<{
isOpen: boolean isOpen: boolean
/** P0 UI/UX OVERHAUL (2026-07-09): Pre-fill the provider name when opening
* from the Delayed New Provider flow in SimpleFuelModal. */
prefillName?: string
}>() }>()
// ── Emits ── // ── Emits ──
@@ -931,6 +934,10 @@ watch(
(open) => { (open) => {
if (open) { if (open) {
resetForm() resetForm()
// P0 UI/UX OVERHAUL (2026-07-09): Pre-fill provider name if provided
if (props.prefillName) {
form.name = props.prefillName
}
fetchCategories() fetchCategories()
loadCategoryTree() loadCategoryTree()
} }

View File

@@ -342,13 +342,13 @@ export default {
}, },
costWizard: { costWizard: {
title: '🧾 Detailed Invoice', title: '🧾 Detailed Invoice',
step1: 'Basic Data', step1: 'Provider & Basic Data',
step2: 'Financials', step2: 'Financials',
step3: 'Admin & Supplier', step3: 'Invoice Details',
step4: 'Files', step4: 'Files',
step1Desc: 'Vehicle, category & date', step1Desc: 'Provider, vehicle, category & date',
step2Desc: 'Amount, VAT & currency', step2Desc: 'Amount, VAT & currency',
step3Desc: 'Supplier, invoice & deadline', step3Desc: 'Invoice number, payment & deadline',
step4Desc: 'Attach documents', step4Desc: 'Attach documents',
selectVehicle: 'Select Vehicle', selectVehicle: 'Select Vehicle',
selectCategory: 'Select Category', selectCategory: 'Select Category',
@@ -358,11 +358,17 @@ export default {
vatRate: 'VAT Rate', vatRate: 'VAT Rate',
grossAmount: 'Gross Amount', grossAmount: 'Gross Amount',
currency: 'Currency', currency: 'Currency',
provider: 'Provider',
providerHint: 'Search for a provider, or leave empty',
vendor: 'Supplier', vendor: 'Supplier',
vendorPlaceholder: 'Search or type supplier name...', vendorPlaceholder: 'Search or type supplier name...',
invoiceNumber: 'Invoice Number', invoiceNumber: 'Invoice Number',
invoiceDate: 'Invoice Date', invoiceDate: 'Invoice Date',
paymentDeadline: 'Payment Deadline', paymentDeadline: 'Payment Deadline',
paymentMethod: 'Payment Method',
paymentTransfer: 'Bank Transfer',
paymentCash: 'Cash',
paymentCard: 'Card',
attachFiles: 'Attach Files', attachFiles: 'Attach Files',
dragDrop: 'Drag & drop files here, or click to browse', dragDrop: 'Drag & drop files here, or click to browse',
noFiles: 'No files attached', noFiles: 'No files attached',

View File

@@ -339,13 +339,13 @@ export default {
}, },
costWizard: { costWizard: {
title: '🧾 Részletes Számla', title: '🧾 Részletes Számla',
step1: 'Alapadatok', step1: 'Szolgáltató & Alapadatok',
step2: 'Pénzügyek', step2: 'Pénzügyek',
step3: 'Admin & Szállító', step3: 'Számla adatok',
step4: 'Fájlok', step4: 'Fájlok',
step1Desc: 'Jármű, kategória & dátum', step1Desc: 'Szolgáltató, jármű, kategória & dátum',
step2Desc: 'Összeg, ÁFA & pénznem', step2Desc: 'Összeg, ÁFA & pénznem',
step3Desc: 'Szállító, számla & határidő', step3Desc: 'Számlaszám, fizetési mód & határidő',
step4Desc: 'Dokumentumok csatolása', step4Desc: 'Dokumentumok csatolása',
selectVehicle: 'Jármű kiválasztása', selectVehicle: 'Jármű kiválasztása',
selectCategory: 'Kategória kiválasztása', selectCategory: 'Kategória kiválasztása',
@@ -355,11 +355,17 @@ export default {
vatRate: 'ÁFA kulcs', vatRate: 'ÁFA kulcs',
grossAmount: 'Bruttó összeg', grossAmount: 'Bruttó összeg',
currency: 'Pénznem', currency: 'Pénznem',
provider: 'Szolgáltató',
providerHint: 'Keresd meg a szolgáltatót, vagy hagyd üresen',
vendor: 'Szállító', vendor: 'Szállító',
vendorPlaceholder: 'Keresés vagy szállító nevének beírása...', vendorPlaceholder: 'Keresés vagy szállító nevének beírása...',
invoiceNumber: 'Számlaszám', invoiceNumber: 'Számlaszám',
invoiceDate: 'Számla dátuma', invoiceDate: 'Számla dátuma',
paymentDeadline: 'Fizetési határidő', paymentDeadline: 'Fizetési határidő',
paymentMethod: 'Fizetési mód',
paymentTransfer: 'Átutalás',
paymentCash: 'Készpénz',
paymentCard: 'Bankkártya',
attachFiles: 'Fájlok csatolása', attachFiles: 'Fájlok csatolása',
dragDrop: 'Húzd ide a fájlokat, vagy kattints a tallózáshoz', dragDrop: 'Húzd ide a fájlokat, vagy kattints a tallózáshoz',
noFiles: 'Nincs fájl csatolva', noFiles: 'Nincs fájl csatolva',

View File

@@ -0,0 +1,626 @@
# 🏗️ Logic Spec: Service Finder 3-Level Platform Architecture
**Verzió:** 1.0
**Dátum:** 2026-07-09
**Állapot:** Tervezet (Architect Review)
**Hatáskör:** Teljes platform — Admin (Mission Control), User (Provider Hub), Robot/AI (Intelligence Layer)
---
## 1. 🎯 Modul Célja és Masterbook 2.0 Illeszkedés
### Cél
A Service Finder platform három architekturális szintre bomlik, amelyek együtt alkotják a teljes ökoszisztémát:
| Szint | Elnevezés | Elsődleges Cél |
|-------|-----------|----------------|
| **Szint 1** | 🏛️ **Admin — Mission Control** | Flottavezetők, moderátorok, superadminok irányítóközpontja |
| **Szint 2** | 👤 **User — Provider Hub** | Szolgáltatók és flottatulajdonosok önkiszolgáló felülete |
| **Szint 3** | 🤖 **Robot/AI — Intelligence Layer** | Automatikus adatkinyerés, intelligens rangsorolás, szemantikus keresés |
### Masterbook 2.0 Illeszkedés
- **Dual Entity (Person/User):** Minden jogi entitás két szinten kezelve — hús-vér személy vs. technikai fiók
- **Triple Wallet:** Vásárolt, keresett és szolgáltatási kreditek elkülönített kezelése
- **Dual Control (Moderation):** Kétlépcsős jóváhagyási folyamat minden szolgáltatói rekordhoz
- **2-Step Asset Flow:** Draft → Active életciklus minden jármű és eszköz esetében
- **4-Level Expertise Taxonomy:** Vehicle Type → Industry → Profession → Specific Task
- **P0 Hybrid Vendor Refactor:** Három párhuzamos szolgáltatói adatforrás egyesítése
---
## 2. 📊 Jelenlegi Rendszer Állapota (Audit Summary)
### 2.1 Adatbázis Modellek — Teljes Körkép
#### identity séma
| Tábla | Státusz | Fájl |
|-------|---------|------|
| `users` | ✅ KÉSZ | [`social.py`](backend/app/models/identity/social.py:26) |
| `persons` | ✅ KÉSZ | — |
| `wallets` | ✅ KÉSZ | Triple Wallet (4 zseb) |
| `service_providers` | ✅ KÉSZ | Közösségi szolgáltatók moderációval |
| `provider_validations` | ✅ KÉSZ | Validációs rekordok súlyozással |
| `votes` | ✅ KÉSZ | +1/-1 közösségi szavazás |
| `user_scores` | ✅ KÉSZ | Pontszámok |
| `service_reviews` | ✅ KÉSZ | Értékelés + transaction_id |
| `competitions` | ✅ KÉSZ | Versenyek |
#### marketplace séma
| Tábla | Státusz | Fájl |
|-------|---------|------|
| `service_profiles` | ✅ KÉSZ | [`service.py`](backend/app/models/marketplace/service.py:21) |
| `expertise_tags` | ✅ KÉSZ | 4-level hierarchy (parent_id, path, level) |
| `service_expertise` | ✅ KÉSZ | Junction table |
| `service_staging` | ✅ KÉSZ | Robot-hunted providers |
| `discovery_parameters` | ✅ KÉSZ | Felfedezési paraméterek |
| `costs` | ✅ KÉSZ | Költség főkönyv |
| `organizations` | ✅ KÉSZ | Hivatalos garázs/szerviz szervezetek |
| `organization_members` | ✅ KÉSZ | Tagok szerepkörökkel |
| `branches` | ✅ KÉSZ | PostGIS location, opening_hours |
| `organization_financials` | ✅ KÉSZ | Pénzügyi adatok |
#### ❌ HIÁNYZÓ modellek
| Modell | Tervezett Tábla | Ok |
|--------|-----------------|----|
| `KYCVerification` | `identity.kyc_verifications` | KYC dokumentumok és státusz követése |
| `ServicePricing` | `marketplace.service_pricing` | Szolgáltatás árazás (alkatrész + munkadíj) |
| `ServiceBooking` | `marketplace.service_bookings` | Foglalási napló |
| `VeszélyesZónaGatekeeper` | `system.gatekeeper_log` | Hard/soft delete tranzakció-ellenőrzés |
### 2.2 Service Layer
| Service | Fájl | Funkciók |
|---------|------|----------|
| `search_providers()` | [`provider_service.py:125`](backend/app/services/provider_service.py:125) | Unified search, geo-filter, trust score |
| `quick_add_provider()` | [`provider_service.py:578`](backend/app/services/provider_service.py:578) | Gyors létrehozás + gamification |
| `update_provider()` | [`provider_service.py:909`](backend/app/services/provider_service.py:909) | Teljes frissítés (category sync, expertise) |
| `_award_provider_points()` | [`provider_service.py:63`](backend/app/services/provider_service.py:63) | Gamification pontozás |
| `_sync_service_expertises()` | [`provider_service.py:742`](backend/app/services/provider_service.py:742) | 4-level hybrid mentés |
| `find_nearby_services()` | [`search_service.py:24`](backend/app/services/search_service.py:24) | PostGIS + weighted ranking |
| `AIService._execute_ai_call()` | [`ai_service.py:23`](backend/app/services/ai_service.py:23) | Ollama → Groq/Gemini fallback |
| `AIService._fallback_ai_call()` | [`ai_service.py:81`](backend/app/services/ai_service.py:81) | Cloud fallback (Gemini → Groq) |
| `AIService.process_ocr_document()` | [`ai_service.py:174`](backend/app/services/ai_service.py:174) | OCR dokumentum feldolgozás |
| `CostService.record_cost()` | [`cost_service.py`](backend/app/services/cost_service.py) | Költségrögzítés |
### 2.3 API Végpontok — Állapot
| Végpont | Metódus | Státusz | Leírás |
|---------|---------|---------|--------|
| `/admin/providers` | GET | ✅ KÉSZ | Unified provider list (3 tábla UNION ALL) |
| `/admin/providers/stats` | GET | ✅ KÉSZ | Statisztikák |
| `/admin/providers/{id}` | GET | ✅ KÉSZ | Detail + history + validations |
| `/admin/providers/{id}/approve` | POST | ✅ KÉSZ | Jóváhagyás + Promotion Engine |
| `/admin/providers/{id}/reject` | POST | ✅ KÉSZ | Elutasítás okával |
| `/admin/providers/{id}/restore` | POST | ✅ KÉSZ | Visszaállítás |
| `/admin/providers/{id}/flag` | POST | ✅ KÉSZ | Megjelölés (audit) |
| `/admin/providers/{id}` | DELETE | ✅ KÉSZ | Soft delete |
| `/admin/providers/{id}` | PATCH | ✅ KÉSZ | Teljes update + ServiceProfile auto-create |
| `/admin/providers/{id}/validations` | GET | ✅ KÉSZ | Validációs rekordok |
| `/admin/providers/{id}/history` | GET | ✅ KÉSZ | Audit log |
| `/search/match` | GET | ✅ KÉSZ | PostGIS geofencing search |
#### ❌ HIÁNYZÓ Végpontok
| Végpont | Metódus | Prioritás |
|---------|---------|-----------|
| `/admin/gatekeeper/hard-delete` | POST | **P0** — Veszélyes zóna hard delete |
| `/admin/gatekeeper/log` | GET | **P0** — Gatekeeper audit log |
| `/admin/kyc/{user_id}` | GET/PUT | **P1** — KYC dokumentumok kezelése |
| `/admin/kyc/pending` | GET | **P1** — Függő KYC kérelmek |
| `/admin/subscriptions` | CRUD | **P1** — Előfizetések kezelése |
| `/admin/analytics/tco` | GET | **P1** — TCO aggregáció |
| `/admin/analytics/provider-rankings` | GET | **P2** — Szolgáltató rangsor analitika |
| `/providers/me/profile` | GET/PUT | **P1** — Szolgáltató saját profil |
| `/providers/me/services` | CRUD | **P1** — Saját szolgáltatások |
| `/providers/me/bookings` | GET | **P2** — Saját foglalások |
| `/search/semantic` | POST | **P2** — NLP alapú keresés |
| `/search/ai-rank` | POST | **P2** — AI rangsorolás |
---
## 3. 🏛️ Admin Level — Mission Control
### 3.1 Jóváhagyási Munkafolyamat (✅ KÉSZ)
```
flowchart LR
A[Függő Szolgáltató] --> B{Admin Döntés}
B -->|Approve| C[Promotion Engine]
B -->|Reject| D[Elutasítás Indoklással]
C --> E[ServiceProvider létrehozva]
C --> F[ServiceProfile auto-generated]
C --> G[Gamification XP jóváírva]
C --> H[AuditLog naplózva]
D --> I[ProviderValidation rekord]
D --> J[Status: rejected]
B -->|Flag| K[Audit megjelölés]
K --> L[Manuális felülvizsgálat]
```
### 3.2 KYC Verifikáció (❌ HIÁNYZIK — P1)
**Tervezett Modell:**
```python
class KYCVerification(Base):
__tablename__ = "kyc_verifications"
__table_args__ = {"schema": "identity"}
id: int (PK)
user_id: int (FK users.id)
person_id: bigint (FK persons.id)
documents: dict (JSONB) feltöltött dokumentumok
status: enum(pending, verified, rejected)
verified_by: int (FK users.id, nullable)
verified_at: datetime
notes: text
expires_at: datetime
created_at: datetime
updated_at: datetime
```
### 3.3 Kategória Menedzsment (✅ RÉSZBEN)
**Meglévő:**
- ✅ 4-level ExpertiseTag hierarchia (parent_id, path, level, name_i18n, search_keywords)
- ✅ Seed szkriptek: `seed_expertise_tags.py`, `seed_expertise_taxonomy.py`
- ✅ 136 kategória (utolsó seed: 768)
-`_sync_service_expertises()` — 4-level hybrid mentés
-`_create_new_tags()` — Új címkék automatikus létrehozása
**Hiányzó:**
- ❌ Kategória kezelő admin UI (CRUD + átrendezés)
- ❌ Kategória láthatósági szabályok (Global/Country/Region)
### 3.4 Pénzügyi & Előfizetés Kontroll (❌ HIÁNYZIK — P1)
**Meglévő:**
- ✅ Triple Wallet modell (`identity.wallets`)
- ✅ FinancialLedger (`audit.financial_ledger`)
- ✅ Stripe webhook (Double Lock protokoll)
- ✅ Billing Engine (vízesés modell)
-`Organization.subscription_tier_id`, `subscription_plan`, `subscription_tier` kapcsolat
**Hiányzó:**
- ❌ Admin előfizetés kezelő UI
- ❌ Előfizetés számlázási adatok admin felület
- ❌ Wallet egyenlegek admin áttekintése
### 3.5 Veszélyes Zóna — Gatekeeper (❌ HIÁNYZIK — P0 KRITIKUS)
**Tervezett architektúra:**
```python
# system.gatekeeper_log tábla
class GatekeeperLog(Base):
__tablename__ = "gatekeeper_log"
__table_args__ = {"schema": "system"}
id: int (PK)
action: enum(soft_delete, hard_delete, restore, purge)
target_table: str
target_id: int
initiated_by: int (FK users.id)
approved_by: int (FK users.id, nullable)
status: enum(pending, approved, rejected, executed)
reason: text
backup_snapshot: dict (JSONB) hard delete előtti adatmentés
executed_at: datetime
created_at: datetime
```
**Hard Delete Folyamat:**
1. Admin kiválaszt egy rekordot hard delete-re
2. GatekeeperLog létrehozása `pending` státusszal
3. Backup snapshot JSONB-ben (mentés az adatról)
4. Második admin jóváhagyása szükséges (Dual Control)
5. Atomi tranzakció: DELETE + AuditLog + GatekeeperLog.status=executed
6. Rollback lehetőség a backup snapshot-ból
**Referencia:** [`test_hard_delete.py`](backend/test_hard_delete.py:7) — létezik referencia implementáció
---
## 4. 👤 User Level — Provider Hub
### 4.1 Profil Menedzsment (✅ RÉSZBEN)
**Meglévő:**
-`ProviderUpdateInput` séma (category_ids, supported_vehicle_classes, specializations, opening_hours)
-`update_provider()` — Teljes provider frissítés
-`quick_add_provider()` — Gyors létrehozás gamification hook-kal
- ✅ AddressManager integráció (P0 Unified Address Refactor)
- ✅ Frontend_admin: [`providers/[id]/edit.vue`](frontend_admin/pages/providers/[id]/edit.vue) — Teljes admin szerkesztő
**Hiányzó:**
- ❌ Szolgáltatói self-service frontend (public felületen)
- ❌ Provider dashboard (saját statisztikák, értékelések)
- ❌ Multi-language profil szerkesztés (name_i18n)
### 4.2 Szolgáltatás Katalógus (❌ HIÁNYZIK — P1)
**Tervezett Modell:**
```python
class ServicePricing(Base):
__tablename__ = "service_pricing"
__table_args__ = {"schema": "marketplace"}
id: int (PK)
service_profile_id: int (FK marketplace.service_profiles.id)
category_id: int (FK marketplace.expertise_tags.id)
name: str
name_i18n: dict (JSONB) Twin-technika
description: str
description_i18n: dict (JSONB)
labor_cost: Decimal(18,4)
parts_cost: Decimal(18,4)
currency: str (default: "HUF")
is_flat_rate: bool
estimated_duration_minutes: int
is_active: bool
valid_from: datetime
valid_until: datetime (nullable)
```
### 4.3 Értékelések & Gamifikáció (✅ RÉSZBEN)
**Meglévő:**
-`ServiceReview` modell — rating, review_text, transaction_id
-`PointRule` — gamification pontszabályok
-`_award_provider_points()` — Provider pontozás
- ✅ Gamification admin frontend: [`gamification/`](frontend_admin/pages/gamification/)
- ✅ Leaderboard, badges, competitions, seasons
**Hiányzó:**
- ❌ Review moderációs admin UI (flag-elt review-k)
- ❌ Gamification widget a provider dashboardon
- ❌ Pontszabályok vizuális szerkesztője
### 4.4 Lead Menedzsment (❌ HIÁNYZIK — P2)
**Meglévő:**
-`ServiceRequest` kapcsolat a `Branch` modellben
**Hiányzó:**
- ❌ Teljes foglalási munkafolyamat (ServiceBooking model)
- ❌ Geofenced broadcast (körzetes értesítés)
- ❌ Értesítési csatornák (email, push, in-app)
---
## 5. 🤖 Robot/AI Level — Intelligence Layer
### 5.1 Szemantikus Keresés (❌ HIÁNYZIK — P2)
**Meglévő:**
- ✅ PostGIS geofencing (`ST_DWithin`, `ST_DistanceSphere`)
- ✅ Config-driven weighted ranking (ad_weight, partner_weight, trust_weight, dist_penalty, pref_weight)
- ✅ Hierarchical ranking config (SystemParameter)
**Tervezett NLP Search:**
```python
class SemanticSearchEngine:
"""
Tervezett motor a meglévő SearchService kiterjesztésére.
"""
@classmethod
async def semantic_search(cls, query: str, lat: float, lng: float, radius_km: float):
# 1. Query embedding (Ollama / local)
query_embedding = await AIService.get_embedding(query)
# 2. Hybrid search: PostGIS + embedding similarity
results = await cls._hybrid_search(query_embedding, lat, lng, radius_km)
# 3. AI re-ranking
reranked = await AIService.rerank_results(query, results)
return reranked
```
### 5.2 Intelligens Rangsorolás (✅ RÉSZBEN)
**Weighted Scoring Formula** ([`search_service.py:92`](backend/app/services/search_service.py:92)):
```
score = (ad_weight * is_advertiser) +
(partner_weight * is_partner) +
(trust_weight * trust_score) -
(dist_penalty * dist_km) +
(pref_weight * is_preferred)
```
**Hiányzó:**
- ❌ ML-alapú rangsorolás (felhasználói viselkedés alapján)
- ❌ A/B tesztelési keretrendszer
- ❌ Rangsorolási metrikák admin dashboard
### 5.3 Automatikus Adatkinyerés (✅ RÉSZBEN)
**Meglévő:**
- ✅ [`AIService`](backend/app/services/ai_service.py) — Multi-Agent gateway
- ✅ Ollama local → Groq/Gemini cloud fallback
-`process_ocr_document()` — OCR dokumentum feldolgozás
-`get_gold_data_from_research()` — Arany adatkinyerés
-`get_clean_vehicle_data()` — Adattisztítás
-`robot_1_ocr_processor.py` — OCR robot
**Hiányzó:**
- ❌ Document pipeline admin UI (OCR eredmények ellenőrzése)
- ❌ AI prompt konfigurációs admin panel
- ❌ Fallback monitoring és naplózás
---
## 6. 🔧 Adatmodell Terv — Részletes
### 6.1 Meglévő Modellek Összefoglalója
| Séma | Tábla | Oszlopok | Kulcs (PK/FK) | JSONB | i18n | Soft Delete |
|------|-------|----------|---------------|-------|------|-------------|
| `identity` | `users` | 20+ | PK, FK→persons | preferences | — | `deleted_at` |
| `identity` | `persons` | 22+ | PK, FK→users, FK→address | identity_docs, ice_contact | — | `deleted_at` |
| `identity` | `service_providers` | 12+ | PK, FK→address | — | — | `status=rejected` |
| `identity` | `wallets` | 10+ | PK, FK→users | — | — | — |
| `marketplace` | `service_profiles` | 18+ | PK, FK→service_providers | opening_hours, specializations | — | — |
| `marketplace` | `expertise_tags` | 10+ | PK, FK→self (parent) | name_i18n, search_keywords | ✅ 4 mező | — |
| `marketplace` | `organizations` | 25+ | PK, FK→address | custom_features, visual_settings, settings, aliases, tags | — | `is_deleted` |
| `marketplace` | `branches` | 12+ | PK, FK→organization, FK→address | opening_hours | — | `is_deleted` |
| `marketplace` | `service_staging` | 12+ | PK | raw_data | — | — |
| `audit` | `financial_ledger` | 8+ | PK, FK→users | details | — | — |
### 6.2 Twin-technika (Többnyelvűség)
**Meglévő implementáció:**
- `ExpertiseTag.name_i18n` (JSONB) — {"hu": "Olajcsere", "en": "Oil Change", "de": "Ölwechsel"}
- `ExpertiseTag.description_i18n` (JSONB)
- `ExpertiseTag.search_keywords` (JSONB) — {"hu": ["olaj", "motorolaj"], "en": ["oil", "motor oil"]}
**Kötelező minden új i18n mezőre:**
```python
name_i18n: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
```
### 6.3 Soft-delete Logika
| Tábla | Soft Delete Mező | Típus |
|-------|------------------|-------|
| `users` | `deleted_at` | DateTime (nullable) |
| `persons` | `deleted_at` | DateTime (nullable) |
| `organizations` | `is_deleted` | Boolean |
| `branches` | `is_deleted` | Boolean |
| `service_providers` | `status = rejected` | Enum |
| `organization_members` | `status != active` | Enum |
**Hiányzó Gatekeeper:** A Veszélyes Zóna funkcióhoz dedikált `gatekeeper_log` tábla szükséges (Lásd 3.5)
### 6.4 Hiányzó Modellek — Alembic Migration Terv
| # | Modell | Tábla | Típus | Migráció |
|---|--------|-------|-------|----------|
| 1 | `GatekeeperLog` | `system.gatekeeper_log` | **P0** | `gatekeeper_log_v1` |
| 2 | `KYCVerification` | `identity.kyc_verifications` | P1 | `kyc_verification_v1` |
| 3 | `ServicePricing` | `marketplace.service_pricing` | P1 | `service_pricing_v1` |
| 4 | `ServiceBooking` | `marketplace.service_bookings` | P2 | `service_booking_v1` |
| 5 | `AIRequestLog` | `audit.ai_request_logs` | P2 | `ai_request_log_v1` |
---
## 7. ⚙️ Admin Kontroll Rendszerváltozók
### 7.1 Hierarchikus Config Változók
A rendszer négy szinten kezeli a konfigurációs változókat:
| Szint | Tábla | Hatáskör |
|-------|-------|-----------|
| **Global** | `system.system_parameters` | Az összes régióra és felhasználóra |
| **Country** | `system.system_parameters` (scope=country) | Adott országra |
| **Region** | `system.system_parameters` (scope=region) | Adott régióra |
| **User** | `identity.users.preferences` | Egyedi felhasználóra |
### 7.2 Meglévő Rendszerváltozók
| Kulcs | Típus | Scope | Érték |
|-------|-------|-------|-------|
| `GAMIFICATION_MASTER_CONFIG` | JSONB | Global | Gamification master config |
| `AI_REQUEST_DELAY` | Float | Global | Késleltetés AI hívások között |
| `AI_TEMPERATURE` | Float | Global | AI hőmérséklet paraméter |
| `AI_TIMEOUT_LOCAL` | Integer | Global | Helyi AI timeout (sec) |
| `ENABLE_AI_FALLBACK` | Boolean | Global | Cloud fallback engedélyezése |
| `AI_MODEL_TEXT` | String | Global | Szöveg AI modell neve |
| `AI_MODEL_VISION` | String | Global | Kép AI modell neve |
| `DVLA_DAILY_LIMIT` | Integer | Global | Napi DVLA API limit |
### 7.3 Tervezett Új Változók
| Kulcs | Típus | Scope | Cél |
|-------|-------|-------|-----|
| `SEARCH_RANKING_CONFIG` | JSONB | Global | Súlyozási mátrix (ad/partner/trust/dist/pref) |
| `GATEKEEPER_REQUIRED_APPROVALS` | Integer | Global | Hány admin kell hard delete-hez |
| `KYC_REQUIRED_LEVELS` | JSONB | Country | Országonkénti KYC követelmények |
| `PROVIDER_AUTO_APPROVE` | Boolean | Country | Automatikus jóváhagyás engedélyezése |
| `REVIEW_MODERATION_ENABLED` | Boolean | Global | Review moderáció bekapcsolása |
---
## 8. 🌍 Geo-Logika és Rangsorolás
### 8.1 PostGIS Alapú Keresés
A [`search_service.py`](backend/app/services/search_service.py:24) a `find_nearby_services()` metódussal PostGIS `ST_DWithin` és `ST_DistanceSphere` függvényeket használ:
```sql
-- Generált SQL (lekérdezés logika)
SELECT o.id, o.name, gpc.city,
b.branch_rating,
ST_DistanceSphere(b.location, ST_MakePoint(:lng, :lat)) AS distance_meters
FROM marketplace.organizations o
JOIN marketplace.branches b ON o.id = b.organization_id
LEFT JOIN system.addresses a ON a.id = b.address_id
LEFT JOIN system.geo_postal_codes gpc ON gpc.id = a.postal_code_id
WHERE o.is_active = true
AND b.is_deleted = false
AND ST_DWithin(b.location, ST_MakePoint(:lng, :lat), :radius_meters)
ORDER BY distance_meters ASC;
```
### 8.2 Súlyozott Rangsorolás
A [`SearchService`](backend/app/services/search_service.py) config-driven súlyozást használ:
```python
score = (
r["ad_weight"] * is_advertiser + # Hirdetési súly (default: 10)
r["partner_weight"] * is_partner + # Partner súly (default: 5)
r["trust_weight"] * trust_score - # Bizalmi súly (default: 2)
r["dist_penalty"] * dist_km + # Távolság büntetés (default: 0.5)
r["pref_weight"] * is_preferred # Preferált súly (default: 3)
)
```
### 8.3 Csomag Szintek Szerinti Rangsorolás
| Csomag Szint | ad_weight | partner_weight | max_distance_km | Prioritás |
|-------------|-----------|----------------|-----------------|-----------|
| **Free** | 0 | 0 | 20 | Alacsony |
| **Basic** | 2 | 1 | 50 | Közepes |
| **Premium** | 5 | 3 | 100 | Magas |
| **Enterprise** | 10 | 5 | 200 | Legmagasabb |
---
## 9. 📅 Implementációs Sorrend és Függőségek
### 9.1 Függőségi Gráf
```mermaid
flowchart TD
P0[P0: Veszélyes Zóna Gatekeeper] --> P1a[P1: KYC Verifikáció]
P0 --> P1b[P1: Előfizetés Admin UI]
P1a --> P2a[P2: Szolgáltató Self-Service]
P1b --> P2b[P2: Foglalási Rendszer]
P2a --> P2c[P2: NLP Szemantikus Keresés]
P2b --> P2d[P2: AI Rangsorolás]
style P0 fill:#ff4444,color:#fff
style P1a fill:#ffaa00,color:#000
style P1b fill:#ffaa00,color:#000
style P2a fill:#44aa44,color:#fff
style P2b fill:#44aa44,color:#fff
style P2c fill:#44aa44,color:#fff
style P2d fill:#44aa44,color:#fff
```
### 9.2 Prioritási Sorrend
#### P0 — Kritikus (Hiányzó biztonsági funkciók)
| # | Feladat | Érintett Fájlok |
|---|---------|-----------------|
| 1 | **Gatekeeper hard/soft delete** — Tranzakció-ellenőrzés Dual Control-lal | [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1109), [`test_hard_delete.py`](backend/test_hard_delete.py) |
| 2 | **Gatekeeper admin API** — DELETE jogosultság ellenőrzés + AuditLog | új végpont |
#### P1 — Magas Prioritás (Hiányzó üzleti funkciók)
| # | Feladat | Érintett Fájlok |
|---|---------|-----------------|
| 3 | **KYCVerification modell + API** — Dokumentumfeltöltés és státusz | új modell + endpoint |
| 4 | **KYC admin UI** — Függő kérelmek kezelése | új frontend oldal |
| 5 | **ServicePricing modell + API** — Árazási adatok | új modell + endpoint |
| 6 | **Előfizetés admin UI** — Subscription CRUD | új frontend oldal |
| 7 | **Szolgáltatói self-service API** — Saját profil kezelés | új végpontok |
#### P2 — Fejlesztési Cél (Intelligens funkciók)
| # | Feladat | Érintett Fájlok |
|---|---------|-----------------|
| 8 | **NLP szemantikus keresés** — Embedding + hybrid search | [`search_service.py`](backend/app/services/search_service.py) kiterjesztése |
| 9 | **AI rangsorolás** — ML-based ranking | [`ai_service.py`](backend/app/services/ai_service.py) kiterjesztése |
| 10 | **Foglalási rendszer** — ServiceBooking flow | új modell + endpoint |
| 11 | **Lead management** — Értesítési csatornák | új service |
| 12 | **Document pipeline admin UI** — OCR eredmények | új frontend oldal |
### 9.3 Implementációs Szekvencia
**Fázis 1 — Biztonsági Alapok (P0)**
1. `GatekeeperLog` modell létrehozása (`system` séma)
2. Admin hard-delete API végpont (Dual Control)
3. Admin gatekeeper log UI
4. Meglévő DELETE végpontok gatekeeper-en keresztül vezetése
**Fázis 2 — Admin Kibővítés (P1)**
5. `KYCVerification` modell + API
6. KYC admin frontend
7. `ServicePricing` modell + API
8. Előfizetés admin UI
**Fázis 3 — Önkiszolgálás (P1)**
9. Provider self-service API (CRUD)
10. Provider dashboard frontend
11. Multi-language profil szerkesztés
**Fázis 4 — Intelligens Réteg (P2)**
12. NLP szemantikus keresőmotor
13. AI rangsorolás
14. Foglalási rendszer
15. Lead management
---
## 10. 🔗 Érintett Fájlok Teljes Listája
### Backend — Modellek
- [`social.py`](backend/app/models/identity/social.py) — ServiceProvider, ProviderValidation, Vote, ServiceReview
- [`service.py`](backend/app/models/marketplace/service.py) — ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging
- [`organization.py`](backend/app/models/marketplace/organization.py) — Organization, Branch, OrganizationMember
- [`audit.py`](backend/app/models/audit.py) — AuditLog, FinancialLedger
### Backend — API Végpontok
- [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py) — Admin provider moderáció (1224+ sor)
- [`providers.py`](backend/app/api/v1/endpoints/providers.py) — Public provider API
- [`search.py`](backend/app/api/v1/endpoints/search.py) — Geofencing kereső (99 sor)
### Backend — Service Layer
- [`provider_service.py`](backend/app/services/provider_service.py) — Provider CRUD, gamification, search
- [`search_service.py`](backend/app/services/search_service.py) — PostGIS + weighted ranking
- [`ai_service.py`](backend/app/services/ai_service.py) — Multi-Agent AI gateway (Ollama → Groq/Gemini)
- [`cost_service.py`](backend/app/services/cost_service.py) — Költségrögzítés
### Frontend — Admin (Nuxt)
- [`providers/index.vue`](frontend_admin/pages/providers/index.vue) — Provider lista
- [`providers/pending.vue`](frontend_admin/pages/providers/pending.vue) — Függő jóváhagyások
- [`providers/[id]/index.vue`](frontend_admin/pages/providers/[id]/index.vue) — Provider részletek
- [`providers/[id]/edit.vue`](frontend_admin/pages/providers/[id]/edit.vue) — Provider szerkesztés
- [`gamification/`](frontend_admin/pages/gamification/) — Gamification admin felület
### Frontend — Public (Vue+Vite)
- [`provider/ProviderEditModal.vue`](frontend_app/src/components/provider/ProviderEditModal.vue) — Provider szerkesztés
- [`provider/ProviderDetailModal.vue`](frontend_app/src/components/provider/ProviderDetailModal.vue) — Provider részletek
- [`provider/ProviderQuickAddModal.vue`](frontend_app/src/components/provider/ProviderQuickAddModal.vue) — Gyors hozzáadás
- [`dashboard/ServiceFinderCard.vue`](frontend_app/src/components/dashboard/ServiceFinderCard.vue) — Kereső widget
### Dokumentációk
- [`admin_providers_root_cause_analysis.md`](docs/admin_providers_root_cause_analysis.md) — Root cause analysis
- [`epic_10_admin_frontend_spec.md`](docs/sf/epic_10_admin_frontend_spec.md) — Epic 10 spec
- [`Masterbook_2.0_Status.md`](docs/Masterbook_2.0_Status.md) — Projekt státusz
- [`full_db_schema_blueprint.md`](docs/full_db_schema_blueprint.md) — Teljes séma blueprint
---
## ✅ Verifikációs Checklist
### Admin Level
- [ ] Admin provider moderáció (approve/reject/flag/restore) — ✅ KÉSZ
- [ ] UNION ALL query (3 tábla egyesítése) — ✅ KÉSZ
- [ ] Promotion Engine (Staging → Live) — ✅ KÉSZ
- [ ] Veszélyes Zóna Gatekeeper — ❌ HIÁNYZIK (P0)
- [ ] KYC Verifikáció — ❌ HIÁNYZIK (P1)
- [ ] Előfizetés admin UI — ❌ HIÁNYZIK (P1)
- [ ] Kategória menedzsment admin UI — ❌ HIÁNYZIK (P1)
### User Level
- [ ] Provider CRUD (quick_add, update) — ✅ KÉSZ
- [ ] Geofencing kereső (PostGIS) — ✅ KÉSZ
- [ ] Szolgáltatói self-service — ❌ HIÁNYZIK (P1)
- [ ] ServicePricing modell — ❌ HIÁNYZIK (P1)
- [ ] Foglalási rendszer — ❌ HIÁNYZIK (P2)
- [ ] Értékelés és gamifikáció — ✅ RÉSZBEN
### Robot/AI Level
- [ ] Multi-Agent AI Gateway (Ollama → Groq/Gemini) — ✅ KÉSZ
- [ ] OCR dokumentum feldolgozás — ✅ KÉSZ
- [ ] Config-driven weighted ranking — ✅ KÉSZ
- [ ] NLP szemantikus keresés — ❌ HIÁNYZIK (P2)
- [ ] ML-alapú rangsorolás — ❌ HIÁNYZIK (P2)
- [ ] AI prompt admin panel — ❌ HIÁNYZIK (P2)