garázs fejlesztés
This commit is contained in:
530
.roo/history.md
530
.roo/history.md
@@ -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 ✅
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
320
backend/app/scripts/seed_expertise_enterprise.py
Normal file
320
backend/app/scripts/seed_expertise_enterprise.py
Normal 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()
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
695
backend/app/scripts/seed_insurance_providers.py
Normal file
695
backend/app/scripts/seed_insurance_providers.py
Normal 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())
|
||||||
@@ -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(
|
||||||
|
|||||||
53
backend/test_hard_delete.py
Normal file
53
backend/test_hard_delete.py
Normal 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())
|
||||||
193
docs/p0_provider_ingestion_gamification_audit_report.md
Normal file
193
docs/p0_provider_ingestion_gamification_audit_report.md
Normal 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
@@ -1 +1 @@
|
|||||||
{"id":"dev","timestamp":1783475444387}
|
{"id":"dev","timestamp":1783500293953}
|
||||||
@@ -1 +1 @@
|
|||||||
{"id":"dev","timestamp":1783475444387,"prerendered":[]}
|
{"id":"dev","timestamp":1783500293953,"prerendered":[]}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
6
frontend_admin/.nuxt/nuxt.d.ts
vendored
6
frontend_admin/.nuxt/nuxt.d.ts
vendored
@@ -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" />
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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: "
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
304
frontend_admin/i18n/locales/en/gamification.json
Normal file
304
frontend_admin/i18n/locales/en/gamification.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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)"
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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..."
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!"
|
|
||||||
}
|
|
||||||
@@ -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!"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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..."
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
181
frontend_admin/i18n/locales/en/system.json
Normal file
181
frontend_admin/i18n/locales/en/system.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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..."
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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: "
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
304
frontend_admin/i18n/locales/hu/gamification.json
Normal file
304
frontend_admin/i18n/locales/hu/gamification.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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)"
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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..."
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!"
|
|
||||||
}
|
|
||||||
@@ -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!"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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..."
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
181
frontend_admin/i18n/locales/hu/system.json
Normal file
181
frontend_admin/i18n/locales/hu/system.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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..."
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
626
plans/logic_spec_service_finder_3_level_architecture.md
Normal file
626
plans/logic_spec_service_finder_3_level_architecture.md
Normal 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)
|
||||||
|
|
||||||
Reference in New Issue
Block a user