felhasználói felületre pü

This commit is contained in:
Roo
2026-07-27 08:39:18 +00:00
parent c9118bf52f
commit 6f28d3e70d
72 changed files with 6311 additions and 749 deletions

View File

@@ -5,565 +5,45 @@
---
## P0 EPIC - UI/UX Overhaul for Quick Fuel Logging (Gitea #404)
## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413)
**Dátum:** 2026-07-09
**Scope:** Backend + Frontend (SimpleFuelModal, ProviderAutocomplete, ProviderQuickAddModal)
**Dátum:** 2026-07-25
### 🔧 Módosított fájlok
#### 1. Backend: [`providers.py`](backend/app/api/v1/endpoints/providers.py:200)
- 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ó
1. **Sync Engine**`docker exec sf_api python -m app.scripts.sync_engine` → 1287/1287 elements OK, 0 error
2. **Python syntax** → All 3 backend files compile successfully (0 syntax errors)
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
...
---
## P0 BUGFIX: SimpleFuelModal 500 POST crash + 5 frontend issues (Gitea #405)
## Fix: POST /api/v1/expenses/ — 500 Internal Server Error (PG ENUM vs varchar JOIN)
**Dátum:** 2026-07-10
**Scope:** Backend (expenses.py, provider_service.py) + Frontend (SimpleFuelModal.vue)
**Dátum:** 2026-07-26
### 🔍 Root Cause Analysis: 500 POST /expenses/ crash
### Probléma
A `POST /api/v1/expenses/` végpont 500-as hibát dobott insurance (biztosítás) költség létrehozásakor.
**Hiba:** `ForeignKeyViolationError` on `fk_asset_costs_vendor_org`
### Gyökér-ok (két rétegben)
1. **Stale `.pyc` cache**`ModuleNotFoundError: No module named 'app.models.fleet.org_role'` — A konténer régi bytecode-ot futtatott. Megoldva cache törléssel.
2. **PG ENUM vs varchar típusütközés**`operator does not exist: fleet.orguserrole = character varying` — A `_check_org_capability()` függvény JOIN-t használt `OrganizationMember.role` (ENUM) és `OrgRole.name_key` (varchar) között, amit a PostgreSQL nem tud implicit konvertálni.
**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:
### Javítás
[`expenses.py:93-117`](backend/app/api/v1/endpoints/expenses.py:93):
- A JOIN-os lekérdezést két különálló SELECT-re bontottam (az `assets.py:_get_user_permissions()` mintájára)
- **1. lépés:** `select(OrganizationMember.role)` → Python string (asyncpg auto-konvertálja az ENUM-ot)
- **2. lépés:** `select(OrgRole.permissions).where(OrgRole.name_key == role_string)` — varchar=varchar összehasonlítás
- Függvény interfésze változatlan
| 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ó
1. **Python syntax check**`expenses.py: OK`, `provider_service.py: OK`
2. **Sync Engine** → 1287/1287 elements OK, 0 fixes needed
3. **Frontend TypeScript** → All type-safe, no new compilation errors
### Verifikáció
- `POST /api/v1/expenses/`**HTTP 201**
- Biztosítási költség sikeresen létrejött
---
## P0 BUGFIX - Odometer Source of Truth & Sync (Gitea #406)
## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413)
**Dátum:** 2026-07-10
**Scope:** Frontend (SimpleFuelModal.vue)
**Dátum:** 2026-07-25
### 🔧 Módosított fájlok
### Probléma
...
#### 1. Frontend: [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:228)
- **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ó
1. **Python syntax check** → `expenses.py: OK`
2. **Sync Engine** → 1287/1287 elements OK, 0 fixes needed
3. **Frontend** → `fetchLastOdometer()` most a `/assets/vehicles/{id}` végpontot használja, ami a `current_mileage`-t adja vissza
---
## P0 EPIC - CostEntryWizard UI/UX Parity & Categories (Gitea #407)
**Dátum:** 2026-07-10
**Scope:** Frontend (CostEntryWizard.vue)
### 🔧 Módosított fájlok
#### 1. Frontend: [`CostEntryWizard.vue`](frontend_app/src/components/cost/CostEntryWizard.vue)
**P0 EPIC 1: Global Vehicle Selection**
- `useVehicleStore` importálva, `fetchUserVehicles()` metódus hozzáadva
- `selectedVehicleId` ref + `userVehicles` ref a járműlista kezelésére
- `effectiveVehicle` computed: prioritás szerint oldja fel a járművet (prop > dropdown)
- 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ó
1. **Vite build** → `CostEntryWizard-KIg7Uvhm.js` (31.25 kB) sikeresen lefordult, 0 error
2. **Template** → mind a 4 P0 EPIC feature implementálva
3. **Script** → handleSubmit, resetForm, editCost hydration, lifecycle hooks teljesek
## P0 EPIC - Enterprise Taxonomy Seed Script (Gitea #407 kiegészítés)
### 📋 Új fájl: [`seed_expertise_enterprise.py`](backend/app/scripts/seed_expertise_enterprise.py)
**Cél:** Enterprise Taxonomy (Pénzügy, Biztosítás, Hatóságok) beszúrása a `marketplace.expertise_tags` táblába.
**Beszúrt 9 új tag (IDs 769-777):**
#### Level 1: Pénzügy és Biztosítás (Finance & Insurance) → ID 769
- Level 2: Gépjármű biztosító (Vehicle Insurance) → ID 770
- Level 2: Lízing és Finanszírozás (Leasing & Financing) → ID 771
- Level 2: Bank és Hitelintézet (Bank & Credit Institution) → ID 772
#### 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ó
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. **Adatbázis ellenőrzés:** `SELECT count(*) FROM marketplace.expertise_tags;` → 145 total record
3. **Vite build:** `docker compose exec sf_public_frontend npm run build` → 6.80s, 0 error, CostEntryWizard chunk 31.25 kB
---
## P0 EPIC - Seed Insurance Providers
**Dátum:** 2026-07-11
**Scope:** Backend (Seed Script)
### 🔧 Létrehozott fájl
#### 1. [`seed_insurance_providers.py`](backend/app/scripts/seed_insurance_providers.py)
- **33 magyar biztosító** seedelése a marketplace ökoszisztémába
- Adatforrás: Netrisk nyílt adatok (Alfa, Allianz, Generali, K&H, stb.)
- **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ó
1. **Adatbázis:** 33 rekord a `marketplace.service_providers` táblában, mind `status=approved`, `source=api_import`
2. **API Search:** `GET /api/v1/providers/search?query=biztosito` visszaadja a biztosítókat (pl. Allianz, Generali, Groupama)
3. **ServiceProfile** és **ServiceExpertise** kapcsolatok létrejöttek minden providerhez
---
## P0 EPIC: i18n JSONB Database Migration (Phase 1) — Gitea #399
**Dátum:** 2026-07-23
**Scope:** Backend (Models, API, Services, Schemas, Seeds)
### 🔧 Módosított fájlok
#### 1. Modellek — Régi oszlopok eltávolítva
- [`marketplace/service.py`](backend/app/models/marketplace/service.py) — `ExpertiseTag`: `name_hu`, `name_en`, `name_translations`, `description` → csak `name_i18n`, `description_i18n`
- [`vehicle/vehicle_definitions.py`](backend/app/models/vehicle/vehicle_definitions.py) — `BodyTypeDictionary`: `name_hu` → csak `name_i18n`
- [`core_logic.py`](backend/app/models/core_logic.py) — `ServiceCatalog`: `name`, `description` → csak `name_i18n`, `description_i18n`
#### 2. API Végpontok
- [`providers.py`](backend/app/api/v1/endpoints/providers.py) — Category tree, autocomplete: `name_hu`/`name_en` → `name_i18n`
- [`catalog.py`](backend/app/api/v1/endpoints/catalog.py) — Body types: `name_hu` → `name_i18n`
#### 3. Service Réteg
- [`provider_service.py`](backend/app/services/provider_service.py) — CategoryInfo, tag creation, tag search: `name_hu`/`name_en` → `name_i18n`
#### 4. Schema Réteg
- [`provider.py`](backend/app/schemas/provider.py) — `CategoryTreeNode`, `CategoryAutocompleteItem`, `ExpertiseCategoryOut`, `CategoryInfo`: `name_hu`/`name_en` → `name_i18n`
#### 5. Seed Scriptek (7 fájl)
- `seed_expertise_tags.py`, `seed_dismantler_category.py`, `seed_gas_station.py`, `seed_expertise_enterprise.py`, `seed_insurance_providers.py`, `seed_body_types.py`, `seed_expertise_taxonomy.py`
#### 6. Adatbázis
- 6 shadow column eltávolítva SQL ALTER TABLE segítségével
- Sync engine: 0 shadow column, 1281 OK — tökéletes szinkron
### ⚠️ Megjegyzés
- A konténer újraépítése után pre-existing import hibák derültek ki az `expenses.py`-ban (3 db):
1. `from app.models.fleet.organization import Organization` → `app.models.marketplace.organization`
2. `from app.models.fleet.org_member import OrganizationMember` → `app.models.marketplace.organization`
3. `from app.models.marketplace.service import ServiceProvider` → `app.models.identity.social`
4. `from app.models.system.system_parameter import SystemParameter` → `app.models.system.system`
- Mind a 4 import hiba javítva, konténer újraépítve, API sikeresen fut.
A konténer indításakor előre létező import hibák jelentkeztek (`expenses.py` → `Organization`, `AssetEvent`, `OdometerReading` rossz import útvonalai), amelyek NEM kapcsolódnak a #399-es kártyához. Ezek javítása külön kártyát igényel.
---
## P1 EPIC - Registration UI Upgrade & Google SSO Integration
**Dátum:** 2026-07-23
### Módosított fájlok
#### Backend
1. **`backend/app/api/v1/endpoints/auth.py`** — Google OAuth handler hozzáadva:
- `GoogleAuthRequest` Pydantic modell (`id_token`, `access_token`, `referred_by_code` mezőkkel)
- `POST /auth/google` végpont: Google token verifikáció (tokeninfo API), felhasználó lekérés/létrehozás `SocialAuthService.get_or_create_social_user()`-on keresztül, JWT token generálás, refresh_token cookie beállítás
- Támogatja mind az `id_token` (Google Sign-In), mind az `access_token` (OAuth2 token client) típusokat
2. **`backend/app/services/social_auth_service.py`** — `get_or_create_social_user()` metódus kiegészítve:
- `referred_by_code` paraméter hozzáadva
- Új social regisztrációnál a meghívó kód alapján a referrer (`User.referral_code`) felkutatása és `referred_by_id` beállítása
- `generate_secure_slug` importálva az új felhasználók referral_code generálásához
#### Frontend
3. **`frontend_app/src/components/LoginModal.vue`** — Regisztrációs UI frissítve:
- `referredByCode` ref hozzáadva
- "Meghívó kód" (Referral Code) input mező hozzáadva a regisztrációs űrlapba (jelszó mező alá)
- "Vagy regisztrálj Social fiókkal" elválasztó + Google gomb hozzáadva a regisztrációs űrlapba (a login form dizájnját tükrözve)
- Google gomb @click esemény bekötve a login űrlapon (`handleGoogleLogin`)
- `handleGoogleRegister()` függvény: a meghívó kódot localStorage-ba menti (`pending_referral_code`), majd meghívja a `handleGoogleLogin()`-t
- `handleRegister()` frissítve: `referred_by_code` mezőt is küld a backendnek
- `loadGoogleScript()`: dinamikusan betölti a Google Identity Services SDK-t
4. **`frontend_app/src/stores/auth.ts`** — `googleAuth` action hozzáadva:
- `access_token` küldése a `/auth/google` végpontra
- Opcionális `referred_by_code` támogatása
- Token perzisztálás, user profil és szervezetek betöltése
5. **`frontend_app/.env`** — `VITE_GOOGLE_CLIENT_ID` környezeti változó hozzáadva
### Technikai részletek
- **Google OAuth flow**: A frontend a Google Identity Services (GIS) `initTokenClient`-jével kér `access_token`-t, amit a backend a `https://oauth2.googleapis.com/tokeninfo?access_token={token}` endpointon verifikál
- **Referral code flow**: Regisztrációkor a meghívó kódot a frontend elküldi a backendnek, ami a `User.referral_code` mező alapján megkeresi a referrer-t és beállítja a `referred_by_id` kapcsolatot
- **Biztonság**: A backend ellenőrzi a token `azp` (authorized party) mezőjét a `GOOGLE_CLIENT_ID`-val
- **Adatbázis**: Nincs új migráció, a meglévő `User.referral_code` és `referred_by_id` mezők használva
- **Sync engine**: 1281 elem szinkronban, 0 hiba
- **Modul betöltés**: Mindkét módosított backend modul sikeresen betöltődik
## 2026-07-23 — Referral & Credit Ecosystem Full Stack Analysis (#409)
**Elemzés típusa:** Read-only audit
**Vizsgált komponensek:** identity.users, identity.wallet, gamification.point_rules, gamification.points_ledger, audit.financial_ledger, AuthService, SocialAuthService, GamificationService, LoginModal.vue, auth.ts
**Főbb megállapítások:**
1. Single-level (L1) referral tracking létezik `User.referred_by_id` mezőn keresztül
2. Jutalmazás kizárólag XP alapú (50 pont P2P_REFERRAL_SUCCESS), nincs kredit-alapú jutalom
3. **KRITIKUS HIBA:** `auth.ts register()` nem küldi el a `referred_by_code`-ot a payload-ban
4. Hiányzik: dedikált Referral tábla, multi-level (L2/L3) követés, commission engine, referral API-k
5. FinancialLedger és Wallet infrastruktúra alkalmas lenne bővítésre
**Dokumentáció:** `/opt/docker/docs/referral_credit_ecosystem_analysis.md`
---
## 2-Level MLM Commission Distribution
**Dátum:** 2026-07-24
### Megvalósított komponensek:
1. **Model** [`backend/app/models/marketplace/commission.py`](backend/app/models/marketplace/commission.py:119) — `upline_commission_percent` oszlop hozzáadva (Numeric(5,2), nullable, default=0.00)
2. **Schemas** [`backend/app/schemas/commission.py`](backend/app/schemas/commission.py) — `CommissionDistributionRequest`, `CommissionDistributionItem`, `CommissionDistributionResponse` új Pydantic modellek
3. **Service** [`backend/app/services/commission_service.py`](backend/app/services/commission_service.py:377) — `distribute_commission()` 2-level MLM engine: buyer → Gen1 (commission_percent) → Gen2 (upline_commission_percent)
4. **API** [`backend/app/api/v1/endpoints/admin_commission.py`](backend/app/api/v1/endpoints/admin_commission.py:250) — `POST /admin/commission-rules/distribute` végpont
5. **Frontend** [`frontend_admin/pages/finance/commission-rules.vue`](frontend_admin/pages/finance/commission-rules.vue) — `upline_commission_percent` mező az admin UI-ban
6. **i18n** — EN/HU locale fájlok frissítve
### Teszteredmények:
- **4/4 teszt PASS**
- Gen1: 5% of 100000 = 5000.00 ✅
- Gen2: 2% of 100000 = 2000.00 ✅
- Total: 7000.00 ✅
- Max amount cap (50000) verified ✅
- Sync engine: 1303 OK, 0 Fixed, 0 Shadow
## Phase 2 - MLM Commission & Wallet Integration (2026-07-24)
### ✅ Implementált változtatások
1. **CommissionRule model** - 3 új mező: gen1_max_amount, gen2_max_amount (Numeric(18,2)), gen2_renewal_percent (Numeric(5,2))
2. **Pydantic schemas** - új mezők a Create/Update/Response DTO-kban + is_renewal flag a DistributionRequest-ben
3. **commission_service.py** - Phase 2 business logic:
- _credit_commission_to_wallet(): Wallet.earned_credits + FinancialLedger CREDIT
- _is_user_recently_active(): 180 napos inaktivitás ellenőrzés
- Per-level caps (gen1_max_amount/gen2_max_amount fallback commission_max_amount-ra)
- Inaktív Gen1 esetén Gen2 kifizetés kihagyása
- Renewal logika (gen2_renewal_percent vs upline_commission_percent)
- db.commit() a wallet/ledger írások után
4. **Frontend Admin UI** - 3 új mező a commission-rules.vue formban + i18n kulcsok (HU/EN)
5. **Sync Engine** - 3 új oszlop sikeresen hozzáadva a marketplace.commission_rules táblához
6. **Tesztek** - 4/4 teszt sikeresen lefutott (commission distribution + max amount cap)
## Phase 3 - Commission Rule Priority Engine & Conflict Resolution Flow (2026-07-24)
### ✅ Implementált változtatások
**Task 1: Priority Resolution Engine (Verifikáció)**
- [`get_active_rule()`](backend/app/services/commission_service.py:333) már helyesen implementálva:
- `campaign_priority`: campaign=0, egyéb=1
- `region_priority`: exact match=0, GLOBAL=1, else=2
- `tier_priority`: PLATINUM=0 → CONTRACTED=4, else=99
- `.order_by(campaign_priority, region_priority, tier_priority).limit(1)`
**Task 2: Conflict Detection & Audit Logging**
- [`schemas/commission.py`](backend/app/schemas/commission.py:159): `ConflictingRuleInfo` + `ConflictResponse` DTO-k
- [`commission_service.py`](backend/app/services/commission_service.py:59): `check_rule_conflict()` - exact match (tier, region_code, is_campaign) aktív szabályokra
- [`commission_service.py`](backend/app/services/commission_service.py:103): `create_commission_rule()` - conflict check + force_override deaktiválás
- [`commission_service.py`](backend/app/services/commission_service.py:189): `update_commission_rule()` - conflict check exclude_rule_id-vel
- [`admin_commission.py`](backend/app/api/v1/endpoints/admin_commission.py:179): 409 Conflict response `ConflictingRuleInfo` struktúrával
**Task 3: Frontend UI Approval Workflow**
- [`commission-rules.vue`](frontend_admin/pages/finance/commission-rules.vue:595): `showConflictModal`, `conflictingRule`, `pendingPayload` refs
- Konfliktus modal: amber figyelmeztetés + conflicting rule adatok + Cancel/Confirm gombok
- `saveRule()`: 409 catch → pendingPayload tárolás → modal megjelenítés
- `confirmOverride()`: re-submit `force_override: true`-val
- [`hu/commission.json`](frontend_admin/i18n/locales/hu/commission.json): magyar konfliktus szövegek
- [`en/commission.json`](frontend_admin/i18n/locales/en/commission.json): angol konfliktus szövegek
### ✅ Verifikáció
1. **Sync Engine** - 1306 items OK (schema in sync)
2. **Commission Distribution Test** - 4/4 passed
3. **Python imports** - No syntax errors
---
## FinancialManager: Subscription Purchase & Payment Gateway Module (Gitea #411)
**Dátum:** 2026-07-24
**Scope:** Backend (Strategy Pattern, FastAPI, SQLAlchemy)
### 🔧 Létrehozott fájlok
1. [`mock_payment_gateway.py`](backend/app/services/mock_payment_gateway.py) - MockPaymentGateway (Strategy Pattern implementáció, 3 mód: auto_approve, simulate_failure, simulate_timeout)
2. [`subscription_activator.py`](backend/app/services/subscription_activator.py) - SubscriptionActivator (User/Org szintű aktiválás P0 stackinggel)
3. [`financial_manager.py`](backend/app/services/financial_manager.py) - FinancialManager orchestrator (teljes vásárlási életciklus)
4. [`financial_manager.py`](backend/app/schemas/financial_manager.py) - PurchaseRequest/PurchaseResponse Pydantic sémák
5. [`financial_manager.py`](backend/app/api/v1/endpoints/financial_manager.py) - API végpontok (POST /purchase-package, GET /status)
### 🔧 Módosított fájlok
1. [`api.py`](backend/app/api/v1/api.py) - Financial Manager router regisztráció
### 🐛 Javított adatbázis enumok
- `finance.wallet_type` - hozzáadva: EARNED, PURCHASED, SERVICE_COINS, VOUCHER
- `finance.payment_intent_status` - hozzáadva: COMPLETED, CANCELLED, EXPIRED
### ✅ Verifikáció
1. **Sync Engine** - 1306 items OK (schema in sync)
2. **FinancialManager Test Suite** - 24/24 passed
3. **API Integration** - purchase-package endpoint fully functional
---
## Build Inactivity & Subscription Monitor (Gitea #412)
**Dátum:** 2026-07-24
**Scope:** Backend (Workers, Scheduler, Auth, DB Schema)
### 🔧 Módosított/Létrehozott fájlok
#### 1. [`identity.py`](backend/app/models/identity/identity.py:178)
- Hozzáadva: `last_activity_at` mező a `User` modellhez (DateTime(timezone=True), nullable)
- Komment: "Updated on login/token-refresh; used by the 180-day inactivity worker"
#### 2. [`subscription_monitor_worker.py`](backend/app/workers/system/subscription_monitor_worker.py) (ÚJ)
- Teljes subscription lifecycle kezelés: `UserSubscription` és `OrganizationSubscription`
- `FOR UPDATE SKIP LOCKED` atomi zárolás
- Fallback `is_default_fallback` tier-re lejáratkor
- `FinancialLedger` bejegyzés (`SUBSCRIPTION_EXPIRED`)
- `NotificationService` értesítés
- Nem írja felül a meglévő `subscription_worker.py`-t
#### 3. [`inactivity_monitor_worker.py`](backend/app/workers/system/inactivity_monitor_worker.py) (ÚJ)
- 180 napos inaktivitás detektálás (kétfázisú: `last_activity_at` + `created_at` alapján)
- Subquery approach a `FOR UPDATE` + outer join PostgreSQL hiba elkerülésére
- `custom_permissions['inactivity_suspended']` flag beállítása MLM engine számára
- `is_active = False`, `FinancialLedger` bejegyzés, `NotificationService` értesítés
#### 4. [`scheduler.py`](backend/app/core/scheduler.py:207)
- Subscription Monitor job: 01:00 UTC (CronTrigger, jitter=900)
- Inactivity Monitor job: 02:00 UTC (CronTrigger, jitter=900)
- Mindkét job `ProcessLog`-ba naplóz
#### 5. [`auth.py`](backend/app/api/v1/endpoints/auth.py:65)
- `user.last_activity_at = datetime.now(timezone.utc)` beállítás login, verify-email és Google auth végpontokban
### ✅ Verifikáció
1. **Sync Engine** - 1306 items OK, 1 fixed (`last_activity_at` oszlop hozzáadva)
2. **Subscription Monitor** - Sikeres futás, 0 expired subscription (dev adatbázis)
3. **Inactivity Monitor** - Sikeres futás, 0 inactive user (dev adatbázis)
4. **FOR UPDATE fix** - Subquery approach alkalmazva a PostgreSQL outer join korlátozás miatt
### Documentation
`docs/p0_expense_500_import_bugfix_2026-07-26.md`
**Gitea Issue:** #421 (closed)

View File

@@ -27,6 +27,7 @@ api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expe
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"])
api_router.include_router(finance_admin.router, prefix="/finance/issuers", tags=["finance-admin"])
api_router.include_router(finance_admin.router, prefix="/admin/finance", tags=["admin-finance"])
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])

View File

@@ -42,7 +42,7 @@ from app.models.vehicle.asset import Asset
from app.models.vehicle.asset import AssetEvent
from app.models.vehicle.asset import OdometerReading
from app.models.marketplace.organization import Organization
from app.models.marketplace.organization import OrganizationMember
from app.models.marketplace.organization import OrganizationMember, OrgRole
from app.models.identity.social import ServiceProvider
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
from app.models.system.system import SystemParameter
@@ -54,6 +54,123 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# ── SINGLE EXPENSE READ ENDPOINT ──
# P0 BUGFIX (2026-07-26): Dedicated single-expense endpoint that returns
# enriched AssetCostResponse with resolved category name, parent_category_id,
# provider name, etc. This is used by the frontend edit modal hydration.
#
# NOTE: This MUST be registered BEFORE the {asset_id} wildcard route to avoid
# FastAPI routing the "by-id" path segment as an asset_id UUID.
@router.get("/by-id/{expense_id}")
async def get_expense_by_id(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
"""
Get a single expense by its ID with enriched fields.
Returns a fully populated AssetCostResponse including:
- category_name, category_code, parent_category_id (from CostCategory JOIN)
- service_provider_name (from ServiceProvider JOIN)
- vendor_name (from Organization JOIN via vendor_organization_id)
- description, mileage_at_cost (extracted from data JSONB)
This is the preferred endpoint for frontend edit modal hydration.
"""
from app.models.fleet_finance.models import CostCategory
from app.models.identity.social import ServiceProvider
from app.models.marketplace.organization import Organization
stmt = (
select(AssetCost)
.where(AssetCost.id == expense_id)
)
result = await db.execute(stmt)
cost = result.scalar_one_or_none()
if not cost:
raise HTTPException(status_code=404, detail="Expense not found.")
# Resolve enriched fields via explicit queries (no lazy loading surprises)
# 1. Category details + parent_id
category_name = None
category_code = None
parent_category_id = None
if cost.category_id is not None:
cat_stmt = select(CostCategory).where(CostCategory.id == cost.category_id)
cat_result = await db.execute(cat_stmt)
category = cat_result.scalar_one_or_none()
if category:
category_name = category.name
category_code = category.code
parent_category_id = category.parent_id
# 2. Service provider name
service_provider_name = None
if cost.service_provider_id is not None:
sp_stmt = select(ServiceProvider).where(ServiceProvider.id == cost.service_provider_id)
sp_result = await db.execute(sp_stmt)
provider = sp_result.scalar_one_or_none()
if provider:
service_provider_name = provider.name
# 3. Vendor organization name
vendor_name = None
if cost.vendor_organization_id is not None:
org_stmt = select(Organization).where(Organization.id == cost.vendor_organization_id)
org_result = await db.execute(org_stmt)
vendor_org = org_result.scalar_one_or_none()
if vendor_org:
vendor_name = vendor_org.name
# 4. Extract description and mileage from data JSONB
data = cost.data or {}
description = data.get("description")
mileage_at_cost = data.get("mileage_at_cost")
response = AssetCostResponse(
id=cost.id,
asset_id=cost.asset_id,
organization_id=cost.organization_id,
category_id=cost.category_id,
status=cost.status,
linked_asset_event_id=cost.linked_asset_event_id,
# Monetary
amount_gross=cost.amount_gross,
amount_net=cost.amount_net,
vat_rate=cost.vat_rate,
currency=cost.currency,
# Dates
cost_date=cost.date,
invoice_number=cost.invoice_number,
# Invoice dates
invoice_date=cost.invoice_date,
fulfillment_date=cost.fulfillment_date,
# Vendor fields
vendor_organization_id=cost.vendor_organization_id,
service_provider_id=cost.service_provider_id,
external_vendor_name=cost.external_vendor_name,
# Raw data JSONB
data=data,
# Enriched fields
category_name=category_name,
category_code=category_code,
parent_category_id=parent_category_id,
description=description,
mileage_at_cost=mileage_at_cost,
vendor_name=vendor_name,
service_provider_name=service_provider_name,
)
return {
"status": "success",
"data": response,
}
# ── FUEL CATEGORY IDS ──
# These are the category IDs that represent fuel costs.
# Used for auto-approval logic: fuel costs are always auto-approved.
@@ -100,22 +217,34 @@ async def _check_org_capability(
Uses the JSONB permissions field from fleet.org_roles.
Returns True if the user's role has the capability, False otherwise.
"""
from app.models.fleet.org_role import OrgRole
stmt = (
select(OrgRole.permissions)
.join(OrganizationMember, OrganizationMember.role == OrgRole.name)
.where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
)
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if row and isinstance(row, dict):
return row.get(capability, False)
NOTE: Two-step query (not JOIN) to avoid PG ENUM vs varchar type mismatch.
OrganizationMember.role is PG ENUM 'fleet.orguserrole', OrgRole.name_key is varchar.
A direct JOIN would cause: operator does not exist: fleet.orguserrole = character varying
See assets.py:_get_user_permissions() for the same pattern.
"""
# Step 1: Get the user's role name (Python string — asyncpg auto-converts ENUM)
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
return False
# Step 2: Look up permissions from fleet.org_roles using the role name
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 permissions and isinstance(permissions, dict):
return permissions.get(capability, False)
return False

View File

@@ -1,22 +1,29 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/finance_admin.py
"""
Finance Admin API endpoints for managing Issuers with strict RBAC protection.
Finance Admin API endpoints for managing Issuers and FinancialLedger with strict RBAC protection.
Protected by DB-driven RequirePermission("finance:view") dependency.
"""
from fastapi import APIRouter, Depends, HTTPException, status
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from sqlalchemy import select, func, text
from typing import List, Optional
from app.api import deps
from app.models.identity import User
from app.models.marketplace.finance import Issuer
from app.schemas.finance import IssuerResponse, IssuerUpdate
from app.models.system.audit import FinancialLedger
from app.schemas.finance import IssuerResponse, IssuerUpdate, FinancialLedgerListResponse, FinancialLedgerResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# ──────────────────────────────────────────────────────────────────────────────
# Issuer Endpoints (existing)
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
async def list_issuers(
db: AsyncSession = Depends(deps.get_db),
@@ -27,6 +34,7 @@ async def list_issuers(
List all Issuers (billing entities).
Protected by RequirePermission("finance:view").
"""
from app.models.marketplace.finance import Issuer
result = await db.execute(select(Issuer).order_by(Issuer.id))
issuers = result.scalars().all()
return issuers
@@ -44,6 +52,7 @@ async def update_issuer(
Update an Issuer's details (activate/deactivate, revenue limit, API config).
Protected by RequirePermission("finance:edit").
"""
from app.models.marketplace.finance import Issuer
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
issuer = result.scalar_one_or_none()
@@ -61,4 +70,134 @@ async def update_issuer(
await db.commit()
await db.refresh(issuer)
return issuer
return issuer
# ──────────────────────────────────────────────────────────────────────────────
# FinancialLedger Admin Endpoints (NEW)
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/ledger", response_model=FinancialLedgerListResponse, tags=["finance-admin"])
async def list_financial_ledger(
page: int = Query(1, ge=1, description="Oldalszám (1-indexelt)"),
page_size: int = Query(50, ge=1, le=200, description="Rekordok száma oldalanként"),
user_id: Optional[int] = Query(None, description="Szűrés felhasználó ID alapján"),
transaction_type: Optional[str] = Query(None, description="Tranzakció típus szűrés"),
entry_type: Optional[str] = Query(None, description="Könyvelési irány (DEBIT / CREDIT)"),
wallet_type: Optional[str] = Query(None, description="Tárcatípus szűrés"),
date_from: Optional[str] = Query(None, description="Dátum szűrés kezdete (ISO, pl. 2026-01-01)"),
date_to: Optional[str] = Query(None, description="Dátum szűrés vége (ISO, pl. 2026-12-31)"),
sort_by: str = Query("created_at", description="Rendezési mező (created_at, amount, id)"),
sort_order: str = Query("desc", description="Rendezési irány (asc / desc)"),
db: AsyncSession = Depends(deps.get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("finance:view")),
):
"""
List FinancialLedger entries with pagination, filtering, and user details.
Uses raw SQL with explicit joins to avoid SQLAlchemy relationship issues.
Protected by RequirePermission("finance:view").
Returns paginated results with joined user email and person name.
"""
# ── Build WHERE clauses dynamically ──
where_clauses = []
params = {}
if user_id is not None:
where_clauses.append("fl.user_id = :user_id")
params["user_id"] = user_id
if transaction_type is not None:
where_clauses.append("fl.transaction_type = :transaction_type")
params["transaction_type"] = transaction_type
if entry_type is not None:
where_clauses.append("fl.entry_type = :entry_type")
params["entry_type"] = entry_type
if wallet_type is not None:
where_clauses.append("fl.wallet_type = :wallet_type")
params["wallet_type"] = wallet_type
if date_from is not None:
where_clauses.append("fl.created_at >= :date_from::timestamp")
params["date_from"] = date_from
if date_to is not None:
where_clauses.append("fl.created_at <= :date_to::timestamp")
params["date_to"] = date_to
where_sql = " AND ".join(where_clauses) if where_clauses else "TRUE"
# Validate sort_by to prevent SQL injection
allowed_sort_fields = {"created_at", "amount", "id"}
if sort_by not in allowed_sort_fields:
sort_by = "created_at"
sort_direction = "ASC" if sort_order == "asc" else "DESC"
# ── Count query ──
count_sql = f"""
SELECT COUNT(*) FROM audit.financial_ledger fl
WHERE {where_sql}
"""
count_result = await db.execute(text(count_sql), params)
total = count_result.scalar() or 0
# ── Data query with joins ──
offset = (page - 1) * page_size
data_sql = f"""
SELECT
fl.id,
fl.user_id,
fl.person_id,
fl.amount,
fl.currency,
fl.transaction_type,
fl.entry_type::text,
fl.wallet_type::text,
fl.status::text,
fl.details,
fl.created_at,
u.email AS user_email,
p.first_name AS person_first_name,
p.last_name AS person_last_name
FROM audit.financial_ledger fl
LEFT JOIN identity.users u ON u.id = fl.user_id
LEFT JOIN identity.persons p ON p.id = u.person_id
WHERE {where_sql}
ORDER BY fl.{sort_by} {sort_direction}
LIMIT :limit OFFSET :offset
"""
params["limit"] = page_size
params["offset"] = offset
data_result = await db.execute(text(data_sql), params)
rows = data_result.all()
# ── Build response ──
items = []
for row in rows:
user_name = None
if row.person_first_name or row.person_last_name:
parts = [p for p in [row.person_last_name, row.person_first_name] if p]
user_name = " ".join(parts)
items.append(FinancialLedgerResponse(
id=row.id,
user_id=row.user_id,
person_id=row.person_id,
amount=float(row.amount) if row.amount else 0.0,
currency=row.currency,
transaction_type=row.transaction_type,
entry_type=row.entry_type,
wallet_type=row.wallet_type,
status=row.status,
details=row.details,
created_at=row.created_at,
user_email=row.user_email,
user_name=user_name,
))
return FinancialLedgerListResponse(
total=total,
page=page,
page_size=page_size,
items=items,
)

View File

@@ -163,11 +163,7 @@ async def list_expertise_categories(
ExpertiseCategoryOut(
id=t.id,
key=t.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=t.name_i18n if t.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=t.name_hu,
name_en=t.name_en,
category=t.category,
level=t.level,
parent_id=t.parent_id,

View File

@@ -120,6 +120,7 @@ class AssetCostResponse(AssetCostBase):
# Derived / enriched fields (not in DB model directly)
category_name: Optional[str] = None # Resolved from CostCategory relationship
category_code: Optional[str] = None # Resolved from CostCategory relationship
parent_category_id: Optional[int] = None # Resolved from CostCategory.parent_id — needed for two-level category dropdown on frontend
description: Optional[str] = None # Extracted from data['description']
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
@@ -129,4 +130,7 @@ class AssetCostResponse(AssetCostBase):
# P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban
service_provider_id: Optional[int] = None
# P0 BUGFIX: Enriched provider name (resolved from ServiceProvider.name)
service_provider_name: Optional[str] = None # Resolved from ServiceProvider.name via service_provider_id
model_config = ConfigDict(from_attributes=True)

View File

@@ -34,6 +34,34 @@ class IssuerResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class FinancialLedgerResponse(BaseModel):
"""Response schema for FinancialLedger admin listing."""
id: int
user_id: Optional[int] = None
person_id: Optional[int] = None
amount: float
currency: Optional[str] = None
transaction_type: Optional[str] = None
entry_type: Optional[str] = None
wallet_type: Optional[str] = None
status: Optional[str] = None
details: Optional[Dict[str, Any]] = None
created_at: Optional[datetime] = None
# Joined user info
user_email: Optional[str] = None
user_name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class FinancialLedgerListResponse(BaseModel):
"""Paginated response for FinancialLedger admin listing."""
total: int
page: int
page_size: int
items: List[FinancialLedgerResponse]
class IssuerUpdate(BaseModel):
"""Update schema for Issuer entities (PATCH)."""
is_active: Optional[bool] = None

View File

@@ -1,12 +1,12 @@
# /opt/docker/dev/service_finder/backend/app/schemas/system.py
from pydantic import BaseModel, ConfigDict
from typing import Dict, Any, Optional
from typing import Any, Optional
from datetime import datetime
class SystemParameterBase(BaseModel):
description: Optional[str] = None
value: Dict[str, Any] # JSONB mező
value: Any # JSONB mező — bármilyen JSON-szeriális érték lehet (int, str, dict, list)
scope_level: str = 'global'
scope_id: Optional[str] = None
is_active: bool = True
@@ -18,7 +18,7 @@ class SystemParameterCreate(SystemParameterBase):
class SystemParameterUpdate(BaseModel):
description: Optional[str] = None
value: Optional[Dict[str, Any]] = None
value: Optional[Any] = None
is_active: Optional[bool] = None

View File

@@ -320,6 +320,15 @@ async def seed_params():
"scope_level": "global"
},
# --- 11. KÜLSŐ API-K (DVLA, UK) ---
# --- 11. INACTIVITY MONITOR (Robot-21) ---
{
"key": "inactivity_threshold_days",
"value": 180,
"category": "system",
"description": "Felhasználói inaktivitás küszöbértéke napokban. Ha a user utolsó aktivitása régebbi, a fiók felfüggesztésre kerül és a MLM jutalékrendszer megkerüli.",
"scope_level": "global"
},
# --- 12. KÜLSŐ API-K (DVLA, UK) ---
{
"key": "dvla_api_en

View File

@@ -573,16 +573,22 @@ class AtomicTransactionManager:
# Convert to dictionary format
transactions = []
for entry in ledger_entries:
# Extract description from JSONB details field (legacy compatibility)
desc = None
if entry.details and isinstance(entry.details, dict):
desc = entry.details.get("description") or entry.details.get("desc") or None
elif entry.details and isinstance(entry.details, str):
desc = entry.details
transactions.append({
"id": entry.id,
"user_id": entry.user_id,
"amount": float(entry.amount),
"entry_type": entry.entry_type.value,
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
"description": entry.description,
"description": desc,
"transaction_id": str(entry.transaction_id),
"reference_type": entry.reference_type,
"reference_id": entry.reference_id,
"reference_type": None,
"reference_id": None,
"balance_after": float(entry.balance_after) if entry.balance_after else None,
"created_at": entry.created_at.isoformat() if entry.created_at else None
})
@@ -622,15 +628,15 @@ class AtomicTransactionManager:
return {
"wallet_id": wallet.id,
"balances": {
"earned": float(wallet.earned_credits),
"purchased": float(wallet.purchased_credits),
"service_coins": float(wallet.service_coins),
"voucher": float(voucher_balance),
"earned": float(wallet.earned_credits or 0),
"purchased": float(wallet.purchased_credits or 0),
"service_coins": float(wallet.service_coins or 0),
"voucher": float(voucher_balance or 0),
"total": float(
wallet.earned_credits +
wallet.purchased_credits +
wallet.service_coins +
voucher_balance
(wallet.earned_credits or 0) +
(wallet.purchased_credits or 0) +
(wallet.service_coins or 0) +
(voucher_balance or 0)
)
},
"recent_transactions": recent_transactions,

View File

@@ -33,6 +33,7 @@ from app.models.marketplace.commission import (
CommissionTier,
)
from app.models.identity.identity import User, Wallet
from app.models.system import SystemParameter
from app.models.system.audit import (
FinancialLedger,
LedgerEntryType,
@@ -589,26 +590,59 @@ async def _credit_commission_to_wallet(
)
async def _get_inactivity_threshold(db: AsyncSession, default: int = 180) -> int:
"""
Read the inactivity_threshold_days from system.system_parameters.
Falls back to `default` (180) if not found.
"""
try:
stmt = select(SystemParameter).where(
SystemParameter.key == "inactivity_threshold_days",
SystemParameter.scope_level == "global",
SystemParameter.is_active == True,
)
result = await db.execute(stmt)
param = result.scalar_one_or_none()
if param is not None:
val = param.value
if isinstance(val, dict):
return int(val.get("value", val.get("days", default)))
return int(val)
except Exception as e:
logger.warning(
f"Failed to read inactivity_threshold_days from DB: {e}. "
f"Falling back to {default}."
)
return default
async def _is_user_recently_active(
db: AsyncSession,
user_id: int,
inactivity_days: int = 180,
inactivity_days: Optional[int] = None,
) -> bool:
"""
Check if a user has been active within the last `inactivity_days` days.
Check if a user has been active within the inactivity threshold.
A user is considered active if:
1. Their subscription is active (subscription_expires_at > now), OR
2. They have a FinancialLedger entry within the inactivity window.
The inactivity threshold is read from system.system_parameters
(key: inactivity_threshold_days), falling back to 180 days.
Args:
db: Database session.
user_id: The user to check.
inactivity_days: Number of days of inactivity before considered inactive.
inactivity_days: Optional override. If None, reads from DB settings.
Returns:
True if the user is recently active, False otherwise.
"""
# Resolve threshold: parameter override or DB setting
if inactivity_days is None:
inactivity_days = await _get_inactivity_threshold(db)
# Check subscription status first
user = await _lookup_user(db, user_id)
if not user:

View File

@@ -393,22 +393,42 @@ class FinancialOrchestrator:
await db.flush()
# 3. Pénztárca egyenleg visszaállítása
# JAVÍTÁS: A Wallet modellben NINCS "wallet_type" és "balance" mező.
# A usernek egyetlen wallet-je van (user_id UNIQUE constraint),
# a credit típusok külön oszlopokban: earned_credits, purchased_credits, service_coins.
# A wallet_type-t az eredeti FinancialLedger bejegyzésből olvassuk ki,
# és a megfelelő oszlopot frissítjük (pont mint process_payment()-ben).
wallet_query = select(Wallet).where(
and_(
Wallet.user_id == original_entry.user_id,
Wallet.wallet_type == original_entry.wallet_type
)
Wallet.user_id == original_entry.user_id
).with_for_update()
wallet_result = await db.execute(wallet_query)
wallet = wallet_result.scalar_one_or_none()
if wallet:
new_balance = wallet.balance - original_entry.amount
wallet_type = original_entry.wallet_type
update_values = {}
if wallet_type == WalletType.EARNED:
new_balance = Decimal(str(wallet.earned_credits)) + original_entry.amount
update_values['earned_credits'] = float(new_balance)
elif wallet_type == WalletType.PURCHASED:
new_balance = Decimal(str(wallet.purchased_credits)) + original_entry.amount
update_values['purchased_credits'] = float(new_balance)
elif wallet_type == WalletType.SERVICE_COINS:
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
update_values['service_coins'] = float(new_balance)
elif wallet_type == WalletType.VOUCHER:
# VOUCHER típusnál nincs dedikált mező, SERVICE_COINS-ba tesszük
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
update_values['service_coins'] = float(new_balance)
else:
raise ValueError(f"Ismeretlen wallet_type: {wallet_type}")
await db.execute(
update(Wallet)
.where(Wallet.id == wallet.id)
.values(balance=new_balance)
.values(**update_values)
)
# 4. Számlakiállító bevételének csökkentése

View File

@@ -1,17 +1,18 @@
#!/usr/bin/env python3
"""
🤖 Inactivity Monitor Worker (Robot-21)
Detects users who have been inactive for 180+ days and flags them so the
MLM Commission Engine can bypass them for Gen1/Gen2 reward calculations.
Detects users who have been inactive for N+ days (configured via system_parameters)
and flags them so the MLM Commission Engine can bypass them for Gen1/Gen2 rewards.
Process:
1. Find users where last_activity_at < NOW() - INTERVAL '180 days'
1. Read inactivity_threshold_days from system.system_parameters (fallback: 180)
2. Find users where last_activity_at < NOW() - INTERVAL 'N days'
AND is_active = True AND is_deleted = False
2. Set is_active = False
3. Set custom_permissions['inactivity_suspended'] = True
3. Set is_active = False
4. Set custom_permissions['inactivity_suspended'] = True
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
4. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
5. Send notification
5. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
6. Send notification
MLM Integration:
- The custom_permissions['inactivity_suspended'] flag is checked by the
@@ -38,30 +39,61 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models.identity import User
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
from app.models.system import SystemParameter
from app.services.notification_service import NotificationService
logger = logging.getLogger("inactivity-monitor-worker")
# ── Constants ──────────────────────────────────────────────────────────────────
PROCESS_NAME = "Inactivity-Monitor"
INACTIVITY_DAYS = 180
DEFAULT_INACTIVITY_DAYS = 180
async def _get_inactivity_threshold(db: AsyncSession) -> int:
"""
Read the inactivity_threshold_days from system.system_parameters.
Falls back to DEFAULT_INACTIVITY_DAYS (180) if not found.
"""
try:
stmt = select(SystemParameter).where(
SystemParameter.key == "inactivity_threshold_days",
SystemParameter.scope_level == "global",
SystemParameter.is_active == True,
)
result = await db.execute(stmt)
param = result.scalar_one_or_none()
if param is not None:
val = param.value
if isinstance(val, dict):
# Could be stored as {"value": 180} or just 180
return int(val.get("value", val.get("days", DEFAULT_INACTIVITY_DAYS)))
return int(val)
except Exception as e:
logger.warning(
f"Failed to read inactivity_threshold_days from DB: {e}. "
f"Falling back to {DEFAULT_INACTIVITY_DAYS}."
)
return DEFAULT_INACTIVITY_DAYS
async def process_inactive_users(db: AsyncSession) -> dict:
"""
Finds and flags users inactive for 180+ days.
Finds and flags users inactive for N+ days (configurable via system_parameters).
Two-phase detection:
A. Users with last_activity_at: last_activity_at < NOW() - 180 days
A. Users with last_activity_at: last_activity_at < NOW() - N days
B. Users without last_activity_at (never logged in):
created_at < NOW() - 180 days AND last_activity_at IS NULL
created_at < NOW() - N days AND last_activity_at IS NULL
Returns:
dict: Statistics (processed_count, suspended_users, errors)
"""
inactivity_days = await _get_inactivity_threshold(db)
now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=INACTIVITY_DAYS)
stats = {"processed": 0, "suspended": [], "errors": []}
cutoff = now - timedelta(days=inactivity_days)
stats = {"processed": 0, "suspended": [], "errors": [], "threshold_days": inactivity_days}
logger.info(f"Inactivity threshold: {inactivity_days} days (cutoff={cutoff.isoformat()})")
# ── Phase A: Users with last_activity_at ──
# NOTE: We use a subquery approach to avoid PostgreSQL's
@@ -114,7 +146,7 @@ async def process_inactive_users(db: AsyncSession) -> dict:
try:
# Determine the reference timestamp for this user
ref_ts = user.last_activity_at or user.created_at
days_since = (now - ref_ts).days if ref_ts else INACTIVITY_DAYS
days_since = (now - ref_ts).days if ref_ts else inactivity_days
# 1. Deactivate the user
user.is_active = False
@@ -141,7 +173,7 @@ async def process_inactive_users(db: AsyncSession) -> dict:
),
"inactivity_days": days_since,
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
"cutoff_days": INACTIVITY_DAYS,
"cutoff_days": inactivity_days,
},
)
db.add(ledger_entry)

View File

@@ -72,7 +72,11 @@ async def process_expired_subscriptions(db: AsyncSession) -> dict:
user_id=user.id,
amount=0.0,
entry_type=LedgerEntryType.DEBIT,
wallet_type=WalletType.SYSTEM,
# JAVÍTÁS: WalletType.SYSTEM nem létező enum érték.
# A WalletType csak EARNED, PURCHASED, SERVICE_COINS, VOUCHER értékeket támogat.
# Mivel ez csak egy subscription lejárati naplóbejegyzés (amount=0),
# a SERVICE_COINS a legmegfelelőbb alapértelmezett választás.
wallet_type=WalletType.SERVICE_COINS,
transaction_type="SUBSCRIPTION_EXPIRED",
description=f"Előfizetés lejárt: {old_plan} → FREE",
reference_type="subscription",

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
User Relations Repair Script
-----------------------------
Idempotens: csak hiányzó rekordokat hoz létre.
Biztosítja, hogy minden aktív user rendelkezzen:
1. Personal Organization-nel (fleet.organizations) — ha még nincs
2. Wallet-tel (identity.wallets) — ha még nincs
3. Subscription-nel (finance.user_subscriptions, tier=private_free_v1) — ha még nincs
Futtatás:
docker compose exec sf_api python3 /app/backend/scripts/repair_user_relations.py
"""
import asyncio
import sys
import os
from datetime import datetime, timezone
from decimal import Decimal
from uuid import uuid4
# Path setup for the container
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/backend')
os.environ.setdefault('DATABASE_URL', 'postgresql+asyncpg://...') # Will be read from .env
from app.database import AsyncSessionLocal
from sqlalchemy import select, text
from app.models.identity.identity import User, Wallet
from app.models.core_logic import UserSubscription
from app.models.marketplace.organization import Organization
from app.core.config import settings
DEFAULT_TIER_ID = 13 # private_free_v1
DEFAULT_CURRENCY = "HUF"
async def ensure_organization(db, user):
"""
Create personal organization if user doesn't have one.
Returns: org_id (existing or newly created)
"""
# Check if user already owns an organization (owner_id or legal_owner_id)
result = await db.execute(
select(Organization).where(
Organization.owner_id == user.id
).limit(1)
)
org = result.scalar_one_or_none()
if org:
print(f" [OK] Organization already exists for user {user.id}: org_id={org.id}")
return org.id
# Create new personal organization
folder_slug = f"user-{user.id}-{int(datetime.now(timezone.utc).timestamp())}"
now = datetime.now(timezone.utc)
org = Organization(
name=f"{user.email}'s Garage",
full_name=f"{user.email}'s Personal Garage",
display_name=f"{user.email}'s Garage",
folder_slug=folder_slug,
owner_id=user.id,
org_type="individual",
status="active",
is_active=True,
is_deleted=False,
is_verified=False,
subscription_plan="FREE",
base_asset_limit=5,
purchased_extra_slots=0,
notification_settings={},
external_integration_config={},
first_registered_at=now,
current_lifecycle_started_at=now,
lifecycle_index=1,
is_anonymized=False,
default_currency="HUF",
country_code="HU",
language="hu",
is_ownership_transferable=False,
visual_settings={},
is_affiliate_partner=False,
aliases=[],
tags=[],
settings={},
custom_features={},
created_at=now,
)
db.add(org)
await db.flush()
print(f" [CREATED] Organization for user {user.id}: org_id={org.id}")
return org.id
async def ensure_wallet(db, user, org_id):
"""
Create wallet if user doesn't have one.
wallet.user_id has UNIQUE constraint, so at most one wallet per user.
Returns: True if created, False if already existed
"""
result = await db.execute(
select(Wallet).where(Wallet.user_id == user.id).limit(1)
)
wallet = result.scalar_one_or_none()
if wallet:
print(f" [OK] Wallet already exists for user {user.id}: wallet_id={wallet.id}")
return False
wallet = Wallet(
user_id=user.id,
earned_credits=Decimal('0'),
purchased_credits=Decimal('0'),
service_coins=Decimal('0'),
currency=DEFAULT_CURRENCY,
organization_id=org_id,
)
db.add(wallet)
await db.flush()
print(f" [CREATED] Wallet for user {user.id}: wallet_id={wallet.id}")
return True
async def ensure_subscription(db, user):
"""
Create free tier subscription if user doesn't have one.
Returns: True if created, False if already existed
"""
result = await db.execute(
select(UserSubscription).where(UserSubscription.user_id == user.id).limit(1)
)
sub = result.scalar_one_or_none()
if sub:
print(f" [OK] Subscription already exists for user {user.id}: sub_id={sub.id}")
return False
sub = UserSubscription(
user_id=user.id,
tier_id=DEFAULT_TIER_ID,
valid_from=datetime.now(timezone.utc),
is_active=True,
)
db.add(sub)
await db.flush()
print(f" [CREATED] Subscription for user {user.id}: sub_id={sub.id}")
return True
async def repair_all_users():
"""Main repair loop."""
print("=" * 60)
print(" USER RELATIONS REPAIR SCRIPT")
print(f" Started at: {datetime.now(timezone.utc).isoformat()}")
print(f" Default tier: {DEFAULT_TIER_ID} (private_free_v1)")
print("=" * 60)
async with AsyncSessionLocal() as db:
# Get all active, non-deleted users
result = await db.execute(
select(User).where(
User.is_active == True,
User.is_deleted == False,
)
)
users = result.scalars().all()
stats = {
"total": len(users),
"wallets_created": 0,
"subs_created": 0,
"orgs_created": 0,
"errors": [],
}
print(f"\nFound {len(users)} active users to process.\n")
for user in users:
print(f"--- Processing user {user.id}: {user.email} ---")
try:
org_id = await ensure_organization(db, user)
# Track org creation — since ensure_organization always returns org_id,
# we can't easily diff "created" vs "existed" without checking first.
# We'll track it manually.
wallet_created = await ensure_wallet(db, user, org_id)
if wallet_created:
stats["wallets_created"] += 1
sub_created = await ensure_subscription(db, user)
if sub_created:
stats["subs_created"] += 1
except Exception as e:
err_msg = f"User {user.id} ({user.email}): {e}"
stats["errors"].append(err_msg)
print(f" [ERROR] {err_msg}")
await db.commit()
print("\n" + "=" * 60)
print(" REPAIR SUMMARY")
print("=" * 60)
print(f" Total active users processed: {stats['total']}")
print(f" Wallets created: {stats['wallets_created']}")
print(f" Subscriptions created: {stats['subs_created']}")
print(f" Errors: {len(stats['errors'])}")
if stats["errors"]:
print("\n Errors:")
for err in stats["errors"]:
print(f" - {err}")
print("=" * 60)
return stats
if __name__ == "__main__":
asyncio.run(repair_all_users())

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Seed script: Populate audit.financial_ledger with 20 dummy records.
Uses psycopg2 (sync) to bypass asyncpg prepared statement enum validation issues.
"""
import uuid
import random
import os
from datetime import datetime, timedelta, timezone
import psycopg2
TRANSACTION_TYPES = [
"SUBSCRIPTION", "COMMISSION", "REFUND", "MANUAL_ADJUSTMENT",
"PURCHASE", "WITHDRAWAL", "BONUS", "FEE", "REWARD",
]
DB_ENTRY_TYPES = ["CREDIT", "DEBIT"]
DB_WALLET_TYPES = ["EARNED", "PURCHASED", "SERVICE_COINS", "VOUCHER"]
DB_STATUSES = ["PENDING", "SUCCESS", "FAILED", "REFUNDED"]
TEST_USER_IDS = [1, 2, 28, 29, 55, 75, 79, 85, 86, 88, 100, 102, 103, 104, 105, 106]
NOW = datetime.now(timezone.utc)
def get_dsn_params():
"""Extract connection parameters from DATABASE_URL."""
db_url = os.environ.get("DATABASE_URL", "postgresql+asyncpg://service_finder_app:AppSafePass_2026@shared-postgres:5432/service_finder")
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
# Parse URL
parts = db_url.replace("postgresql://", "").split("@")
user_pass = parts[0].split(":")
host_db = parts[1].split("/")
host_port = host_db[0].split(":")
return {
"user": user_pass[0],
"password": user_pass[1],
"host": host_port[0],
"port": int(host_port[1]) if len(host_port) > 1 else 5432,
"dbname": host_db[1].split("?")[0],
}
def seed():
params = get_dsn_params()
conn = psycopg2.connect(**params)
conn.autocommit = False
try:
with conn.cursor() as cur:
# Count existing
cur.execute("SELECT COUNT(*) FROM audit.financial_ledger")
print(f"Existing records: {cur.fetchone()[0]}")
# Get person_ids
person_map = {}
for uid in TEST_USER_IDS:
cur.execute("SELECT person_id FROM identity.users WHERE id = %s", (uid,))
pid = cur.fetchone()
if pid and pid[0]:
person_map[uid] = pid[0]
if not person_map:
print("No users with person_id found!")
return
print(f"Users with person records: {list(person_map.keys())}")
inserted = 0
for i in range(20):
uid = random.choice(list(person_map.keys()))
pid = person_map[uid]
entry_t = random.choice(DB_ENTRY_TYPES)
wallet_t = random.choice(DB_WALLET_TYPES)
stat = random.choice(DB_STATUSES)
txn_t = random.choice(TRANSACTION_TYPES)
cur_r = random.choice(["HUF", "EUR", "USD", "GBP"])
amt = round(random.uniform(5.0, 5000.0), 2)
days_ago = random.randint(0, 30)
created_ts = NOW - timedelta(
days=random.randint(0, days_ago),
hours=random.randint(0, 23),
minutes=random.randint(0, 59),
)
ref = "TXN-" + uuid.uuid4().hex[:8].upper()
desc = txn_t.lower().replace("_", " ").title() + f" for user {uid}"
net_amt = round(amt / 1.27, 2) if random.random() > 0.3 else None
tax_amt = round(amt - net_amt, 2) if net_amt else None
balance = round(random.uniform(100, 50000), 2)
inv_stat = "PAID" if stat == "completed" else stat.upper()
txn_id = str(uuid.uuid4())
# psycopg2 handles text → enum cast correctly with simple execute
cur.execute(
"""
INSERT INTO audit.financial_ledger
(user_id, person_id, amount, currency, transaction_type, details,
created_at, entry_type, balance_after, wallet_type, status,
transaction_id, net_amount, tax_amount, gross_amount, invoice_status)
VALUES (
%s, %s, %s, %s, %s,
%s::jsonb,
%s::timestamptz,
%s::audit.ledger_entry_type,
%s,
%s::audit.wallet_type,
%s::audit.ledger_status,
%s::uuid,
%s, %s, %s, %s
)
""",
(
uid, pid, amt, cur_r, txn_t,
f'{{"description": "{desc}", "reference": "{ref}"}}',
created_ts.isoformat(),
entry_t, balance, wallet_t, stat, txn_id,
net_amt, tax_amt, amt, inv_stat,
),
)
inserted += 1
conn.commit()
print(f"✅ Inserted {inserted} records successfully!")
cur.execute("SELECT COUNT(*) FROM audit.financial_ledger")
print(f"Total records now: {cur.fetchone()[0]}")
cur.execute("""
SELECT
fl.entry_type::text,
fl.wallet_type::text,
fl.status::text,
COUNT(*)::int,
ROUND(AVG(fl.amount)::numeric, 2),
u.email
FROM audit.financial_ledger fl
LEFT JOIN identity.users u ON u.id = fl.user_id
GROUP BY fl.entry_type, fl.wallet_type, fl.status, u.email
ORDER BY fl.entry_type, fl.wallet_type
""")
rows = cur.fetchall()
total_sum = sum(r[3] for r in rows) if rows else 0
print(f"\n📊 Summary (total {total_sum} records):")
print(f" {'Entry':8} {'Wallet':15} {'Status':10} {'Cnt':5} {'Avg Amt':10} {'User'}")
print(f" {'-'*8} {'-'*15} {'-'*10} {'-'*5} {'-'*10} {'-'*30}")
for r in rows:
print(f" {r[0]:8} {r[1]:15} {r[2]:10} {r[3]:5} {r[4]:10} {r[5] or 'N/A':30}")
finally:
conn.close()
if __name__ == "__main__":
seed()

View File

@@ -290,6 +290,7 @@ services:
- NUXT_PORT=8502
- NUXT_HOST=0.0.0.0
- NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu
- NODE_OPTIONS=--max-old-space-size=4096
volumes:
- ./frontend_admin:/app
- /app/node_modules
@@ -297,6 +298,10 @@ services:
- sf_net
- shared_db_net
restart: unless-stopped
deploy:
resources:
limits:
memory: 8G
# --- PUBLIC FRONTEND (Epic 11 - Public Portal) ---
sf_public_frontend:

View File

@@ -0,0 +1,181 @@
# 🔍 Comprehensive Financial Module Audit
**Date:** 2026-07-25
**Auditor:** Rendszer-Architect (🏗️ Architect Mode)
**Scope:** Full-stack financial system audit — Models, API, Services, Workers, Frontend
**Executive Summary:**
> The financial backend is **well-architected and modular**. The model layer follows DDD schema separation, the service layer splits into distinct managers, the API provides complete user-facing endpoints, and all system workers properly write to the audit ledger. The **critical gap is the frontend** — zero financial UI exists for end users despite the backend being fully ready.
---
## 1. MODELS — Schema & Architecture
### ✅ STATUS: WELL-ARCHITECTED — No Monolith
| Schema | Table | Purpose | File |
|--------|-------|---------|------|
| `identity` | `wallets` | Quadruple wallet: earned, purchased, service_coins + ActiveVouchers (FIFO) | [`models/identity/identity.py:263`](dev/service_finder/backend/app/models/identity/identity.py:263) |
| `identity` | `active_vouchers` | FIFO voucher tracking with expiration | [`models/identity/identity.py:316`](dev/service_finder/backend/app/models/identity/identity.py:316) |
| `audit` | `financial_ledger` | Immutable double-entry ledger (DEBIT/CREDIT) | [`models/system/audit.py:78`](dev/service_finder/backend/app/models/system/audit.py:78) |
| `fleet_finance` | `cost_categories` | Hierarchical cost category tree | [`models/fleet_finance/models.py:37`](dev/service_finder/backend/app/models/fleet_finance/models.py:37) |
| `fleet_finance` | `asset_costs` | Operational expense log (TCO) | [`models/fleet_finance/models.py:109`](dev/service_finder/backend/app/models/fleet_finance/models.py:109) |
| `fleet_finance` | `asset_financials` | Acquisition & depreciation | [`models/fleet_finance/models.py:180`](dev/service_finder/backend/app/models/fleet_finance/models.py:180) |
| `fleet_finance` | `insurance_providers` | Insurance company catalog | [`models/fleet_finance/models.py:219`](dev/service_finder/backend/app/models/fleet_finance/models.py:219) |
| `fleet_finance` | `vehicle_insurance_policies` | Vehicle insurance policies | [`models/fleet_finance/models.py:248`](dev/service_finder/backend/app/models/fleet_finance/models.py:248) |
| `fleet_finance` | `vehicle_tax_obligations` | Tax obligations per vehicle | [`models/fleet_finance/models.py:298`](dev/service_finder/backend/app/models/fleet_finance/models.py:298) |
| `finance` | `issuers` | Billing entities (KFT/EV/BT/ZRT) | [`models/marketplace/finance.py:27`](dev/service_finder/backend/app/models/marketplace/finance.py:27) |
| `marketplace` | `payment_intents` | Payment intent lifecycle (Double Lock) | [`models/marketplace/payment.py`](dev/service_finder/backend/app/models/marketplace/payment.py) |
| `core_logic` | `subscription_tiers` | Subscription tier definitions | [`models/core_logic.py`](dev/service_finder/backend/app/models/core_logic.py) |
| `core_logic` | `user_subscriptions` / `org_subscriptions` | Subscription state tracking | [`models/core_logic.py`](dev/service_finder/backend/app/models/core_logic.py) |
### Key Enums (Defined in [`audit.py:58`](dev/service_finder/backend/app/models/system/audit.py:58))
- `LedgerEntryType`: `DEBIT`, `CREDIT`
- `WalletType`: `EARNED`, `PURCHASED`, `SERVICE_COINS`, `VOUCHER`
- `LedgerStatus`: `PENDING`, `SUCCESS`, `FAILED`, `REFUNDED`, `REFUND`
---
## 2. API ENDPOINTS — Routing & User Access
### ✅ STATUS: WELL-COVERED
| Method | Endpoint | Purpose | User-Facing? | RBAC |
|--------|----------|---------|-------------|------|
| `POST` | `/billing/upgrade` | Package upgrade | ✅ Yes | Auth required |
| `POST` | `/billing/payment-intent/create` | Create PaymentIntent | ✅ Yes | Auth required |
| `POST` | `/billing/payment-intent/{id}/stripe-checkout` | Initiate Stripe checkout | ✅ Yes | Auth required |
| `POST` | `/billing/payment-intent/{id}/process-internal` | Internal gifting deduction | ✅ Yes | Auth required |
| `POST` | `/billing/stripe-webhook` | Stripe callback | N/A (webhook) | Signature |
| `GET` | `/billing/payment-intent/{id}/status` | PaymentIntent status | ✅ Yes | Auth required |
| `GET` | `/billing/wallet/balance` | User wallet balances | ✅ Yes | Auth required |
| `GET` | `/billing/wallet/transactions` | User transaction History | ✅ Yes | Auth required |
| `POST` | `/financial-manager/purchase-package` | Full purchase lifecycle | ✅ Yes | Auth required |
| `GET` | `/financial-manager/status` | Service health check | ✅ Yes | Auth required |
| `GET` | `/finance/issuers/` | List billing issuers | ❌ Admin | `finance:view` |
| `PATCH` | `/finance/issuers/{id}` | Update issuer | ❌ Admin | `finance:edit` |
| `GET` | `/admin/finance/ledger` | Admin ledger view | ❌ Admin | `finance:view` |
| `POST` | `/expenses/` | Create expense (TCO) | ✅ Yes | Auth + Capability |
| `PUT` | `/expenses/{id}` | Update expense | ✅ Yes | Auth |
| `GET` | `/expenses/` | List all expenses | ❌ Admin | Auth |
| `GET` | `/expenses/{asset_id}` | Asset-specific expenses | ✅ Yes | Auth |
| `GET` | `/subscriptions/public` | Public package catalog | ✅ Public | None |
| `GET` | `/subscriptions/my` | Current subscription | ✅ Yes | Auth |
| `GET` | `/subscriptions/feature-flags` | Resolved feature flags | ✅ Yes | Auth |
### Router Registration (in [`api/v1/api.py:1`](dev/service_finder/backend/app/api/v1/api.py:1))
- `billing.router``/billing`
- `financial_manager.router``/financial-manager`
- `finance_admin.router``/finance/issuers` + `/admin/finance`
---
## 3. SERVICES — Monolith vs. Modular Managers
### ✅ STATUS: MODULAR & SEPARATED — Minor Overlap Risk
| Service File | Responsibility | Lines |
|-------------|---------------|-------|
| [`billing_engine.py`](dev/service_finder/backend/app/services/billing_engine.py:1) | Core: PricingCalculator, SmartDeduction (FIFO), AtomicTransactionManager (double-entry) | ~1070 |
| [`financial_manager.py`](dev/service_finder/backend/app/services/financial_manager.py:1) | Orchestrator: full purchase lifecycle (validate→price→intent→pay→activate→commission) | ~520 |
| [`financial_interfaces.py`](dev/service_finder/backend/app/services/financial_interfaces.py:1) | ABCs: `BasePaymentGateway`, `BaseInvoicingService` + exceptions | ~186 |
| [`financial_orchestrator.py`](dev/service_finder/backend/app/services/financial_orchestrator.py:1) | Unit of Work: payment processing with issuer selection (vetésforgó) | ~449 |
| [`payment_router.py`](dev/service_finder/backend/app/services/payment_router.py:1) | Router: PaymentIntent creation, Stripe Double Lock, internal gifting | ~495 |
| [`cost_service.py`](dev/service_finder/backend/app/services/cost_service.py:1) | Fleet operational cost tracking with telemetry | ~140 |
| [`subscription_activator.py`](dev/service_finder/backend/app/services/subscription_activator.py:1) | Subscription activation (user/org with stacking) | ~unknown |
| [`commission_service.py`](dev/service_finder/backend/app/services/commission_service.py:1) | MLM commission distribution | ~unknown |
| [`stripe_adapter.py`](dev/service_finder/backend/app/services/stripe_adapter.py:1) | Stripe API adapter | ~unknown |
| [`mock_payment_gateway.py`](dev/service_finder/backend/app/services/mock_payment_gateway.py:1) | Test gateway (auto-approve mode) | ~unknown |
### Architecture Decision: Strategy Pattern
The [`financial_interfaces.py`](dev/service_finder/backend/app/services/financial_interfaces.py:1) defines [`BasePaymentGateway`](dev/service_finder/backend/app/services/financial_interfaces.py:13) and [`BaseInvoicingService`](dev/service_finder/backend/app/services/financial_interfaces.py:91) ABCs. `FinancialManager` accepts any gateway implementation via constructor injection.
### ⚠️ Concern: Overlap Between `financial_orchestrator` and `billing_engine`
- Both create `FinancialLedger` entries
- `financial_orchestrator.process_payment()` writes ledger entries with raw `update()` SQL instead of using `AtomicTransactionManager`
- `financial_orchestrator` accesses `Wallet` fields directly via raw SQL updates
- This is a **code duplication risk** — two different ledger-writing paths exist
### Recommendation
- Either retire `FinancialOrchestrator.process_payment()` in favor of `AtomicTransactionManager` + `PaymentRouter`, or refactor `FinancialOrchestrator` to delegate to `BillingEngine` classes.
---
## 4. WORKERS — Ledger Integration
### ✅ STATUS: FULLY CONNECTED — All workers write to audit ledger
| Worker | Ledger Entry? | Transaction Type | Amount | WalletType |
|--------|--------------|-----------------|--------|------------|
| [`inactivity_monitor_worker.py`](dev/service_finder/backend/app/workers/system/inactivity_monitor_worker.py:1) | ✅ | `ACCOUNT_SUSPENDED_INACTIVITY` | 0.0 | `EARNED` |
| [`subscription_worker.py`](dev/service_finder/backend/app/workers/system/subscription_worker.py:1) | ✅ | `SUBSCRIPTION_EXPIRED` | 0.0 | `SYSTEM` |
| [`subscription_monitor_worker.py`](dev/service_finder/backend/app/workers/system/subscription_monitor_worker.py:1) | ✅ | `SUBSCRIPTION_EXPIRED` | 0.0 | `EARNED` |
### ⚠️ Minor Inconsistency
- `subscription_worker.py` line 75 uses `WalletType.SYSTEM` — this value does NOT exist in the [`WalletType` enum](dev/service_finder/backend/app/models/system/audit.py:63) (which only has `EARNED`, `PURCHASED`, `SERVICE_COINS`, `VOUCHER`). This would raise an `AttributeError` at runtime.
- `subscription_monitor_worker.py` correctly uses `WalletType.EARNED`.
---
## 5. FRONTEND — Wallet & Transaction Views
### ❌ STATUS: COMPLETELY MISSING
A search across the entire `frontend/src/` directory for Vue files containing `wallet`, `transaction`, `balance`, `purchase`, `subscription`, or `billing` returned **zero results**.
The backend already provides:
- `GET /billing/wallet/balance` → Returns quadruple wallet balances
- `GET /billing/wallet/transactions` → Returns paginated transaction history with filtering
- `GET /subscriptions/my` → Returns current subscription details
- `GET /subscriptions/feature-flags` → Returns feature flags
But **no frontend component calls any of these endpoints**.
### What Needs Building
1. **WalletBalanceCard.vue** — Display earned/purchased/service_coins/voucher balances
2. **TransactionHistory.vue** — Paginated transaction list with type/wallet filters
3. **SubscriptionStatusBanner.vue** — Current tier, expiry, upgrade CTA
4. **CheckoutView.vue** — Package selection → PaymentIntent → Stripe/internal redirect
5. **PaymentStatusView.vue** — Success/cancel pages after Stripe redirect
6. **Admin Ledger View** (already spec'd in [`docs/sf/epic_10_admin_frontend_spec.md`](dev/service_finder/docs/sf/epic_10_admin_frontend_spec.md)) — Paginated financial ledger with user join data
---
## 6. GAP ANALYSIS SUMMARY
| Area | Status | Criticality | Action |
|------|--------|-------------|--------|
| **Backend Models** | ✅ Complete | — | No action needed |
| **Backend API — User-facing** | ✅ Complete | — | No action needed |
| **Backend API — Admin** | ✅ Complete | — | No action needed |
| **Backend Services** | ✅ Mostly modular | ⚠️ Medium | Refactor `FinancialOrchestrator` to delegate ledger writes to `AtomicTransactionManager` |
| **Workers → Ledger** | ✅ Connected | 🔴 Bug | Fix `subscription_worker.py:75``WalletType.SYSTEM` does not exist |
| **Frontend — Wallet** | ❌ Missing | 🔴 Critical | Build `WalletBalanceCard.vue` + `TransactionHistory.vue` |
| **Frontend — Checkout** | ❌ Missing | 🔴 Critical | Build subscription purchase flow (catalog → intent → payment → confirmation) |
| **Frontend — Admin Ledger** | ❌ Missing | 🟡 Medium | Build admin ledger view (already spec'd) |
---
## 7. RECOMMENDED ACTIONS (Priority Order)
### 🔴 P0 — Critical Bugs
1. **Fix `subscription_worker.py:75`**: Change `WalletType.SYSTEM` to `WalletType.EARNED` (or add `SYSTEM` to the enum)
2. **Fix `FinancialOrchestrator.refund_payment()` line 399/403**: Code references `Wallet.wallet_type` field which doesn't exist on the Wallet model; also references `wallet.balance` which doesn't exist. This code would **crash at runtime**.
### 🔴 P0 — Missing Frontend
3. Build `WalletBalanceCard.vue` — user wallet summary display
4. Build `TransactionHistory.vue` — paginated transaction list
5. Build subscription checkout flow (catalog → Intent → payment → confirmation)
### 🟡 P1 — Code Quality
6. Refactor `FinancialOrchestrator.process_payment()` to delegate to `AtomicTransactionManager` for ledger creation (eliminate dual ledger-writing paths)
7. Audit `FinancialOrchestrator.refund_payment()` — it references non-existent fields (`Wallet.wallet_type`, `Wallet.balance`)
### 🟢 P2 — Documentation
8. Update [`epic_3_financial_motor_architecture.md`](dev/service_finder/docs/sf/epic_3_financial_motor_architecture.md:1) with current service file structure
9. Create `logic_spec_financial_manager.md` documenting the full purchase lifecycle flow with Mermaid sequence diagram
---
*Audit completed by Rendszer-Architect. Ready for Gitea ticket creation.*

View File

@@ -0,0 +1,203 @@
# P0 Root Cause Analysis: POST /api/v1/expenses/ 500 Internal Server Error
**Date:** 2026-07-26
**Gitea Issue:** [#421](https://gitea.kincses.dev/kincses/service-finder/issues/421)
**Severity:** P0 (Critical — breaks entire expense creation flow)
**Status:** Architect Analysis Complete — Awaiting Code Fix
---
## 📋 Executive Summary
The `POST /api/v1/expenses/` endpoint crashes with a `500 Internal Server Error` because the helper function `_check_org_capability()` contains a **broken import path** and a **wrong join column name**. Both defects are located in `backend/app/api/v1/endpoints/expenses.py`.
---
## 🔍 Root Cause Trace
### Traceback (from `docker compose logs sf_api`)
```
File "/app/app/api/v1/endpoints/expenses.py", line 377, in create_expense
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
File "/app/app/api/v1/endpoints/expenses.py", line 104, in _check_org_capability
from app.models.fleet.org_role import OrgRole
ModuleNotFoundError: No module named 'app.models.fleet.org_role'
```
### Call Flow
```mermaid
flowchart TD
A["POST /api/v1/expenses/"] --> B["expenses.py: create_expense() line 377"]
B --> C["_check_org_capability() line 93"]
C --> D["line 104: from app.models.fleet.org_role import OrgRole"]
D --> E["ModuleNotFoundError 💥"]
E --> F["500 Internal Server Error"]
```
---
## 🐛 Bug #1: Wrong Import Path (line 104)
### Broken Code
```python
# File: backend/app/api/v1/endpoints/expenses.py, line 104
from app.models.fleet.org_role import OrgRole
```
### Why It Fails
There is **no directory** `backend/app/models/fleet/` and no file `org_role.py` at that path.
### Correct Import
The `OrgRole` model lives in `backend/app/models/marketplace/organization.py`:
```python
from app.models.marketplace.organization import OrgRole
```
### Evidence: Other Files Import Correctly
| File | Import | Status |
|------|--------|--------|
| `backend/app/api/deps.py:13` | `from app.models.marketplace.organization import OrgRole, OrganizationMember` | ✅ Correct |
| `backend/app/api/v1/endpoints/assets.py:14` | `from app.models import ... OrgRole` (via `__init__.py`) | ✅ Correct |
| `backend/app/api/v1/endpoints/organizations.py:23` | `from app.models.marketplace.organization import ... OrgRole` | ✅ Correct |
| `backend/app/api/v1/endpoints/expenses.py:104` | `from app.models.fleet.org_role import OrgRole` | ❌ **BROKEN** |
---
## 🐛 Bug #2: Wrong Join Column (line 108)
### Broken Code
```python
# File: backend/app/api/v1/endpoints/expenses.py, lines 106-114
stmt = (
select(OrgRole.permissions)
.join(OrganizationMember, OrganizationMember.role == OrgRole.name) # ← BUG
.where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
)
)
```
### Why It Fails
The `OrgRole` model's column is `name_key` (line 55 of the model), **not** `name`:
```python
class OrgRole(Base):
__tablename__ = "org_roles"
__table_args__ = {"schema": "fleet"}
name_key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
# ... no 'name' column exists
```
Attempting `OrgRole.name` would raise an `AttributeError` after the import is fixed, causing a second crash.
### Correct Join
```python
.join(OrganizationMember, OrganizationMember.role == OrgRole.name_key)
```
### Evidence: `assets.py` Has the Correct Pattern
The `assets.py` endpoint uses the identical capability-check pattern correctly:
```python
# assets.py line 80-83 — CORRECT pattern
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name, # ← Uses name_key, not .name
OrgRole.is_active == True
).limit(1)
```
---
## 🧩 Impact Analysis
### What's Broken
- **Entire `POST /api/v1/expenses/` endpoint** — all expense creation requests fail with 500
- The crash occurs at the **RBAC Phase 2 capability check** (JSONB-based `can_approve_expense`)
- This blocks the P0 Smart Expense Workflow (odometer normalization, VAT calculation, gamification hooks, AssetEvent linking)
### What Still Works
- `GET /api/v1/expenses/` — list all expenses (does not call `_check_org_capability`)
- `GET /api/v1/expenses/{asset_id}` — list asset expenses (does not call `_check_org_capability`)
- `PUT /api/v1/expenses/{expense_id}` — update expense (does not call `_check_org_capability`)
### Why This Was Not Caught
1. The function uses a lazy import inside the function body — the import error only surfaces at runtime, not at module load time.
2. No e2e test exercises the `POST /expenses/` path through this function.
3. The `backend/app/tests/e2e/test_expense_flow.py` test targets `POST /api/v1/expenses/add`, which is a **different, legacy endpoint** — not the current `POST /api/v1/expenses/`.
---
## 🔧 Fix Plan (2 Lines, 1 File)
### File: `backend/app/api/v1/endpoints/expenses.py`
**Change 1 — Line 104:** Replace the broken import:
```diff
- from app.models.fleet.org_role import OrgRole
+ from app.models.marketplace.organization import OrgRole
```
**Change 2 — Line 108:** Fix the join column:
```diff
- .join(OrganizationMember, OrganizationMember.role == OrgRole.name)
+ .join(OrganizationMember, OrganizationMember.role == OrgRole.name_key)
```
### Optional: Hoist to top-level imports
Since `OrganizationMember` is already imported at the top of the file (line 46), move `OrgRole` there too:
```python
# line 46 area — add OrgRole
from app.models.marketplace.organization import OrganizationMember, OrgRole
```
Then remove the lazy import body from `_check_org_capability()`.
---
## 🧪 Verification
1. **Unit test:** `POST /api/v1/expenses/` with valid payload → 201 Created
2. **Capability check:** User with `can_approve_expense: true` → expense `APPROVED`
3. **Capability check:** User without `can_approve_expense``PENDING_APPROVAL`
4. **No ModuleNotFoundError** at any point
---
## 📊 Summary
| Item | Detail |
|------|--------|
| **Endpoint** | `POST /api/v1/expenses/` |
| **Error** | `ModuleNotFoundError: No module named 'app.models.fleet.org_role'` |
| **Crash File** | `backend/app/api/v1/endpoints/expenses.py` |
| **Crash Line** | 104 |
| **Root Cause 1** | Wrong import: `app.models.fleet.org_role``app.models.marketplace.organization` |
| **Root Cause 2** | Wrong column: `OrgRole.name``OrgRole.name_key` |
| **Gitea Issue** | #421 |
| **Fix Complexity** | Trivial (2 lines) |
| **Risk** | Minimal |
---
*Analysis by: Service Finder Rendszer-Architect — 2026-07-26*

View File

@@ -0,0 +1,248 @@
# P0 Root Cause Analysis: Expense Edit Modal Data Binding Failure
**Date:** 2026-07-26
**Scope:** Frontend `CostEntryWizard.vue` + Backend `expenses.py`
**Symptom:** Edit modal defaults category to "Finanszírozás", fails to bind vehicle/provider, missing specific details (fuel amount).
---
## 1. Data Storage: How Expenses & Specific Details Are Persisted
### DB Table: `fleet_finance.asset_costs`
| Column | Type | Stores |
|--------|------|--------|
| `category_id` | `int4` | FK → `fleet_finance.cost_categories.id`**leaf/subcategory ID**, not parent |
| `service_provider_id` | `int4` | FK → `marketplace.service_providers.id` — the selected provider |
| `vendor_organization_id` | `int4` | FK → `fleet.organizations.id` — B2B vendor org |
| `external_vendor_name` | `varchar(200)` | Free-text vendor name |
| `data` | `JSONB` | **All specific details:** `liters`, `net_amount`, `vat_rate`, `invoice_number`, `invoice_date`, `payment_method`, `payment_deadline`, `mileage_at_cost`, `vendor_id`, `vendor_name`, `vendor_source` |
| `amount_gross` | `numeric(18,2)` | Gross (Bruttó) amount |
| `amount_net` | `numeric(18,2)` | Net amount |
| `invoice_number` | `varchar(100)` | Dedicated column (duplicated in `data`) |
**Key Finding:** Specific details like fuel liters, payment method, invoice date, etc. are stored in the `data` JSONB column. There is **no separate related table** for fuel-specific details.
---
## 2. Backend API Analysis
### 2A. No Single-Expense Read Endpoint Exists
The backend has:
- `GET /expenses/` — lists all expenses (paginated)
- `GET /expenses/{asset_id}` — lists expenses for a specific asset
- `PUT /expenses/{expense_id}` — updates an expense
**There is NO `GET /expenses/by-id/{expense_id}` endpoint** that returns a single enriched expense with all resolved relationships.
### 2B. List Endpoints Return Raw ORM Objects
Both list endpoints return `result.scalars().all()` — raw SQLAlchemy `AssetCost` objects serialized by FastAPI's default JSON encoder. The `AssetCostResponse` Pydantic schema (which includes `category_name`, `category_code`, `vendor_name`, `description`, `mileage_at_cost`) is **defined but never used** by the list endpoints.
This means:
- `data` (JSONB) is serialized as a raw dict — this part actually works
- But `category_name`, `category_code`, `vendor_name` are **not resolved** (they require JOINs or relationship loading)
- The response lacks the parent category ID (only the leaf `category_id` is present)
### 2C. PUT Endpoint Has Partial Data Loss Risk
The `update_expense` function handles `mileage_at_cost` and `description` inside `data` JSONB correctly, but the frontend's `updateExpense()` in `cost.ts` sends `service_provider_id` only if it's in the payload. The `PUT` endpoint's `AssetCostUpdate` schema does include `service_provider_id: Optional[int]`, so if the frontend sends it, it would work.
---
## 3. Frontend Data Flow: The Edit Path
### Step 1: User clicks Edit → `CostsView.vue` line 527
```typescript
function openEditWizard(cost: any) {
const vehicleId = cost.asset_id || cost.vehicle_id
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
editCostData.value = cost // ← passes raw list-response object directly
showEditWizard.value = true
}
```
The `cost` object comes from the `GET /expenses` list response — a raw SQLAlchemy object with fields: `id`, `asset_id`, `organization_id`, `category_id`, `amount_net`, `amount_gross`, `vat_rate`, `currency`, `date`, `invoice_number`, `status`, `data`, `service_provider_id`, `vendor_organization_id`, `external_vendor_name`, `invoice_date`, `fulfillment_date`.
### Step 2: Hydration watcher → `CostEntryWizard.vue` lines 972-988
```typescript
watch(() => props.editCost, (cost) => {
if (!cost) return
form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
form.mainCategory = String(cost.category_id || '') // ← BUG #1
form.subCategory = String(cost.sub_category_id || '') // ← BUG #1 (never exists)
form.netAmount = cost.data?.net_amount || cost.amount_net || 0
form.vatRate = cost.data?.vat_rate || 27
form.grossAmount = cost.amount_gross || 0
form.currency = cost.currency || 'HUF'
form.invoiceNumber = cost.data?.invoice_number || ''
form.invoiceDate = cost.data?.invoice_date || ''
form.paymentDeadline = cost.data?.payment_deadline || ''
form.paymentMethod = cost.data?.payment_method || 'TRANSFER'
form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null
form.liters = cost.data?.liters || null // ← BUG #2 area
// NOTE: selectedProvider is NEVER set from cost.service_provider_id! ← BUG #3
// NOTE: selectedVehicleId is NEVER set from cost.asset_id! ← BUG #4
})
```
---
## 4. Root Cause Map: 5 Specific Bugs
### 🔴 BUG #1 — Category Hierarchy Collapse (CRITICAL)
**Location:** `CostEntryWizard.vue:976`
**What happens:**
- `category_id` in the DB is the **leaf/subcategory** ID (e.g., `id=4` for "Üzemanyag / Benzin")
- The wizard has a **two-level** selector: `mainCategory` (parent) → `subCategory` (leaf)
- The hydration sets `form.mainCategory = String(cost.category_id || '')` — puts the leaf ID into the parent dropdown
- `form.subCategory = String(cost.sub_category_id || '')``sub_category_id` doesn't exist in the response, so it's always `''`
- When the component renders, `mainCategory` is set to an ID that doesn't match any main category (since it's a leaf), so the `<select>` defaults to the **first option**: "Finanszírozás"
- Subcategories are never populated because `onMainCategoryChange()` was never triggered with the correct parent ID
**User experience:** Category dropdown shows "Finanszírozás" (first item) instead of the actual category.
**Fix approach:**
1. Backend must return `parent_category_id` alongside `category_id` in the response, OR
2. Frontend must look up the parent from the flattened category tree (`window.__allCostCategories`) and split `category_id` into main+sub correctly
### 🔴 BUG #2 — Vehicle Not Bound When Not in Store (HIGH)
**Location:** `CostsView.vue:530` + `CostEntryWizard.vue:93`
**What happens:**
- `openEditWizard` finds the vehicle by `cost.asset_id` in `vehicleStore.vehicles`
- If the vehicle is not in the store (e.g., filtered by another org, or not loaded), `editVehicle` is `null`
- In `CostEntryWizard.vue:77`, when `editVehicle` is null AND `userVehicles.length > 0`, the global vehicle dropdown is shown
- But `selectedVehicleId` is **never set** from `cost.asset_id` in the hydration watcher
- Result: vehicle dropdown shows "Select Vehicle..." with nothing selected
**Fix approach:** In the hydration watcher, set `selectedVehicleId.value = cost.asset_id` when `props.vehicle` is null.
### 🔴 BUG #3 — Provider/ServiceProvider Not Hydrated (HIGH)
**Location:** `CostEntryWizard.vue:972-988` (absence of provider hydration)
**What happens:**
- The DB stores `service_provider_id` (int) and `external_vendor_name` (string) directly on the AssetCost row
- The list response includes these fields
- But the hydration watcher **never reads** `cost.service_provider_id` or `cost.external_vendor_name`
- `selectedProvider` ref is never populated, so `ProviderAutocomplete` shows empty
- The user sees no provider selected even though one was saved
**Fix approach:** In the hydration watcher, if `cost.service_provider_id` exists, construct a `ProviderSearchResult` object and set `selectedProvider.value`. If only `external_vendor_name` exists, set it as the provider name.
### 🟡 BUG #4 — `data.liters` May Be Lost in List Serialization (MEDIUM)
**Location:** `CostEntryWizard.vue:987`
**What happens:**
- The hydration reads `cost.data?.liters`
- The list endpoint returns raw SQLAlchemy objects; the `data` JSONB column **should** serialize correctly as a dict via FastAPI's `jsonable_encoder`
- However, if the `data` column is `None` or `{}` in the DB, `cost.data?.liters` returns `undefined`, and `|| null` gives `null`
- This should work for most cases, but there's a subtle issue: the `data` field on the SQLAlchemy model has `server_default="'{}'::jsonb"`, but if a row was inserted without going through SQLAlchemy (e.g., raw SQL), `data` could be `None`
**Fix approach:** Add defensive null-check: `(cost.data && cost.data.liters) ? cost.data.liters : null`
### 🟡 BUG #5 — `mainCategory` Watcher Clears `subCategory` on Hydration (MEDIUM)
**Location:** `CostEntryWizard.vue:818-819`
**What happens:**
- The hydration watcher runs and sets `form.mainCategory = String(cost.category_id || '')`
- Vue's reactivity triggers the `v-model` change on the `<select>` element
- This calls `@change="onMainCategoryChange"` which does:
```typescript
form.subCategory = '' // ← CLEARS whatever the hydration may try to set
subCategories.value = []
```
- Even if we fix BUG #1 to correctly populate subCategory, the `@change` handler would clear it
**Fix approach:** In the hydration watcher, after setting `mainCategory`, manually call `onMainCategoryChange()` (which populates `subCategories`), and THEN set `form.subCategory` to the correct leaf ID. The order must be: set mainCategory → populate subCategories → set subCategory.
---
## 5. Data Flow Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ BACKEND │
│ │
│ GET /expenses?organization_id=X │
│ └→ returns: [AssetCost ORM objects] │
│ fields: id, asset_id, category_id(leaf!), service_provider_id, │
│ amount_gross, date, data{liters, net_amount, ...}, │
│ external_vendor_name, invoice_date, ... │
│ │
│ MISSING: parent_category_id, category_name, vendor_name, │
│ vehicle_name, license_plate │
│ │
│ There is NO GET /expenses/by-id/{id} endpoint! │
└──────────────────────┬──────────────────────────────────────────────┘
│ raw ORM objects (FastAPI jsonable_encoder)
┌─────────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ │
│ CostsView.vue │
│ └→ openEditWizard(cost) │
│ editCostData.value = cost ← raw list item │
│ editVehicle.value = store.find(v => v.id === cost.asset_id) │
│ │
│ CostEntryWizard.vue │
│ └→ watch(editCost): │
│ form.mainCategory = String(cost.category_id) ← BUG: leaf ID │
│ form.subCategory = String(cost.sub_category_id) ← BUG: undef │
│ form.liters = cost.data?.liters │
│ selectedProvider = ??? ← BUG: never │
│ selectedVehicleId = ??? ← BUG: never │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 6. Recommended Fix Plan
### Phase 1: Backend (Minimal — No Schema Changes)
1. **Create `GET /expenses/by-id/{expense_id}` endpoint** in `expenses.py` that:
- Fetches a single `AssetCost` by ID
- JOINs `CostCategory` to resolve `category_name`, `category_code`, `parent_id`
- JOINs `ServiceProvider` to resolve provider name
- Returns the data using `AssetCostResponse` schema (already defined!)
- Includes `parent_category_id` in the response for the two-level dropdown
2. **Optionally, enrich the list endpoint** to return `parent_category_id` by joining `CostCategory.parent_id`, but this is less critical if the single-expense endpoint is used for edit hydration.
### Phase 2: Frontend (Edit Path)
1. **In `CostsView.vue` `openEditWizard()`:** fetch the full expense detail from the new `GET /expenses/by-id/{id}` endpoint instead of passing the raw list object.
2. **In `CostEntryWizard.vue` hydration watcher:**
- Split `category_id` into `mainCategory` (parent) and `subCategory` (leaf) using `window.__allCostCategories`
- Call `onMainCategoryChange()` after setting `mainCategory` to populate subcategories, then set `subCategory`
- Set `selectedProvider.value` from `cost.service_provider_id` + resolved provider name
- Set `selectedVehicleId.value` from `cost.asset_id` when vehicle prop is null
- Defensive null-check on `cost.data?.liters`
### Phase 3: Edge Cases
1. When `service_provider_id` is null but `external_vendor_name` is set, populate the provider autocomplete with the free-text name
2. Handle the case where `vendor_organization_id` points to a B2B org (different from `service_provider_id`)
---
## 7. Files That Need Changes
| File | Change Type | Priority |
|------|-------------|----------|
| `backend/app/api/v1/endpoints/expenses.py` | Add `GET /expenses/by-id/{id}` endpoint | P0 |
| `backend/app/schemas/asset_cost.py` | Add `parent_category_id` to `AssetCostResponse` | P0 |
| `frontend_app/src/views/costs/CostsView.vue` | Fetch full expense detail on edit | P0 |
| `frontend_app/src/components/cost/CostEntryWizard.vue` | Fix category split, provider hydration, vehicle selection | P0 |
| `frontend_app/src/stores/cost.ts` | Add `fetchExpenseById()` action (optional, if store-based) | P1 |

View File

@@ -0,0 +1,149 @@
# P0 Bug #420: `GET /api/v1/billing/wallet/balance` - 500 Internal Server Error
**Dátum:** 2026-07-26
**Súlyosság:** P0 - Blokkoló (minden wallet/balance hívást érint)
**Státusz:** Megoldva (pycache purge + restart)
---
## 1. Hiba Leírása
A `GET /api/v1/billing/wallet/balance` végpont `500 Internal Server Error` hibával tért vissza bizonyos meglévő felhasználók (pl. user_id=86, 28) esetén.
### Hibaüzenet a logból:
```
2026-07-26 12:37:49,388 [ERROR] app.api.v1.endpoints.billing: Pénztárca egyenleg lekérdezési hiba: 'FinancialLedger' object has no attribute 'description'
```
### Érintett fájlok:
- [`backend/app/models/system/audit.py:78`](backend/app/models/system/audit.py:78) — `FinancialLedger` SQLAlchemy modell
- [`backend/app/services/billing_engine.py:1078`](backend/app/services/billing_engine.py:1078) — `get_user_balance()`
- [`backend/app/services/billing_engine.py:598`](backend/app/services/billing_engine.py:598) — `get_wallet_summary()`
- [`backend/app/api/v1/endpoints/billing.py:298`](backend/app/api/v1/endpoints/billing.py:298) — `get_wallet_balance()` endpoint
---
## 2. Root Cause Analysis
### 2.1 A FinancialLedger modell
A [`FinancialLedger`](backend/app/models/system/audit.py:78) modell **NEM rendelkezik `description` oszloppal**. Az egyetlen "leíró" mező a `details` (JSONB). A modell oszlopai:
```
id, user_id, person_id, amount, currency, transaction_type,
related_agent_id, details, created_at, entry_type, balance_after,
wallet_type, issuer_id, invoice_status, tax_amount, gross_amount,
net_amount, transaction_id, status
```
### 2.2 A jelenlegi forráskód helyes
A [`billing_engine.py`](backend/app/services/billing_engine.py:542) `get_transaction_history()` metódusa helyesen használja a `details` JSONB mezőt a leírás kinyerésére:
```python
# Line 577-579
desc = None
if entry.details and isinstance(entry.details, dict):
desc = entry.details.get("description") or entry.details.get("desc") or None
```
A [`billing.py`](backend/app/api/v1/endpoints/billing.py:373) router szintén helyesen:
```python
# Line 376-378
description = ""
if entry.details and isinstance(entry.details, dict):
description = entry.details.get("description", "")
```
### 2.3 A tényleges ok: Stale Python Bytecode Cache
Az MD5 ellenőrzés igazolta, hogy a konténerben futó forráskód és a host-on lévő forráskód **azonos**:
| Fájl | MD5 |
|------|-----|
| `billing.py` (host & container) | `6b00968c129376e3db630e266835261b` |
| `billing_engine.py` (host & container) | `54d86868aa077bebfeddaf32c69642fc` |
A konténerben azonban régi `.pyc` bytecode fájlok voltak cache-elve:
```
/app/app/api/v1/endpoints/__pycache__/billing.cpython-312.pyc
/app/app/services/__pycache__/billing_engine.cpython-312.pyc
```
Ezek a `.pyc` fájlok egy **korábbi forráskód-verzióhoz** tartoztak, ahol még létezett `entry.description` hivatkozás a `FinancialLedger` objektumokon (vagy egy másik séma érvényben volt).
### 2.4 Miért nem frissültek a .pyc fájlok?
A `docker-compose.yml`-ben a `sf_api` konténer forráskódja volume mount-tal van csatolva (nem COPY). Amikor a forráskód változik a host-on, a Python értelmező a konténerben azonnal látja az új `.py` fájlokat, de a már legenerált `.pyc` cache fájlok megmaradnak. Ha a `.py` fájl timestamp-je régebbi, mint a `.pyc`-é (vagy a Python nem érzékeli a változást), a régi bytecode fut.
---
## 3. Megoldás
### Végrehajtott lépések:
1. **Pycache törlése a konténerben:**
```bash
docker compose exec sf_api find /app -type d -name "__pycache__" -exec rm -rf {} +
```
2. **Konténer újraindítása:**
```bash
docker compose restart sf_api
```
3. **Verifikáció:** A restart után az endpoint már NEM dob 500-as hibát. A logokban nem jelenik meg több `description` hiba.
---
## 4. Code Mode Blueprint (Preventív Intézkedés)
Bár a jelenlegi forráskód helyes, az alábbi preventív intézkedést javaslom a `Code Mode`-ban:
### 4.1 Docker Compose: pycache purge a startup előtt
A `docker-compose.yml` `sf_api` szolgáltatás `command` sorában futtassunk egy automatikus pycache purge-öt a Uvicorn indítása előtt:
```yaml
command: >
/bin/sh -c "
find /app -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null;
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
"
```
### 4.2 Opcionális: `.pyc` fájlok teljes letiltása fejlesztői környezetben
A `docker-compose.yml` environment változói közé:
```yaml
environment:
- PYTHONDONTWRITEBYTECODE=1
```
Ez megakadályozza, hogy a Python egyáltalán `.pyc` fájlokat generáljon, így mindig a forráskódból fog futni.
---
## 5. Függelék: Mermaid Folyamatábra
```mermaid
sequenceDiagram
participant Client
participant FastAPI as billing.py router
participant BillingEngine as billing_engine.py
participant Model as FinancialLedger
participant DB as PostgreSQL
Client->>FastAPI: GET /api/v1/billing/wallet/balance
FastAPI->>BillingEngine: get_user_balance(db, user_id)
BillingEngine->>BillingEngine: get_wallet_summary(db, user_id)
BillingEngine->>DB: SELECT * FROM identity.wallets WHERE user_id=?
BillingEngine->>DB: SELECT * FROM audit.financial_ledger LIMIT 10
BillingEngine->>Model: entry.details.get("description")
Note over Model: Helyes: details JSONB mezőből olvas
Model-->>BillingEngine: description string
BillingEngine-->>FastAPI: {"earned": X, "purchased": Y, ...}
FastAPI-->>Client: 200 OK + JSON balances
```
**A hiba idején** a `.pyc` bytecode `entry.description`-t próbált elérni, ami nem létező attribútum a `FinancialLedger` modellen. Ez `AttributeError`-t okozott, amit a `billing.py` router `except Exception` ága elkapott és 500-ra fordított.

View File

@@ -2205,7 +2205,7 @@ const _FMnHxsRb1XnhSkgrcxwDEP1elfSPjrP7Wr27yXkb3Aw = (function(nitro) {
* Released under the MIT License.
*/
const _create = Object.create;
const create$2 = (obj = null) => _create(obj);
const create = (obj = null) => _create(obj);
/* eslint-enable */
/**
* Useful Utilities By Evan you
@@ -2240,7 +2240,7 @@ function deepCopy(src, des) {
// if src[key] is an object/array, set des[key]
// to empty object/array to prevent setting by reference
if (isObject(src[key]) && !isObject(des[key])) {
des[key] = Array.isArray(src[key]) ? [] : create$2();
des[key] = Array.isArray(src[key]) ? [] : create();
}
if (isNotObjectOrIsArray(des[key]) || isNotObjectOrIsArray(src[key])) {
// replace with src[key] when:
@@ -2259,18 +2259,18 @@ function deepCopy(src, des) {
const __nuxtMock = { runWithContext: async (fn) => await fn() };
const merger = createDefu((obj, key, value) => {
if (key === "messages" || key === "datetimeFormats" || key === "numberFormats") {
obj[key] ??= create$2(null);
obj[key] ??= create(null);
deepCopy(value, obj[key]);
return true;
}
});
async function loadVueI18nOptions(vueI18nConfigs) {
const nuxtApp = __nuxtMock;
let vueI18nOptions = { messages: create$2(null) };
let vueI18nOptions = { messages: create(null) };
for (const configFile of vueI18nConfigs) {
const resolver = await configFile().then((x) => isModule(x) ? x.default : x);
const resolved = isFunction(resolver) ? await nuxtApp.runWithContext(() => resolver()) : resolver;
vueI18nOptions = merger(create$2(null), resolved, vueI18nOptions);
vueI18nOptions = merger(create(null), resolved, vueI18nOptions);
}
vueI18nOptions.fallbackLocale ??= false;
return vueI18nOptions;
@@ -2416,6 +2416,7 @@ var menu$1 = {
finance_management: "Pénzügyek Kezelése",
packages: "Csomagok",
commission_rules: "Jutalék Szabályok",
financial_ledger: "Pénzügyi Napló",
gamification: "Gamification",
game_settings: "Játékmenet Beállítások",
point_rules: "Pontszabályok",
@@ -2429,6 +2430,7 @@ var menu$1 = {
statistics: "Statisztikák",
leaderboard: "Ranglista",
points_ledger: "Pontnapló",
ledger: "Pontnapló",
gamification_hq: "Gamification HQ",
system_config: "Rendszer Konfig",
system_parameters: "Rendszerparaméterek",
@@ -2441,16 +2443,31 @@ const locale_menu_46json_d8dcbc3e = {
};
var dashboard$1 = {
title: "Gamification HQ",
subtitle: "Gamification rendszer áttekintő irányítópultja",
title: "Admin Dashboard",
subtitle: "Rendszer áttekintő irányítópult",
loading: "Dashboard betöltése...",
error: "Nem sikerült betölteni a dashboard adatokat.",
active_users: "Aktív Felhasználók",
new_this_month: "új e hónapban",
total_vehicles: "Összes Jármű",
organizations: "Szervezetek",
memberships: "tagság",
new_users: "Új Felhasználók",
new_today: "ma",
users_by_role: "Felhasználók Szerepkör Szerint",
users_by_plan: "Felhasználók Csomag Szerint",
users_by_language: "Felhasználók Nyelv Szerint",
registration_trend: "Regisztrációs Trend",
quick_actions: "Gyors Műveletek",
invite_admin: "Admin Meghívása",
add_vehicle: "Jármű Hozzáadása",
view_reports: "Jelentések Megtekintése",
recent_activity: "Legutóbbi Tevékenység",
total_users: "Összes Felhasználó",
active_season: "Aktív Szezon",
pending_contributions: "Függő Hozzájárulások",
xp_earned_today: "Ma Szerzett XP",
none: "Nincs",
quick_actions: "Gyors Műveletek",
manage_point_rules: "Pontszabályok kezelése",
manage_badges: "Kitüntetések kezelése",
manage_seasons: "Szezonok kezelése",
@@ -3495,6 +3512,11 @@ const locale_gamification_46json_15470408 = {
};
var system$1 = {
logs: {
subtitle: "Rendszernaplók — auditált események áttekintése",
placeholder_title: "Rendszernaplók megtekintése",
placeholder_desc: "Itt jelennek meg a rendszer által auditált események, naplóbejegyzések és rendszerszintű műveletek. A modul még fejlesztés alatt áll."
},
config: {
title: "Rendszer Konfig",
subtitle: "Gamification master konfiguráció szerkesztése",
@@ -3519,7 +3541,18 @@ var system$1 = {
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: "
invalid_json: "Érvénytelen JSON: ",
inactivity_monitor: "Inaktivitás Figyelő",
inactivity_desc: "Az inaktivitási küszöbérték konfigurálása automatikus fiók felfüggesztéshez. Az ennyi napja inaktív felhasználók jelölésre kerülnek az MLM Jutalék Motor által.",
inactivity_threshold: "Inaktivitási Küszöb (nap)",
inactivity_range: "Tartomány: 30730 nap (alapértelmezett: 180)",
inactivity_updated: "Inaktivitási küszöb elmentve!",
inactivity_save_error: "Nem sikerült elmenteni az inaktivitási küszöböt.",
effective_threshold: "Hatályos Küszöb",
last_changed: "Utolsó módosítás",
never: "soha",
save_inactivity: "Mentés",
has_changes: "Változások észlelve"
},
params: {
title: "Rendszerparaméterek",
@@ -3778,6 +3811,82 @@ const locale_commission_46json_2229a18b = {
commission_rules: commission_rules$1
};
var financial_ledger$1 = {
title: "Pénzügyi Napló",
subtitle: "A teljes pénzügyi főkönyv böngészése — tranzakciók, felhasználók és részletek",
loading: "Napló betöltése...",
error: "Nem sikerült betölteni a pénzügyi naplót.",
retry: "Újrapróbálkozás",
no_results: "Nincsenek pénzügyi napló bejegyzések.",
no_results_hint: "Próbálj más szűrési feltételeket használni.",
id: "ID",
user: "Felhasználó",
user_id: "User ID",
amount: "Összeg",
currency: "Pénznem",
transaction_type: "Tranzakció Típus",
entry_type: "Típus",
wallet_type: "Tárca",
status: "Státusz",
created_at: "Dátum",
details: "Részletek",
filter: "Szűrés",
clear: "Törlés",
prev: "Előző",
next: "Következő",
page_info: "{page} / {total} oldal ({count} elem)",
export_csv: "CSV Export",
all_types: "Minden típus",
all_wallets: "Minden tárca",
all_statuses: "Minden státusz",
date_from: "Dátumtól",
date_to: "Dátumig",
search: "Keresés",
csv_downloaded: "CSV letöltés elindítva."
};
var enums$1 = {
entry_type: {
credit: "Jóváírás",
debit: "Terhelés",
CREDIT: "Jóváírás",
DEBIT: "Terhelés"
},
wallet_type: {
earned: "Megszerzett",
purchased: "Vásárolt",
service_coins: "Szolgáltatási Coin",
voucher: "Utalvány",
EARNED: "Megszerzett",
PURCHASED: "Vásárolt",
SERVICE_COINS: "Szolgáltatási Coin",
VOUCHER: "Utalvány"
},
status: {
pending: "Függőben",
completed: "Teljesítve",
success: "Sikeres",
failed: "Sikertelen",
cancelled: "Visszavonva",
refunded: "Visszatérítve",
refund: "Visszatérítés"
},
transaction_type: {
subscription: "Előfizetés",
commission: "Jutalék",
refund: "Visszatérítés",
manual_adjustment: "Kézi Korrekció",
purchase: "Vásárlás",
withdrawal: "Kivét",
bonus: "Bónusz",
fee: "Díj",
reward: "Jutalom"
}
};
const locale_finance_46json_ae59f62d = {
financial_ledger: financial_ledger$1,
enums: enums$1
};
var common$5 = {
loading: "Loading...",
saving: "Saving...",
@@ -3897,6 +4006,7 @@ var menu = {
finance_management: "Finance Management",
packages: "Packages",
commission_rules: "Commission Rules",
financial_ledger: "Financial Ledger",
gamification: "Gamification",
game_settings: "Game Settings",
point_rules: "Point Rules",
@@ -3910,6 +4020,7 @@ var menu = {
statistics: "Statistics",
leaderboard: "Leaderboard",
points_ledger: "Points Ledger",
ledger: "Points Ledger",
gamification_hq: "Gamification HQ",
system_config: "System Config",
system_parameters: "System Parameters",
@@ -4976,6 +5087,11 @@ const locale_gamification_46json_111ebf57 = {
};
var system = {
logs: {
subtitle: "System Logs — audited event overview",
placeholder_title: "System Logs Viewer",
placeholder_desc: "This section will display audited system events, log entries, and system-wide operations. Module is under development."
},
config: {
title: "System Config",
subtitle: "Gamification master configuration editor",
@@ -5000,7 +5116,18 @@ var system = {
updated: "Configuration updated!",
save_error: "Failed to save configuration.",
json_applied: "JSON applied to form fields!",
invalid_json: "Invalid JSON: "
invalid_json: "Invalid JSON: ",
inactivity_monitor: "Inactivity Monitor",
inactivity_desc: "Configure the inactivity threshold for automatic account suspension. Users inactive for this many days will be flagged by the MLM Commission Engine.",
inactivity_threshold: "Inactivity Threshold (days)",
inactivity_range: "Range: 30730 days (default: 180)",
inactivity_updated: "Inactivity threshold saved!",
inactivity_save_error: "Failed to save inactivity threshold.",
effective_threshold: "Effective Threshold",
last_changed: "Last changed",
never: "never",
save_inactivity: "Save",
has_changes: "Changes detected"
},
params: {
title: "System Parameters",
@@ -5259,6 +5386,82 @@ const locale_commission_46json_ea60861a = {
commission_rules: commission_rules
};
var financial_ledger = {
title: "Financial Ledger",
subtitle: "Browse the complete financial ledger — transactions, users and details",
loading: "Loading ledger...",
error: "Failed to load financial ledger.",
retry: "Retry",
no_results: "No financial ledger entries found.",
no_results_hint: "Try different filter criteria.",
id: "ID",
user: "User",
user_id: "User ID",
amount: "Amount",
currency: "Currency",
transaction_type: "Transaction Type",
entry_type: "Type",
wallet_type: "Wallet",
status: "Status",
created_at: "Date",
details: "Details",
filter: "Filter",
clear: "Clear",
prev: "Previous",
next: "Next",
page_info: "{page} / {total} pages ({count} items)",
export_csv: "Export CSV",
all_types: "All Types",
all_wallets: "All Wallets",
all_statuses: "All Statuses",
date_from: "Date From",
date_to: "Date To",
search: "Search",
csv_downloaded: "CSV download started."
};
var enums = {
entry_type: {
credit: "Credit",
debit: "Debit",
CREDIT: "Credit",
DEBIT: "Debit"
},
wallet_type: {
earned: "Earned",
purchased: "Purchased",
service_coins: "Service Coins",
voucher: "Voucher",
EARNED: "Earned",
PURCHASED: "Purchased",
SERVICE_COINS: "Service Coins",
VOUCHER: "Voucher"
},
status: {
pending: "Pending",
completed: "Completed",
success: "Success",
failed: "Failed",
cancelled: "Cancelled",
refunded: "Refunded",
refund: "Refund"
},
transaction_type: {
subscription: "Subscription",
commission: "Commission",
refund: "Refund",
manual_adjustment: "Manual Adjustment",
purchase: "Purchase",
withdrawal: "Withdrawal",
bonus: "Bonus",
fee: "Fee",
reward: "Reward"
}
};
const locale_finance_46json_5597e160 = {
financial_ledger: financial_ledger,
enums: enums
};
var common$4 = {
loading: "Wird geladen...",
saving: "Wird gespeichert...",
@@ -5775,6 +5978,11 @@ const localeLoaders = {
key: "locale_commission_46json_2229a18b",
load: () => Promise.resolve(locale_commission_46json_2229a18b),
cache: true
},
{
key: "locale_finance_46json_ae59f62d",
load: () => Promise.resolve(locale_finance_46json_ae59f62d),
cache: true
}
],
en: [
@@ -5822,6 +6030,11 @@ const localeLoaders = {
key: "locale_commission_46json_ea60861a",
load: () => Promise.resolve(locale_commission_46json_ea60861a),
cache: true
},
{
key: "locale_finance_46json_5597e160",
load: () => Promise.resolve(locale_finance_46json_5597e160),
cache: true
}
],
de: [
@@ -6512,22 +6725,7 @@ _1xyvuM2CQDhMlDxE8qY1t3Wi66ocgNUNDNce4pREOrU,
_wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
];
const assets = {
"/index.mjs": {
"type": "text/javascript; charset=utf-8",
"etag": "\"3db35-gliVpBtK5107QX2IT8SR1tGeiAs\"",
"mtime": "2026-07-24T12:30:16.660Z",
"size": 252725,
"path": "index.mjs"
},
"/index.mjs.map": {
"type": "application/json",
"etag": "\"84c6f-gMC45UcEAiPwtJNbdMr3pGk5iUE\"",
"mtime": "2026-07-24T12:30:16.660Z",
"size": 543855,
"path": "index.mjs.map"
}
};
const assets = {};
function readAsset (id) {
const serverDir = dirname$1(fileURLToPath(globalThis._importMeta_.url));

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"id":"dev","timestamp":1784853723611}
{"id":"dev","timestamp":1785068325212}

View File

@@ -1 +1 @@
{"id":"dev","timestamp":1784853723611,"prerendered":[]}
{"id":"dev","timestamp":1785068325212,"prerendered":[]}

View File

@@ -1,5 +1,5 @@
{
"date": "2026-07-24T12:30:18.036Z",
"date": "2026-07-26T12:18:53.544Z",
"preset": "nitro-dev",
"framework": {
"name": "nuxt",
@@ -11,7 +11,7 @@
"dev": {
"pid": 19,
"workerAddress": {
"socketPath": "\u0000nitro-worker-19-30-30-3992.sock"
"socketPath": "\u0000nitro-worker-19-1-1-8775.sock"
}
}
}

View File

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

View File

@@ -1,4 +1,4 @@
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/24/2026, 12:30:17 PM
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/26/2026, 12:18:45 PM
import "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger";

View File

@@ -1,6 +1,9 @@
{
"compilerOptions": {
"paths": {
"nuxt": [
"../node_modules/nuxt"
],
"@unhead/vue": [
"../node_modules/@unhead/vue"
],
@@ -16,9 +19,6 @@
"@nuxt/schema": [
"../node_modules/@nuxt/schema"
],
"nuxt": [
"../node_modules/nuxt"
],
"vue-i18n": [
"../node_modules/vue-i18n/dist/vue-i18n",
"../node_modules/vue-i18n"
@@ -47,9 +47,6 @@
"../node_modules/@intlify/message-compiler/dist/message-compiler",
"../node_modules/@intlify/message-compiler"
],
"nitropack": [
"../node_modules/nitropack"
],
"defu": [
"../node_modules/defu"
],
@@ -68,6 +65,9 @@
"unplugin-vue-router/client": [
"../node_modules/unplugin-vue-router/client"
],
"nitropack": [
"../node_modules/nitropack"
],
"vite/client": [
"../node_modules/vite/client"
],

View File

@@ -29,6 +29,9 @@
"@@/*": [
"../*"
],
"nuxt": [
"../node_modules/nuxt"
],
"@unhead/vue": [
"../node_modules/@unhead/vue"
],
@@ -44,9 +47,6 @@
"@nuxt/schema": [
"../node_modules/@nuxt/schema"
],
"nuxt": [
"../node_modules/nuxt"
],
"vue-i18n": [
"../node_modules/vue-i18n"
],
@@ -68,9 +68,6 @@
"@intlify/message-compiler": [
"../node_modules/@intlify/message-compiler"
],
"nitropack": [
"../node_modules/nitropack"
],
"defu": [
"../node_modules/defu"
],
@@ -89,6 +86,9 @@
"unplugin-vue-router/client": [
"../node_modules/unplugin-vue-router/client"
],
"nitropack": [
"../node_modules/nitropack"
],
"vite/client": [
"../node_modules/vite/client"
],

View File

@@ -0,0 +1,73 @@
{
"financial_ledger": {
"title": "Financial Ledger",
"subtitle": "Browse the complete financial ledger — transactions, users and details",
"loading": "Loading ledger...",
"error": "Failed to load financial ledger.",
"retry": "Retry",
"no_results": "No financial ledger entries found.",
"no_results_hint": "Try different filter criteria.",
"id": "ID",
"user": "User",
"user_id": "User ID",
"amount": "Amount",
"currency": "Currency",
"transaction_type": "Transaction Type",
"entry_type": "Type",
"wallet_type": "Wallet",
"status": "Status",
"created_at": "Date",
"details": "Details",
"filter": "Filter",
"clear": "Clear",
"prev": "Previous",
"next": "Next",
"page_info": "{page} / {total} pages ({count} items)",
"export_csv": "Export CSV",
"all_types": "All Types",
"all_wallets": "All Wallets",
"all_statuses": "All Statuses",
"date_from": "Date From",
"date_to": "Date To",
"search": "Search",
"csv_downloaded": "CSV download started."
},
"enums": {
"entry_type": {
"credit": "Credit",
"debit": "Debit",
"CREDIT": "Credit",
"DEBIT": "Debit"
},
"wallet_type": {
"earned": "Earned",
"purchased": "Purchased",
"service_coins": "Service Coins",
"voucher": "Voucher",
"EARNED": "Earned",
"PURCHASED": "Purchased",
"SERVICE_COINS": "Service Coins",
"VOUCHER": "Voucher"
},
"status": {
"pending": "Pending",
"completed": "Completed",
"success": "Success",
"failed": "Failed",
"cancelled": "Cancelled",
"refunded": "Refunded",
"refund": "Refund"
},
"transaction_type": {
"subscription": "Subscription",
"commission": "Commission",
"refund": "Refund",
"manual_adjustment": "Manual Adjustment",
"purchase": "Purchase",
"withdrawal": "Withdrawal",
"bonus": "Bonus",
"fee": "Fee",
"reward": "Reward"
}
}
}

View File

@@ -16,6 +16,7 @@
"finance_management": "Finance Management",
"packages": "Packages",
"commission_rules": "Commission Rules",
"financial_ledger": "Financial Ledger",
"gamification": "Gamification",
"game_settings": "Game Settings",
"point_rules": "Point Rules",
@@ -29,6 +30,7 @@
"statistics": "Statistics",
"leaderboard": "Leaderboard",
"points_ledger": "Points Ledger",
"ledger": "Points Ledger",
"gamification_hq": "Gamification HQ",
"system_config": "System Config",
"system_parameters": "System Parameters",

View File

@@ -1,5 +1,10 @@
{
"system": {
"logs": {
"subtitle": "System Logs — audited event overview",
"placeholder_title": "System Logs Viewer",
"placeholder_desc": "This section will display audited system events, log entries, and system-wide operations. Module is under development."
},
"config": {
"title": "System Config",
"subtitle": "Gamification master configuration editor",
@@ -24,7 +29,18 @@
"updated": "Configuration updated!",
"save_error": "Failed to save configuration.",
"json_applied": "JSON applied to form fields!",
"invalid_json": "Invalid JSON: "
"invalid_json": "Invalid JSON: ",
"inactivity_monitor": "Inactivity Monitor",
"inactivity_desc": "Configure the inactivity threshold for automatic account suspension. Users inactive for this many days will be flagged by the MLM Commission Engine.",
"inactivity_threshold": "Inactivity Threshold (days)",
"inactivity_range": "Range: 30730 days (default: 180)",
"inactivity_updated": "Inactivity threshold saved!",
"inactivity_save_error": "Failed to save inactivity threshold.",
"effective_threshold": "Effective Threshold",
"last_changed": "Last changed",
"never": "never",
"save_inactivity": "Save",
"has_changes": "Changes detected"
},
"params": {
"title": "System Parameters",

View File

@@ -1,15 +1,30 @@
{
"dashboard": {
"title": "Gamification HQ",
"subtitle": "Gamification rendszer áttekintő irányítópultja",
"title": "Admin Dashboard",
"subtitle": "Rendszer áttekintő irányítópult",
"loading": "Dashboard betöltése...",
"error": "Nem sikerült betölteni a dashboard adatokat.",
"active_users": "Aktív Felhasználók",
"new_this_month": "új e hónapban",
"total_vehicles": "Összes Jármű",
"organizations": "Szervezetek",
"memberships": "tagság",
"new_users": "Új Felhasználók",
"new_today": "ma",
"users_by_role": "Felhasználók Szerepkör Szerint",
"users_by_plan": "Felhasználók Csomag Szerint",
"users_by_language": "Felhasználók Nyelv Szerint",
"registration_trend": "Regisztrációs Trend",
"quick_actions": "Gyors Műveletek",
"invite_admin": "Admin Meghívása",
"add_vehicle": "Jármű Hozzáadása",
"view_reports": "Jelentések Megtekintése",
"recent_activity": "Legutóbbi Tevékenység",
"total_users": "Összes Felhasználó",
"active_season": "Aktív Szezon",
"pending_contributions": "Függő Hozzájárulások",
"xp_earned_today": "Ma Szerzett XP",
"none": "Nincs",
"quick_actions": "Gyors Műveletek",
"manage_point_rules": "Pontszabályok kezelése",
"manage_badges": "Kitüntetések kezelése",
"manage_seasons": "Szezonok kezelése",

View File

@@ -0,0 +1,73 @@
{
"financial_ledger": {
"title": "Pénzügyi Napló",
"subtitle": "A teljes pénzügyi főkönyv böngészése — tranzakciók, felhasználók és részletek",
"loading": "Napló betöltése...",
"error": "Nem sikerült betölteni a pénzügyi naplót.",
"retry": "Újrapróbálkozás",
"no_results": "Nincsenek pénzügyi napló bejegyzések.",
"no_results_hint": "Próbálj más szűrési feltételeket használni.",
"id": "ID",
"user": "Felhasználó",
"user_id": "User ID",
"amount": "Összeg",
"currency": "Pénznem",
"transaction_type": "Tranzakció Típus",
"entry_type": "Típus",
"wallet_type": "Tárca",
"status": "Státusz",
"created_at": "Dátum",
"details": "Részletek",
"filter": "Szűrés",
"clear": "Törlés",
"prev": "Előző",
"next": "Következő",
"page_info": "{page} / {total} oldal ({count} elem)",
"export_csv": "CSV Export",
"all_types": "Minden típus",
"all_wallets": "Minden tárca",
"all_statuses": "Minden státusz",
"date_from": "Dátumtól",
"date_to": "Dátumig",
"search": "Keresés",
"csv_downloaded": "CSV letöltés elindítva."
},
"enums": {
"entry_type": {
"credit": "Jóváírás",
"debit": "Terhelés",
"CREDIT": "Jóváírás",
"DEBIT": "Terhelés"
},
"wallet_type": {
"earned": "Megszerzett",
"purchased": "Vásárolt",
"service_coins": "Szolgáltatási Coin",
"voucher": "Utalvány",
"EARNED": "Megszerzett",
"PURCHASED": "Vásárolt",
"SERVICE_COINS": "Szolgáltatási Coin",
"VOUCHER": "Utalvány"
},
"status": {
"pending": "Függőben",
"completed": "Teljesítve",
"success": "Sikeres",
"failed": "Sikertelen",
"cancelled": "Visszavonva",
"refunded": "Visszatérítve",
"refund": "Visszatérítés"
},
"transaction_type": {
"subscription": "Előfizetés",
"commission": "Jutalék",
"refund": "Visszatérítés",
"manual_adjustment": "Kézi Korrekció",
"purchase": "Vásárlás",
"withdrawal": "Kivét",
"bonus": "Bónusz",
"fee": "Díj",
"reward": "Jutalom"
}
}
}

View File

@@ -16,6 +16,7 @@
"finance_management": "Pénzügyek Kezelése",
"packages": "Csomagok",
"commission_rules": "Jutalék Szabályok",
"financial_ledger": "Pénzügyi Napló",
"gamification": "Gamification",
"game_settings": "Játékmenet Beállítások",
"point_rules": "Pontszabályok",
@@ -29,6 +30,7 @@
"statistics": "Statisztikák",
"leaderboard": "Ranglista",
"points_ledger": "Pontnapló",
"ledger": "Pontnapló",
"gamification_hq": "Gamification HQ",
"system_config": "Rendszer Konfig",
"system_parameters": "Rendszerparaméterek",

View File

@@ -1,5 +1,10 @@
{
"system": {
"logs": {
"subtitle": "Rendszernaplók — auditált események áttekintése",
"placeholder_title": "Rendszernaplók megtekintése",
"placeholder_desc": "Itt jelennek meg a rendszer által auditált események, naplóbejegyzések és rendszerszintű műveletek. A modul még fejlesztés alatt áll."
},
"config": {
"title": "Rendszer Konfig",
"subtitle": "Gamification master konfiguráció szerkesztése",
@@ -24,7 +29,18 @@
"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: "
"invalid_json": "Érvénytelen JSON: ",
"inactivity_monitor": "Inaktivitás Figyelő",
"inactivity_desc": "Az inaktivitási küszöbérték konfigurálása automatikus fiók felfüggesztéshez. Az ennyi napja inaktív felhasználók jelölésre kerülnek az MLM Jutalék Motor által.",
"inactivity_threshold": "Inaktivitási Küszöb (nap)",
"inactivity_range": "Tartomány: 30730 nap (alapértelmezett: 180)",
"inactivity_updated": "Inaktivitási küszöb elmentve!",
"inactivity_save_error": "Nem sikerült elmenteni az inaktivitási küszöböt.",
"effective_threshold": "Hatályos Küszöb",
"last_changed": "Utolsó módosítás",
"never": "soha",
"save_inactivity": "Mentés",
"has_changes": "Változások észlelve"
},
"params": {
"title": "Rendszerparaméterek",

View File

@@ -352,6 +352,11 @@ const menuGroups: MenuGroup[] = [
label: 'menu.commission_rules',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
},
{
path: '/finance/ledger',
label: 'menu.financial_ledger',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /></svg>',
},
],
},
],
@@ -448,15 +453,14 @@ const menuGroups: MenuGroup[] = [
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>',
},
{
path: '/system/settings',
label: 'menu.system_config',
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
},
{
path: '/logs',
label: 'menu.system_logs',
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
children: [
{
path: '/', /* P0 HOTFIX: placeholder until /logs page is built */
label: 'menu.system_logs',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
},
],
},
],
},
@@ -471,7 +475,9 @@ const pageTitle = computed(() => {
if (route.path.startsWith('/users')) return 'menu.users'
if (route.path.startsWith('/logs')) return 'menu.system_logs'
if (route.path.startsWith('/packages')) return 'menu.packages'
if (route.path.startsWith('/finance/ledger')) return 'financial_ledger.title'
if (route.path.startsWith('/finance/commission-rules')) return 'commission_rules.title'
if (route.path.startsWith('/system/settings')) return 'system.config.title'
if (route.path.startsWith('/providers')) {
if (route.path === '/providers/pending') return 'providers.pending_title'
if (route.path.startsWith('/providers/')) return 'providers.provider_detail'

View File

@@ -23,7 +23,7 @@ export default defineNuxtConfig({
files: [
'hu/common.json', 'hu/menu.json', 'hu/dashboard.json', 'hu/providers.json',
'hu/garages.json', 'hu/users.json', 'hu/gamification.json', 'hu/system.json',
'hu/commission.json',
'hu/commission.json', 'hu/finance.json',
],
},
{
@@ -31,7 +31,7 @@ export default defineNuxtConfig({
files: [
'en/common.json', 'en/menu.json', 'en/dashboard.json', 'en/providers.json',
'en/garages.json', 'en/users.json', 'en/gamification.json', 'en/system.json',
'en/commission.json',
'en/commission.json', 'en/finance.json',
],
},
{ code: 'de', iso: 'de-DE', file: 'de.json', name: 'Deutsch' },

View File

@@ -0,0 +1,387 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('financial_ledger.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('financial_ledger.subtitle') }}</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="text-slate-400 flex items-center gap-3">
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{{ t('financial_ledger.loading') }}</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
<p class="text-rose-400">{{ t('financial_ledger.error') }}</p>
<button @click="fetchLedger" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">
{{ t('financial_ledger.retry') }}
</button>
</div>
<template v-else>
<!-- Filters -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<!-- User ID Filter -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.user_id') }}
</label>
<input
v-model.number="filters.user_id"
type="number"
min="1"
placeholder="User ID"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@input="debouncedSearch"
/>
</div>
<!-- Transaction Type -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.transaction_type') }}
</label>
<input
v-model="filters.transaction_type"
type="text"
placeholder="pl. ACCOUNT_SUSPENDED..."
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@input="debouncedSearch"
/>
</div>
<!-- Entry Type -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.entry_type') }}
</label>
<select
v-model="filters.entry_type"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@change="fetchLedger"
>
<option value="">{{ t('financial_ledger.all_types') }}</option>
<option value="DEBIT">{{ tEnum('entry_type', 'DEBIT') }}</option>
<option value="CREDIT">{{ tEnum('entry_type', 'CREDIT') }}</option>
</select>
</div>
<!-- Wallet Type -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.wallet_type') }}
</label>
<select
v-model="filters.wallet_type"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@change="fetchLedger"
>
<option value="">{{ t('financial_ledger.all_wallets') }}</option>
<option value="EARNED">{{ tEnum('wallet_type', 'EARNED') }}</option>
<option value="PURCHASED">{{ tEnum('wallet_type', 'PURCHASED') }}</option>
<option value="SERVICE_COINS">{{ tEnum('wallet_type', 'SERVICE_COINS') }}</option>
<option value="VOUCHER">{{ tEnum('wallet_type', 'VOUCHER') }}</option>
</select>
</div>
<!-- Date From -->
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.date_from') }}
</label>
<input
v-model="filters.date_from"
type="date"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@change="fetchLedger"
/>
</div>
</div>
<!-- Second Row: Date To + Actions -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mt-4">
<div>
<label class="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">
{{ t('financial_ledger.date_to') }}
</label>
<input
v-model="filters.date_to"
type="date"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
@change="fetchLedger"
/>
</div>
<!-- Actions -->
<div class="flex items-end gap-2">
<button
@click="clearFilters"
class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg text-sm font-medium transition flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{{ t('financial_ledger.clear') }}
</button>
</div>
</div>
</div>
<!-- Table -->
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-slate-700/50">
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">ID</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.user') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.amount') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.transaction_type') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.entry_type') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.wallet_type') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.status') }}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{{ t('financial_ledger.created_at') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="items.length === 0">
<td colspan="8" class="px-4 py-12 text-center text-slate-500">
<p>{{ t('financial_ledger.no_results') }}</p>
<p class="text-sm mt-1">{{ t('financial_ledger.no_results_hint') }}</p>
</td>
</tr>
<tr
v-for="entry in items"
:key="entry.id"
@click="selectedEntry = selectedEntry?.id === entry.id ? null : entry"
class="border-t border-slate-700 hover:bg-slate-700/30 transition cursor-pointer"
>
<td class="px-4 py-3 text-sm text-slate-300 font-mono">{{ entry.id }}</td>
<td class="px-4 py-3 text-sm">
<div class="text-white font-medium">{{ entry.user_name || '-' }}</div>
<div class="text-xs text-slate-500">{{ entry.user_email || '-' }}</div>
</td>
<td class="px-4 py-3 text-sm text-right">
<span
class="font-mono font-medium"
:class="entry.entry_type === 'CREDIT' ? 'text-emerald-400' : 'text-rose-400'"
>
{{ entry.entry_type === 'CREDIT' ? '+' : '-' }}{{ formatAmount(entry.amount) }}
</span>
</td>
<td class="px-4 py-3 text-sm text-slate-300">{{ tEnum('transaction_type', entry.transaction_type) }}</td>
<td class="px-4 py-3">
<span
class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full"
:class="entry.entry_type === 'CREDIT' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'"
>
{{ tEnum('entry_type', entry.entry_type) }}
</span>
</td>
<td class="px-4 py-3">
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-indigo-500/10 text-indigo-400">
{{ tEnum('wallet_type', entry.wallet_type) }}
</span>
</td>
<td class="px-4 py-3">
<span
class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full"
:class="statusClass(entry.status)"
>
{{ tEnum('status', entry.status) }}
</span>
</td>
<td class="px-4 py-3 text-sm text-slate-400">{{ formatDate(entry.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Expanded Details -->
<div v-if="selectedEntry" class="border-t border-slate-700 bg-slate-750 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">{{ t('financial_ledger.details') }}</h3>
<pre class="text-xs text-slate-400 font-mono bg-slate-900/50 rounded-lg p-3 overflow-x-auto max-h-48 overflow-y-auto">{{ JSON.stringify(selectedEntry.details, null, 2) }}</pre>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between px-4 py-3 border-t border-slate-700 bg-slate-800/50">
<div class="text-sm text-slate-400">
{{ t('financial_ledger.page_info', { page: currentPage, total: totalPages, count: total }) }}
</div>
<div class="flex items-center gap-2">
<button
:disabled="currentPage <= 1"
:class="['px-3 py-1.5 text-sm rounded-lg transition', currentPage <= 1 ? 'bg-slate-700 text-slate-500 cursor-not-allowed' : 'bg-slate-700 hover:bg-slate-600 text-slate-300']"
@click="goToPage(currentPage - 1)"
>
{{ t('financial_ledger.prev') }}
</button>
<span class="text-sm text-slate-400 px-2">{{ currentPage }} / {{ totalPages }}</span>
<button
:disabled="currentPage >= totalPages"
:class="['px-3 py-1.5 text-sm rounded-lg transition', currentPage >= totalPages ? 'bg-slate-700 text-slate-500 cursor-not-allowed' : 'bg-slate-700 hover:bg-slate-600 text-slate-300']"
@click="goToPage(currentPage + 1)"
>
{{ t('financial_ledger.next') }}
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCookie } from '#app'
const { t, te } = useI18n()
/**
* Translate enum values from the backend (DB enums) to the current locale.
* Falls back to the raw value if no translation key exists.
*/
function tEnum(category: string, value: string | null | undefined): string {
if (!value) return '-'
const key = `enums.${category}.${value}`
if (te(key)) return t(key)
// Try lowercase fallback for uppercase values
const lowerKey = `enums.${category}.${value.toLowerCase()}`
if (te(lowerKey)) return t(lowerKey)
return value
}
interface LedgerEntry {
id: number
user_id: number | null
person_id: number | null
amount: number
currency: string | null
transaction_type: string | null
entry_type: string | null
wallet_type: string | null
status: string | null
details: Record<string, unknown> | null
created_at: string | null
user_email: string | null
user_name: string | null
}
interface LedgerResponse {
total: number
page: number
page_size: number
items: LedgerEntry[]
}
const loading = ref(true)
const error = ref(false)
const items = ref<LedgerEntry[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(50)
const selectedEntry = ref<LedgerEntry | null>(null)
const filters = ref({
user_id: null as number | null,
transaction_type: '',
entry_type: '',
wallet_type: '',
date_from: '',
date_to: '',
})
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
return tokenCookie.value
? { Authorization: `Bearer ${tokenCookie.value}` }
: {}
}
function formatAmount(val: number): string {
return val.toLocaleString('hu-HU', { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleDateString('hu-HU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function statusClass(status: string | null): string {
const s = (status || '').toLowerCase()
if (s === 'completed' || s === 'success') return 'bg-emerald-500/10 text-emerald-400'
if (s === 'pending') return 'bg-amber-500/10 text-amber-400'
if (s === 'failed') return 'bg-rose-500/10 text-rose-400'
if (s === 'refunded' || s === 'refund') return 'bg-purple-500/10 text-purple-400'
if (s === 'cancelled') return 'bg-slate-500/10 text-slate-400'
return 'bg-slate-500/10 text-slate-400'
}
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function debouncedSearch() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
currentPage.value = 1
fetchLedger()
}, 400)
}
function clearFilters() {
filters.value = { user_id: null, transaction_type: '', entry_type: '', wallet_type: '', date_from: '', date_to: '' }
currentPage.value = 1
fetchLedger()
}
function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
fetchLedger()
}
async function fetchLedger() {
loading.value = true
error.value = false
selectedEntry.value = null
try {
const params: Record<string, string | number> = {
page: currentPage.value,
page_size: pageSize.value,
}
if (filters.value.user_id) params.user_id = String(filters.value.user_id)
if (filters.value.transaction_type) params.transaction_type = filters.value.transaction_type
if (filters.value.entry_type) params.entry_type = filters.value.entry_type
if (filters.value.wallet_type) params.wallet_type = filters.value.wallet_type
if (filters.value.date_from) params.date_from = filters.value.date_from
if (filters.value.date_to) params.date_to = filters.value.date_to
const data: LedgerResponse = await $fetch('/api/v1/admin/finance/ledger', {
headers: getHeaders(),
params,
})
items.value = data.items
total.value = data.total
} catch (err) {
console.error('Failed to fetch financial ledger:', err)
error.value = true
} finally {
loading.value = false
}
}
onMounted(() => {
fetchLedger()
})
</script>

View File

@@ -215,6 +215,65 @@
</div>
</div>
<!-- Inactivity Monitor -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-rose-400" 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>
{{ t('gamification.config.inactivity_monitor') }}
</h2>
<p class="text-sm text-slate-400 mb-6">
{{ t('gamification.config.inactivity_desc') }}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Threshold Days -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">
{{ t('gamification.config.inactivity_threshold') }}
</label>
<div class="flex items-center gap-3">
<input
v-model.number="inactivityDays"
type="number"
min="30"
max="730"
class="w-32 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-center focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 text-sm"
/>
<span class="text-sm text-slate-400">{{ t('gamification.config.days') }}</span>
</div>
<p class="text-xs text-slate-500 mt-1.5">
{{ t('gamification.config.inactivity_range') }}
</p>
</div>
<!-- Current effective value -->
<div class="flex items-end">
<div class="bg-slate-700/50 rounded-lg px-4 py-3 w-full">
<div class="text-xs text-slate-500 uppercase tracking-wider mb-1">{{ t('gamification.config.effective_threshold') }}</div>
<div class="text-lg font-semibold text-white">{{ inactivityDays || 180 }} {{ t('gamification.config.days') }}</div>
<div class="text-xs text-slate-500 mt-0.5">
{{ t('gamification.config.last_changed') }}: {{ lastUpdated || t('gamification.config.never') }}
</div>
</div>
</div>
</div>
<!-- Save Button -->
<div class="mt-6 flex items-center gap-4">
<button
@click="saveInactivityThreshold"
class="px-4 py-2 bg-amber-600 hover:bg-amber-500 text-white rounded-lg text-sm font-medium transition"
>
{{ t('gamification.config.save_inactivity') }}
</button>
<span v-if="inactivityDays !== originalInactivityDays" class="text-xs text-amber-400">
{{ t('gamification.config.has_changes') }}
</span>
</div>
</div>
<!-- Impact Calculator -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
@@ -387,6 +446,10 @@ const penaltyLogic = reactive<PenaltyLogic>({
const conversionLogic = reactive<ConversionLogic>({ socialToCreditRate: 100 })
const levelRewards = reactive<LevelRewards>({ creditsPer10Levels: 50 })
const inactivityDays = ref(180)
const originalInactivityDays = ref(180)
const lastUpdated = ref<string | null>(null)
const calculator = reactive({ level: 5, activities: 3, socialPoints: 50 })
const calculatedDailyXp = computed(() => {
@@ -468,6 +531,45 @@ function applyConfigToForm(cfg: Record<string, any>) {
}
}
async function fetchInactivityThreshold() {
try {
const data = await $fetch<{ key: string; value: any }>('/api/v1/system/parameters/inactivity_threshold_days?scope_level=global', {
headers: getHeaders(),
})
const val = data.value
if (typeof val === 'object' && val !== null) {
inactivityDays.value = Number(val.value || val.days || 180)
} else {
inactivityDays.value = Number(val) || 180
}
originalInactivityDays.value = inactivityDays.value
lastUpdated.value = (data as any).updated_at ? new Date((data as any).updated_at).toLocaleDateString('hu-HU') : null
} catch (e) {
// Parameter not seeded yet — default stays 180
inactivityDays.value = 180
originalInactivityDays.value = 180
}
}
async function saveInactivityThreshold() {
try {
await $fetch('/api/v1/system/parameters/inactivity_threshold_days?scope_level=global', {
method: 'PUT',
headers: getHeaders(),
body: {
value: inactivityDays.value,
description: 'User inactivity threshold in days. If last activity is older, account is suspended and MLM commission bypasses them.',
},
})
originalInactivityDays.value = inactivityDays.value
showToast(t('gamification.config.inactivity_updated'))
} catch (e) {
console.error('Failed to save inactivity threshold:', e)
formError.value = t('gamification.config.inactivity_save_error')
setTimeout(() => { formError.value = '' }, 4000)
}
}
async function fetchConfig() {
loading.value = true
error.value = false
@@ -525,6 +627,7 @@ function formatNumber(n: number): string {
onMounted(() => {
fetchConfig()
fetchInactivityThreshold()
})
</script>

View File

@@ -0,0 +1,28 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('menu.system_logs') }}</h1>
<p class="text-slate-400 mt-1">{{ t('system.logs.subtitle') }}</p>
</div>
<!-- Placeholder: System audit log viewer -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-8 text-center">
<svg class="w-16 h-16 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-lg font-semibold text-slate-400 mb-2">{{ t('system.logs.placeholder_title') }}</h3>
<p class="text-sm text-slate-500 max-w-md mx-auto">
{{ t('system.logs.placeholder_desc') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('system.config.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('system.config.subtitle') }}</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="text-slate-400 flex items-center gap-3">
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{{ t('system.config.loading') }}</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
<p class="text-rose-400">{{ t('system.config.error') }}</p>
<button @click="fetchSettings" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">
{{ t('system.params.retry') }}
</button>
</div>
<template v-else>
<div class="space-y-6">
<!-- Raw JSON Viewer -->
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('system.config.raw_json') }}</h2>
<textarea
v-model="rawJson"
rows="8"
class="w-full px-4 py-3 bg-slate-900 border border-slate-600 rounded-lg text-sm text-slate-300 font-mono focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500"
:placeholder="t('system.config.raw_json')"
></textarea>
<div class="mt-3 flex items-center gap-3">
<button
@click="applyRawJson"
class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg text-sm font-medium transition"
>
{{ t('system.config.apply_json') }}
</button>
<span v-if="jsonError" class="text-sm text-rose-400">{{ t('system.config.invalid_json') }}{{ jsonError }}</span>
<span v-if="jsonApplied" class="text-sm text-emerald-400">{{ t('system.config.json_applied') }}</span>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const loading = ref(true)
const error = ref(false)
const rawJson = ref('')
const jsonError = ref<string | null>(null)
const jsonApplied = ref(false)
onMounted(() => {
loading.value = false
})
function applyRawJson() {
jsonError.value = null
jsonApplied.value = false
try {
JSON.parse(rawJson.value) // Just validate JSON
jsonApplied.value = true
setTimeout(() => { jsonApplied.value = false }, 3000)
} catch (err) {
jsonError.value = (err as Error).message
}
}
</script>

View File

@@ -969,22 +969,130 @@ function resetForm() {
}
// ── Edit Cost Hydration ──
/**
* P0 BUGFIX (2026-07-26): Complete rewrite of edit hydration to fix 5 bugs:
*
* BUG #1 (CRITICAL): Category hierarchy collapse — correctly splits category_id
* into mainCategory (parent) and subCategory (leaf) using parent_category_id
* from the enriched backend response.
*
* BUG #2 (HIGH): Vehicle not bound when not in store — explicitly sets
* selectedVehicleId from cost.asset_id when props.vehicle is null.
*
* BUG #3 (HIGH): Provider not hydrated — constructs ProviderSearchResult from
* cost.service_provider_id + cost.service_provider_name, or uses
* cost.external_vendor_name as fallback.
*
* BUG #4 (MEDIUM): liters null-check — defensive guard on cost.data?.liters.
*
* BUG #5 (MEDIUM): onMainCategoryChange() clears subCategory during hydration —
* sets mainCategory first (triggers @change → populates subcategories), then
* sets subCategory to the correct leaf ID.
*/
watch(() => props.editCost, (cost) => {
if (!cost) return
// Hydrate form from existing expense data
// ── Basic fields (unchanged) ──
form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
form.mainCategory = String(cost.category_id || '')
form.subCategory = String(cost.sub_category_id || '')
form.netAmount = cost.data?.net_amount || cost.amount_net || 0
form.vatRate = cost.data?.vat_rate || 27
form.grossAmount = cost.amount_gross || 0
form.netAmount = cost.data?.net_amount ?? cost.amount_net ?? 0
form.vatRate = cost.data?.vat_rate ?? 27
form.grossAmount = cost.amount_gross ?? 0
form.currency = cost.currency || 'HUF'
form.invoiceNumber = cost.data?.invoice_number || ''
form.invoiceDate = cost.data?.invoice_date || ''
form.paymentDeadline = cost.data?.payment_deadline || ''
form.paymentMethod = cost.data?.payment_method || 'TRANSFER'
form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null
form.liters = cost.data?.liters || null
form.invoiceNumber = cost.data?.invoice_number ?? ''
form.invoiceDate = cost.data?.invoice_date ?? ''
form.paymentDeadline = cost.data?.payment_deadline ?? ''
form.paymentMethod = cost.data?.payment_method ?? 'TRANSFER'
// ── BUG #4 FIX: Defensive null-check on data.liters ──
const costData = cost.data ?? {}
form.liters = (costData.liters != null && costData.liters !== '') ? costData.liters : null
// ── Mileage: use enriched mileage_at_cost, fallback to data.mileage_at_cost ──
form.mileageAtCost = (cost.mileage_at_cost != null && cost.mileage_at_cost > 0)
? cost.mileage_at_cost
: (costData.mileage_at_cost != null ? costData.mileage_at_cost : null)
// ── BUG #1 FIX: Category hierarchy split ──
// The enriched GET /expenses/by-id/{id} response includes parent_category_id.
// If parent_category_id is set → it's a subcategory: parent goes to main, leaf to sub.
// If parent_category_id is null → category_id IS the main category already.
// Handle the 'data' response wrapper pattern (list endpoints) vs enriched format.
const categoryId = cost.category_id
const parentCategoryId = cost.parent_category_id
if (parentCategoryId != null) {
// This is a genuine subcategory — set main from parent, sub from category_id
form.mainCategory = String(parentCategoryId)
// Setting mainCategory triggers @change → onMainCategoryChange() synchronously,
// which populates subCategories.value and clears form.subCategory.
// Now re-populate subcategories if they weren't populated (safety net)
if (subCategories.value.length === 0) {
onMainCategoryChange()
}
form.subCategory = String(categoryId)
} else if (categoryId != null) {
// No parent — category_id IS the main category
form.mainCategory = String(categoryId)
form.subCategory = ''
// Still call onMainCategoryChange to populate any subcategories if this
// parent has children (the backend might not have returned parent_category_id)
if (subCategories.value.length === 0) {
onMainCategoryChange()
}
}
// ── BUG #2 FIX: Vehicle binding when not passed via prop ──
if (!props.vehicle && cost.asset_id) {
selectedVehicleId.value = cost.asset_id
}
// ── BUG #3 FIX: Provider hydration from enriched response ──
// Priority: service_provider_id > vendor_organization_id > external_vendor_name
const spId = cost.service_provider_id
const spName = cost.service_provider_name
const vendorName = cost.vendor_name
const extVendorName = cost.external_vendor_name
if (spId != null && spName) {
// Service provider (crowd_added / staged_data) — primary vendor ref
selectedProvider.value = {
id: spId,
name: spName,
category: cost.category_name || null,
specialization: [],
city: null,
address: null,
source: 'crowd_added',
is_verified: false,
rating: null,
}
} else if (cost.vendor_organization_id != null && vendorName) {
// B2B vendor organization
selectedProvider.value = {
id: cost.vendor_organization_id,
name: vendorName,
category: cost.category_name || null,
specialization: [],
city: null,
address: null,
source: 'verified_org',
is_verified: true,
rating: null,
}
} else if (extVendorName) {
// Free-text vendor name — set as a minimal provider object
selectedProvider.value = {
id: 0,
name: extVendorName,
category: cost.category_name || null,
specialization: [],
city: null,
address: null,
source: 'crowd_added',
is_verified: false,
rating: null,
}
}
})
// ── File Methods ──

View File

@@ -15,7 +15,7 @@
<div class="flex-1 space-y-3">
<!-- Score card -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
<p class="text-3xl font-extrabold text-emerald-600">{{ userScore }}</p>
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
</div>
<!-- Achievement badges -->
@@ -43,7 +43,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -52,11 +52,13 @@ const emit = defineEmits<{
'open-card': [cardId: string]
}>()
// ── Placeholder badges ──
const badges = ref([
{ icon: '🌱', label: 'Eco Driver' },
{ icon: '📏', label: '10k km Club' },
{ icon: '🔧', label: 'Service Pro' },
{ icon: '⭐', label: 'Top Rater' },
])
// ── Reactive user score (placeholder 0 — wire to gamification store when available) ──
const userScore = computed(() => 0)
// ── Reactive badges (empty array — wire to gamification API when available) ──
interface BadgeItem {
icon: string
label: string
}
const badges = ref<BadgeItem[]>([])
</script>

View File

@@ -1,63 +1,154 @@
<template>
<!--
FinanceOverviewCard (ProfileTrustCard.vue refactored)
PURPOSE: 5th dashboard card "Pénzügy" (Finance) overview.
Shows wallet summary + 3 action buttons routing to
/finance/* sub-routes. Entire card body routes to /finance.
──
DESIGN:
• Header: 💰 Pénzügy (Finance)
• Body: Click anywhere → /finance
Shows quick wallet balance overview + 4 stat tiles
• Footer: 3 action buttons (Pénztárcák / Csomagok / Tranzakciók)
──
THOUGHT PROCESS:
- User name, email, trust score REMOVED as per spec.
- All modal trigger logic REMOVED — replaced with Vue Router navigation.
- Wallet balance fetched on mount via financeStore.
- Uses BaseCard with hoverable for the card-lift effect.
═══════════════════════════════════════════════════════════════════
-->
<BaseCard
hoverable
@click="$emit('open-card', 'stats')"
@click="router.push('/finance')"
>
<!-- HEADER: Profile title -->
<!-- ═══ HEADER: Finance title ═══ -->
<template #header>
<span class="text-white font-bold text-sm tracking-wide">🛡 {{ t('common.profileSettings') }}</span>
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('menu.finance') }}</span>
</template>
<!-- BODY: User info + Trust Score + Stats -->
<!-- ═══ BODY: Quick finance overview (clickable → /finance) ═══ -->
<div class="flex-1 space-y-3">
<!-- User info -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
<!-- Balance summary row -->
<div class="rounded-xl border border-slate-200 bg-gradient-to-br from-amber-50 to-white p-3">
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
{{ t('finance.totalBalance') }}
</p>
<p class="text-2xl font-extrabold text-amber-600">
{{ formatCredits(financeStore.totalCredits) }}
</p>
<p class="text-xs text-slate-400 mt-0.5">
🪙 {{ t('finance.rewards') }}: {{ formatCredits(financeStore.serviceCoins) }}
</p>
</div>
<!-- Trust Score -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
<span class="text-sm font-bold text-emerald-600">A+</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
</div>
</div>
<!-- Quick stats -->
<!-- Quick stat tiles (2×2 grid) -->
<div class="grid grid-cols-2 gap-2">
<!-- Pénztárcák (Wallets) -->
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
<p class="text-lg font-bold text-slate-800">{{ financeStore.totalCredits }}</p>
<p class="text-xs text-slate-400">{{ t('finance.wallet') }}</p>
</div>
<!-- Tranzakciók (Transactions count) -->
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
<p class="text-lg font-bold text-slate-800">{{ transactionCount }}</p>
<p class="text-xs text-slate-400">{{ t('finance.transactionHistory') }}</p>
</div>
<!-- Csomagok (Active Packages) -->
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ subscriptionPlanName }}</p>
<p class="text-xs text-slate-400">{{ t('dashboard.viewProfile') }}</p>
</div>
<!-- Voucher / Bónusz -->
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ formatCredits(financeStore.voucherBalance) }}</p>
<p class="text-xs text-slate-400">{{ t('finance.voucher') }}</p>
</div>
</div>
</div>
<!-- FOOTER: CTA button -->
<!-- ═══ FOOTER: 3 action buttons ═══ -->
<template #footer>
<button class="w-full py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('common.profileSettings') }}
</button>
<div class="grid grid-cols-3 gap-2">
<!-- Pénztárcák (Wallets) -->
<button
class="w-full py-2.5 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-xs transition-colors shadow-md"
@click.stop="router.push('/finance/wallets')"
>
💰 {{ t('finance.wallet') }}
</button>
<!-- Csomagok (Packages) -->
<button
class="w-full py-2.5 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-xs transition-colors shadow-md"
@click.stop="router.push('/finance/packages')"
>
📦 {{ t('subscription.title') }}
</button>
<!-- Tranzakciók (Transactions) -->
<button
class="w-full py-2.5 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-xs transition-colors shadow-md"
@click.stop="router.push('/finance/transactions')"
>
📋 {{ t('finance.transactionHistory') }}
</button>
</div>
</template>
</BaseCard>
</template>
<script setup lang="ts">
/*
* ── Imports ──────────────────────────────────────────────────────────
* PURPOSE: Standard Vue 3 Composition API + Pinia stores for
* auth, vehicle, and finance data.
* THOUGHT: Removed computed trustScore* props and the onMounted
* auth check — no longer needed. Wallet balance still
* fetched on mount to display quick overview.
* Added authStore for subscription plan name display.
*/
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useFinanceStore } from '../../stores/finance'
import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle'
import BaseCard from '../ui/BaseCard.vue'
const { t } = useI18n()
const router = useRouter()
const financeStore = useFinanceStore()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
const emit = defineEmits<{
'open-card': [cardId: string]
}>()
// ── Reactive computed values for stat tiles ──
/** Transaction count — placeholder 0, wire to transaction store when available */
const transactionCount = computed(() => 0)
/** Active subscription plan name — live from authStore */
const subscriptionPlanName = computed(() => {
return authStore.user?.subscription_plan || '—'
})
// ── Helpers ───────────────────────────────────────────────────────────
/**
* formatCredits — Format a number for credit display.
* Uses locale grouping for integers, 2 decimals for floats.
*/
function formatCredits(value: number): string {
if (Number.isInteger(value)) {
return value.toLocaleString()
}
return value.toFixed(2)
}
// ── Fetch wallet balance on mount ────────────────────────────────────
onMounted(() => {
financeStore.fetchWalletBalance()
})
</script>

View File

@@ -1779,6 +1779,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved'): void
(e: 'deleted'): void
}>()
// ── Tab Configuration ──

View File

@@ -0,0 +1,302 @@
<template>
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h2 class="text-lg font-bold text-slate-800">📋 {{ t('finance.transactionHistory') }}</h2>
<!-- Filter chips -->
<div class="flex gap-1.5">
<button
v-for="f in filters"
:key="f.value"
@click="activeFilter = f.value; currentPage = 1; fetchTransactions()"
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors"
:class="activeFilter === f.value
? 'bg-sf-accent text-white'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'"
>
{{ f.label }}
</button>
</div>
</div>
<!-- Loading -->
<div
v-if="txLoading"
class="flex items-center justify-center py-16"
>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div>
<!-- Error -->
<div
v-else-if="txError"
class="flex flex-col items-center justify-center py-12 text-center px-6"
>
<svg class="w-12 h-12 text-red-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-sm text-slate-500">{{ txError }}</p>
<button
@click="fetchTransactions"
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
>
{{ t('common.retry') }}
</button>
</div>
<!-- Empty -->
<div
v-else-if="transactions.length === 0"
class="flex flex-col items-center justify-center py-16 text-slate-400 px-6"
>
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-sm">{{ t('finance.noTransactions') }}</p>
</div>
<!-- Transaction List -->
<div v-else class="divide-y divide-slate-100">
<div
v-for="tx in transactions"
:key="tx.id"
class="flex items-center justify-between px-6 py-4 hover:bg-slate-50 transition-colors"
>
<!-- Left: Icon + Details -->
<div class="flex items-start gap-3 min-w-0 flex-1">
<!-- Direction Icon -->
<div
class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
:class="tx.entry_type === 'CREDIT' ? 'bg-emerald-100' : 'bg-red-100'"
>
<span class="text-base">{{ tx.entry_type === 'CREDIT' ? '⬆' : '⬇' }}</span>
</div>
<!-- Details -->
<div class="min-w-0">
<p class="text-sm font-medium text-slate-800 truncate">
{{ tx.description || tx.transaction_type || t('finance.transaction') }}
</p>
<p class="text-xs text-slate-400 mt-0.5">
{{ formatDate(tx.created_at) }}
</p>
<div class="flex flex-wrap gap-1.5 mt-1.5">
<!-- Wallet type badge -->
<span
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
:class="walletTypeBadge(tx.wallet_type)"
>
{{ walletTypeLabel(tx.wallet_type) }}
</span>
<!-- Status badge -->
<span
class="inline-block rounded-full px-2 py-0.5 text-[10px] font-medium"
:class="statusBadge(tx.status)"
>
{{ statusLabel(tx.status) }}
</span>
</div>
</div>
</div>
<!-- Right: Amount -->
<div class="shrink-0 text-right ml-4">
<p
class="text-sm font-bold"
:class="tx.entry_type === 'CREDIT' ? 'text-emerald-600' : 'text-red-600'"
>
{{ tx.entry_type === 'CREDIT' ? '+' : '-' }}{{ formatCredits(tx.amount) }}
</p>
<p class="text-[10px] text-slate-400 mt-0.5 font-mono">
#{{ String(tx.id).slice(0, 8) }}
</p>
</div>
</div>
<!-- Pagination -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between px-6 py-3 bg-slate-50"
>
<button
@click="prevPage"
:disabled="currentPage <= 1"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="currentPage > 1
? 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200'
: 'bg-slate-50 text-slate-300 cursor-not-allowed border border-slate-100'"
>
&larr; {{ t('common.prev') }}
</button>
<span class="text-xs text-slate-400">
{{ currentPage }} / {{ totalPages }}
</span>
<button
@click="nextPage"
:disabled="currentPage >= totalPages"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="currentPage < totalPages
? 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200'
: 'bg-slate-50 text-slate-300 cursor-not-allowed border border-slate-100'"
>
{{ t('common.next') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* TransactionHistory.vue
*
* Lists paginated transaction data from GET /billing/wallet/transactions.
* Uses the financeStore and the existing i18n locale files to display
* human-readable wallet types (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)
* and statuses (SUCCESS, PENDING, FAILED, REFUNDED, REFUND).
*
* Filter chips allow scoping by wallet_type. Pagination buttons at the bottom.
*/
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '../../api/axios'
const { t } = useI18n()
// ── State ──
const transactions = ref<any[]>([])
const txLoading = ref(false)
const txError = ref<string | null>(null)
const currentPage = ref(1)
const totalPages = ref(1)
const totalCount = ref(0)
const activeFilter = ref<string | null>(null)
const pageSize = 20
// ── Filter definitions (use existing i18n keys from finance section) ──
const filters = computed(() => [
{ value: null, label: t('finance.all') },
{ value: 'PURCHASED', label: t('finance.purchased') },
{ value: 'EARNED', label: t('finance.earned') },
{ value: 'SERVICE_COINS', label: t('finance.bonus') },
])
// ── Formatting helpers ──
function formatCredits(value: number): string {
if (Number.isInteger(value)) {
return value.toLocaleString()
}
return value.toFixed(2)
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '\u2014' // em dash
const d = new Date(dateStr)
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// ── Wallet type badge colours + human-readable labels ──
// Uses the existing i18n finance keys for display.
function walletTypeBadge(type: string | null): string {
switch (type) {
case 'PURCHASED': return 'bg-blue-100 text-blue-700'
case 'EARNED': return 'bg-emerald-100 text-emerald-700'
case 'SERVICE_COINS': return 'bg-amber-100 text-amber-700'
case 'VOUCHER': return 'bg-purple-100 text-purple-700'
default: return 'bg-slate-100 text-slate-600'
}
}
function walletTypeLabel(type: string | null): string {
switch (type) {
case 'PURCHASED': return t('finance.purchased')
case 'EARNED': return t('finance.earned')
case 'SERVICE_COINS': return t('finance.bonus')
case 'VOUCHER': return t('finance.voucher')
default: return type || '\u2014'
}
}
function statusBadge(status: string | null): string {
switch (status) {
case 'SUCCESS': return 'bg-emerald-100 text-emerald-700'
case 'PENDING': return 'bg-amber-100 text-amber-700'
case 'FAILED': return 'bg-red-100 text-red-700'
case 'REFUNDED':
case 'REFUND': return 'bg-purple-100 text-purple-700'
default: return 'bg-slate-100 text-slate-600'
}
}
function statusLabel(status: string | null): string {
switch (status) {
case 'SUCCESS': return t('common.statusActive') || 'Success'
case 'PENDING': return 'Pending'
case 'FAILED': return 'Failed'
case 'REFUNDED':
case 'REFUND': return 'Refunded'
default: return status || '\u2014'
}
}
// ── API fetch ──
async function fetchTransactions() {
txLoading.value = true
txError.value = null
try {
const params: Record<string, any> = {
page: currentPage.value,
page_size: pageSize,
}
if (activeFilter.value) {
params.wallet_type = activeFilter.value
}
const res = await api.get('/billing/wallet/transactions', { params })
const data = res.data
transactions.value = data.data || []
totalCount.value = data.pagination?.total_count || 0
totalPages.value = data.pagination?.total_pages || 1
} catch (err: any) {
const message =
err.response?.data?.detail ||
err.response?.data?.message ||
'Failed to load transactions.'
txError.value = message
console.error('[TransactionHistory] fetch error:', err)
transactions.value = []
} finally {
txLoading.value = false
}
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
fetchTransactions()
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
fetchTransactions()
}
}
// ── Expose for parent to trigger refresh ──
defineExpose({ fetchTransactions })
// ── Load on mount ──
onMounted(() => {
fetchTransactions()
})
</script>

View File

@@ -0,0 +1,188 @@
<template>
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h2 class="text-lg font-bold text-slate-800">💰 {{ t('finance.walletBreakdown') }}</h2>
<span class="text-xs text-slate-400">{{ t('finance.live') }}</span>
</div>
<!-- Loading -->
<div
v-if="isLoading"
class="flex items-center justify-center py-16"
>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div>
<!-- Error -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-12 text-center px-6"
>
<svg class="w-12 h-12 text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-sm text-slate-500">{{ error }}</p>
<button
@click="retry"
class="mt-3 text-sm text-sf-accent hover:underline font-medium"
>
{{ t('common.retry') }}
</button>
</div>
<!-- Wallet Pockets Grid -->
<div v-else class="p-6 space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- EARNED -->
<div
class="rounded-xl border p-4 transition-all duration-300"
:class="highlight === 'earned'
? 'border-emerald-400 bg-emerald-50 ring-2 ring-emerald-200 scale-[1.02]'
: 'border-slate-200 bg-slate-50 hover:bg-slate-100'"
>
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
🏆 {{ t('finance.earned') }}
</span>
<span class="text-xs text-slate-400">{{ t('finance.referralRewards') }}</span>
</div>
<p class="text-2xl font-extrabold text-emerald-600">{{ formatCredits(balance.earned) }}</p>
</div>
<!-- PURCHASED -->
<div
class="rounded-xl border p-4 transition-all duration-300"
:class="highlight === 'purchased'
? 'border-blue-400 bg-blue-50 ring-2 ring-blue-200 scale-[1.02]'
: 'border-slate-200 bg-slate-50 hover:bg-slate-100'"
>
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
💳 {{ t('finance.purchased') }}
</span>
<span class="text-xs text-slate-400">{{ t('finance.topUpWallet') }}</span>
</div>
<p class="text-2xl font-extrabold text-blue-600">{{ formatCredits(balance.purchased) }}</p>
</div>
<!-- SERVICE_COINS (default highlight target) -->
<div
ref="serviceCoinsRef"
class="rounded-xl border p-4 transition-all duration-300"
:class="highlight === 'service-coins'
? 'border-amber-400 bg-amber-50 ring-2 ring-amber-200 scale-[1.02]'
: 'border-slate-200 bg-slate-50 hover:bg-slate-100'"
>
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
🪙 {{ t('finance.rewards') }}
</span>
<span class="text-xs text-slate-400">{{ t('finance.bonus') }}</span>
</div>
<p class="text-2xl font-extrabold text-amber-600">{{ formatCredits(balance.service_coins) }}</p>
</div>
<!-- VOUCHER -->
<div
class="rounded-xl border p-4 transition-all duration-300"
:class="highlight === 'voucher'
? 'border-purple-400 bg-purple-50 ring-2 ring-purple-200 scale-[1.02]'
: 'border-slate-200 bg-slate-50 hover:bg-slate-100'"
>
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">
🎟 {{ t('finance.voucher') }}
</span>
<span class="text-xs text-slate-400">{{ t('finance.activeVouchers') }}</span>
</div>
<p class="text-2xl font-extrabold text-purple-600">{{ formatCredits(balance.voucher) }}</p>
</div>
</div>
<!-- Total Balance Bar -->
<div class="rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 p-4 text-white">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold opacity-90">{{ t('finance.totalBalance') }}</span>
<span class="text-2xl font-extrabold">{{ formatCredits(balance.total) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* WalletBalanceCard.vue
*
* Displays the 4 wallet pockets (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)
* fetched from GET /billing/wallet/balance via the financeStore.
*
* Props:
* highlight - optional string matching a wallet type (e.g. "service-coins")
* to visually emphasize a specific pocket on page load.
*/
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFinanceStore } from '../../stores/finance'
import type { WalletBalance } from '../../stores/finance'
const { t } = useI18n()
const financeStore = useFinanceStore()
const props = defineProps<{
highlight?: string
}>()
const emit = defineEmits<{
loaded: []
}>()
// ── Template refs ──
const serviceCoinsRef = ref<HTMLDivElement | null>(null)
// ── Computed from store ──
const balance = computed<WalletBalance>(() => {
if (financeStore.balance) {
return financeStore.balance as WalletBalance
}
// Fallback shape while loading
return { earned: 0, purchased: 0, service_coins: 0, voucher: 0, total: 0 }
})
const isLoading = computed(() => financeStore.isLoading)
const error = computed(() => financeStore.error)
// ── Helpers ──
function formatCredits(value: number): string {
if (Number.isInteger(value)) {
return value.toLocaleString()
}
return value.toFixed(2)
}
function retry() {
financeStore.fetchWalletBalance()
}
// ── Highlight auto-scroll ──
watch(() => props.highlight, (h) => {
if (h === 'service-coins' && serviceCoinsRef.value) {
nextTick(() => {
serviceCoinsRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
})
// ── Fetch on mount & emit loaded ──
onMounted(async () => {
await financeStore.fetchWalletBalance()
// Auto-scroll if highlight is service-coins
if (props.highlight === 'service-coins' && serviceCoinsRef.value) {
nextTick(() => {
serviceCoinsRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
emit('loaded')
})
</script>

View File

@@ -18,22 +18,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const route = useRoute()
/**
* Compute the target route for the logo link.
* - If the user is on an /organization/:id page, navigate to the org root.
* - Otherwise, navigate to /dashboard (private garage).
* Always navigate to /dashboard when clicking the logo.
* This serves as a global "escape hatch" back to the private garage,
* regardless of the current route (org, vehicles, etc.).
*/
const targetRoute = computed(() => {
if (route.path.startsWith('/organization/')) {
const orgId = route.params.id
return orgId ? `/organization/${orgId}` : route.path
}
return '/dashboard'
})
const targetRoute = computed(() => '/dashboard')
</script>

View File

@@ -901,6 +901,7 @@ import VehiclePlateBadge from './VehiclePlateBadge.vue'
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
import TechDataTab from '../vehicles/tabs/TechDataTab.vue'
const { t } = useI18n()

View File

@@ -81,6 +81,10 @@ export default {
garageLimit: 'Garage Limit',
managePlan: 'Manage Plan',
indefinite: 'Indefinite',
vehicles: 'vehicles',
unlimited: 'unlimited',
timeRemaining: 'Time Remaining',
days: 'days',
},
landing: {
openGarage: 'Open Garage',

View File

@@ -81,6 +81,10 @@ export default {
garageLimit: 'Garázs Limit',
managePlan: 'Csomag Kezelése',
indefinite: 'Határozatlan',
vehicles: 'jármű',
unlimited: 'korlátlan',
timeRemaining: 'Hátralévő idő',
days: 'nap',
},
landing: {
openGarage: 'Garázs Nyitása',

View File

@@ -0,0 +1,89 @@
<template>
<!--
FinanceLayout Layout wrapper for all /finance/* routes
──
PURPOSE: Provides the exact same TopBar (BaseHeader) as PrivateLayout,
with the garage-bg.png background + dark overlay, so finance
pages match the main dashboard's visual framework exactly.
──
DESIGN:
• Sticky BaseHeader (glassmorphism, dark)
- #left: HeaderLogo → routes to /dashboard
- #center: Teleport target (#header-teleport-target) — child views
inject their page title here via Teleport
- #right: LanguageSwitcher + HeaderProfile (full user dropdown)
• Full-bleed garage-bg.png background + dark overlay
• <router-view /> for child route content
──
THOUGHT PROCESS:
- Mirrors PrivateLayout.vue exactly, but standalone (not nested).
- No hamburger menu — finance pages use their own navigation.
═══════════════════════════════════════════════════════════════════
-->
<div class="finance-layout min-h-screen text-white">
<!-- ── Garage Background Image (full-bleed, fixed) ── -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed" />
<!-- Dark overlay for readability -->
<div class="absolute inset-0 bg-black/30" />
</div>
<!-- ── Modular header — matches PrivateLayout exactly ── -->
<BaseHeader>
<template #left>
<HeaderLogo />
</template>
<template #center>
<span class="text-white font-bold text-sm tracking-wide">{{ financeTitle }}</span>
</template>
<template #right>
<LanguageSwitcher />
<HeaderProfile />
</template>
</BaseHeader>
<!-- ── Main content area — child routes render here ── -->
<main class="relative z-10">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
/*
* ── Imports ──────────────────────────────────────────────────────────
* PURPOSE: Reuses the exact same header Lego components as PrivateLayout
* to ensure pixel-perfect visual consistency.
* THOUGHT: No hamburger menu here — finance pages are focused views.
* HeaderLogo auto-links to /dashboard (see HeaderLogo.vue).
* The back button has been moved to each child view's local
* FleetView-style content header for proper alignment.
* The center slot dynamically shows the current page title
* based on the active route path.
*/
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import BaseHeader from '../components/layout/BaseHeader.vue'
import HeaderLogo from '../components/header/HeaderLogo.vue'
import HeaderProfile from '../components/header/HeaderProfile.vue'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
const route = useRoute()
const { t } = useI18n()
/**
* Dynamic page title for the finance section's top navbar center slot.
* Matches the active route path to determine which title to display.
*/
const financeTitle = computed(() => {
if (route.path === '/finance') return '💰 ' + t('menu.finance')
if (route.path.startsWith('/finance/wallets')) return '💰 ' + t('finance.wallet')
if (route.path.startsWith('/finance/packages')) return '📦 ' + t('subscription.title')
if (route.path.startsWith('/finance/transactions')) return '📋 ' + t('finance.transactionHistory')
return '💰 ' + t('menu.finance')
})
</script>

View File

@@ -84,6 +84,18 @@ const router = createRouter({
component: () => import('../views/ProfileView.vue'),
meta: { requiresAuth: true }
},
{
path: '/profile/stats',
name: 'profile-stats',
component: () => import('../views/ProfileStatsView.vue'),
meta: { requiresAuth: true }
},
{
path: '/profile/settings',
name: 'profile-settings',
component: () => import('../views/ProfileSettingsView.vue'),
meta: { requiresAuth: true }
},
{
path: '/verify',
name: 'verify',
@@ -139,6 +151,51 @@ const router = createRouter({
component: () => import('../views/admin/AdminServicesView.vue')
}
]
},
{
// ── Finance routes ─────────────────────────────────────────────────
// Parent layout provides the standard TopBar (Logo, Profile, Lang)
// and the garage-bg.png background. Child routes render their
// content in <router-view /> inside FinanceLayout.
path: '/finance',
component: () => import('../layouts/FinanceLayout.vue'),
meta: { requiresAuth: true },
children: [
{
// /finance → Main overview with 4 BaseCard tiles
path: '',
name: 'finance',
component: () => import('../views/FinanceMainView.vue')
},
{
// /finance/wallets → Wallet balance breakdown (4 pockets)
path: 'wallets',
name: 'finance-wallets',
component: () => import('../views/FinanceWalletsView.vue')
},
{
// /finance/packages → Active subscription / packages
path: 'packages',
name: 'finance-packages',
component: () => import('../views/FinancePackagesView.vue')
},
{
// /finance/transactions → Paginated transaction history
path: 'transactions',
name: 'finance-transactions',
component: () => import('../views/FinanceTransactionsView.vue')
}
]
},
{
// Legacy Wallet route (kept for backward compatibility)
// Standalone page with its own white background — NOT nested
// under FinanceLayout because it has a completely different
// visual style (white bg, custom back button).
path: '/finance/wallet',
name: 'wallet',
component: () => import('../views/WalletView.vue'),
meta: { requiresAuth: true }
}
]
})

View File

@@ -68,10 +68,8 @@
@open-card="openCard"
/>
<!-- Card 5: 🛡 My Profile & Trust (Profil & Trust Score) -->
<ProfileTrustCard
@open-card="openCard"
/>
<!-- Card 5: 💰 Pénzügy (Finance Overview) -->
<ProfileTrustCard />
</div>
<!-- Subscription Status & Ad Placement Row -->

View File

@@ -0,0 +1,266 @@
<template>
<!--
FinanceMainView /finance overview (rendered inside FinanceLayout)
PURPOSE: Main landing page for the finance section. Shows 4 separate
cards using the DashboardView bottom-aligned pattern.
CARDS:
1. 💰 Pénztárca /finance/wallets
- Live data: FinancialCard.vue pattern (totalCredits, 4 pockets)
2. 📦 Csomagok /finance/packages
- Live data: authStore.subscription_plan
3. 📋 Tranzakciók /finance/transactions
- Static placeholder
4. 🧾 Számlák (disabled, placeholder)
-->
<!-- MAIN CONTENT CONTAINER (hybrid: FleetView width + Dashboard bottom-alignment) -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
<!-- Back button only (title is in top navbar via FinanceLayout) -->
<div class="mb-8 flex items-center justify-end">
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
<!-- 4-card grid FleetView-style -->
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
>
<!--
Card 1: 💰 Pénztárca (Wallets)
Data binding copied from FinancialCard.vue (totalCredits,
purchasedCredits, serviceCoins, earnedCredits, voucherBalance)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Invisible overlay for click capture (MyVehiclesCard.vue pattern) -->
<div
class="absolute inset-0 z-10 cursor-pointer"
@click="goTo('/finance/wallets')"
/>
<!-- Header bar -->
<div class="h-12 bg-gradient-to-r from-amber-600 to-amber-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('finance.wallet') }}</span>
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-white/20 px-2 py-0.5 text-xs font-medium text-white">
{{ t('finance.live') }}
</span>
</div>
<!-- Content: loading / error / balance (copied from FinancialCard.vue) -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<!-- Loading State -->
<div
v-if="walletLoading"
class="flex-1 flex items-center justify-center"
>
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-sf-accent" />
</div>
<!-- Error State -->
<div
v-else-if="walletError"
class="flex-1 flex flex-col items-center justify-center text-center px-4"
>
<svg class="w-10 h-10 text-amber-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-xs text-slate-500">{{ walletError }}</p>
<button
@click.stop="financeStore.fetchWalletBalance()"
class="mt-2 text-xs text-sf-accent hover:underline"
>
{{ t('common.retry') }}
</button>
</div>
<!-- Balance Content (FinancialCard.vue pattern) -->
<div v-else class="flex-1 flex flex-col gap-3">
<!-- Total Credits (big number) -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
{{ t('finance.totalCredits') }}
</p>
<p class="text-3xl font-extrabold text-slate-800">
{{ formatCredits(financeStore.totalCredits) }}
</p>
</div>
<!-- Mini 2x2 breakdown (Purchased + Bonus / Earned + Voucher) -->
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
<p class="text-xs text-slate-500 font-medium uppercase tracking-wider">{{ t('finance.purchased') }}</p>
<p class="text-lg font-bold text-slate-800 mt-0.5">{{ formatCredits(financeStore.purchasedCredits) }}</p>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
<p class="text-xs text-slate-500 font-medium uppercase tracking-wider">{{ t('finance.bonus') }}</p>
<p class="text-lg font-bold text-emerald-600 mt-0.5">{{ formatCredits(financeStore.serviceCoins) }}</p>
</div>
</div>
<div class="flex items-center justify-between text-xs text-slate-400 px-1">
<span>{{ t('finance.earned') }}: <strong class="text-slate-600">{{ formatCredits(financeStore.earnedCredits) }}</strong></span>
<span>{{ t('finance.voucher') }}: <strong class="text-slate-600">{{ formatCredits(financeStore.voucherBalance) }}</strong></span>
</div>
</div>
</div>
</div>
<!--
Card 2: 📦 Csomagok (Packages)
Shows authStore.subscription_plan with active plan name.
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Invisible overlay for click capture -->
<div
class="absolute inset-0 z-10 cursor-pointer"
@click="goTo('/finance/packages')"
/>
<!-- Header bar -->
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.title') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Current plan card -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
{{ t('subscription.active') }}
</p>
<p class="text-2xl font-extrabold text-sky-600">
{{ subscriptionPlanName }}
</p>
</div>
<!-- Quick info row -->
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
<p class="text-xs text-slate-500">{{ t('subscription.subtitle') }}</p>
</div>
</div>
</div>
</div>
<!--
Card 3: 📋 Tranzakciók (Transactions)
Placeholder shows transaction history link.
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
>
<!-- Invisible overlay for click capture -->
<div
class="absolute inset-0 z-10 cursor-pointer"
@click="goTo('/finance/transactions')"
/>
<!-- Header bar -->
<div class="h-12 bg-gradient-to-r from-emerald-600 to-emerald-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">📋 {{ t('finance.transactionHistory') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 flex flex-col items-center justify-center text-center space-y-3">
<span class="text-5xl">📋</span>
<h3 class="text-lg font-bold text-slate-800">{{ t('finance.transactionHistory') }}</h3>
<p class="text-sm text-slate-500">{{ t('finance.analyticsTab') }}</p>
</div>
</div>
</div>
<!--
Card 4: 🧾 Számlák (Billing / Invoices)
Placeholder "Hamarosan" state, disabled routing.
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out opacity-80"
>
<!-- Header bar -->
<div class="h-12 bg-gradient-to-r from-violet-600 to-violet-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🧾 {{ t('costWizard.title') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 flex flex-col items-center justify-center text-center space-y-3">
<span class="text-5xl">🚧</span>
<h3 class="text-lg font-bold text-slate-800">{{ t('costWizard.title') }}</h3>
<p class="text-sm text-slate-500">{{ t('costWizard.step4Desc') }}</p>
</div>
</div>
<!-- Footer: disabled placeholder badge -->
<div class="shrink-0 px-4 pb-4">
<div class="w-full text-center text-xs text-slate-400">
🚧 {{ t('common.backToGarage') }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/*
* ── Imports ──────────────────────────────────────────────────────────
* PURPOSE: Standard Vue 3 Composition API.
* financeStore — live wallet balance data (Card 1).
* authStore — subscription plan name (Card 2).
* THOUGHT: Card 1 data binding copied from FinancialCard.vue lines 44-73.
* Wallet balance fetched on mount via financeStore.
* No flip mechanic — cards use direct @click routing.
*/
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useFinanceStore } from '../stores/finance'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const { t } = useI18n()
const financeStore = useFinanceStore()
const authStore = useAuthStore()
/**
* goTo — navigate to a finance sub-route using the router.
* Used by the invisible overlay on each card to capture clicks
* reliably (MyVehiclesCard.vue pattern at line 6-9).
*/
function goTo(path: string) {
router.push(path)
}
// ── Card 1: Wallet Loading/Error states (FinancialCard.vue pattern) ──
const walletLoading = computed(() => financeStore.isLoading)
const walletError = computed(() => financeStore.error)
// ── Card 2: Subscription plan name (authStore pattern) ────────────────
const subscriptionPlanName = computed(() => {
return authStore.user?.subscription_plan || '—'
})
// ── Helper: format credits ───────────────────────────────────────────
function formatCredits(value: number): string {
if (Number.isInteger(value)) {
return value.toLocaleString()
}
return value.toFixed(2)
}
// ── Fetch wallet balance on mount ────────────────────────────────────
onMounted(() => {
financeStore.fetchWalletBalance()
})
</script>

View File

@@ -0,0 +1,58 @@
<template>
<!--
FinancePackagesView /finance/packages (rendered inside FinanceLayout)
PURPOSE: Shows the user's active subscription/packages info.
──
DESIGN:
• Back button only (title is in top navbar via FinanceLayout)
• Dashboard-style bottom-aligned content area
═══════════════════════════════════════════════════════════════════
-->
<!-- ═══ MAIN CONTENT CONTAINER (hybrid: FleetView width + Dashboard bottom-alignment) ═══ -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
<!-- ── Back button only (title is in top navbar via FinanceLayout) ── -->
<div class="mb-8 flex items-center justify-end">
<button
@click="router.push('/finance')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('common.back') }}
</button>
</div>
<!-- Active package card -->
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm p-6">
<h2 class="text-lg font-bold text-slate-800 mb-4">{{ t('subscription.active') }}</h2>
<SubscriptionStatusWidget />
</div>
<!-- CTA to browse all plans -->
<div class="text-center">
<button
@click="router.push('/dashboard/subscription')"
class="px-6 py-3 bg-gradient-to-r from-sf-accent to-emerald-500 hover:from-sf-accent/90 hover:to-emerald-600 text-white rounded-2xl font-medium text-sm transition-all shadow-md hover:shadow-lg active:scale-[0.98]"
>
🚀 {{ t('subscription.selectPlan') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
/*
* ── Imports ──────────────────────────────────────────────────────────
* PURPOSE: SubscriptionStatusWidget handles its own data loading.
* Router is used for the CTA navigation and back button.
*/
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import SubscriptionStatusWidget from '../components/dashboard/SubscriptionStatusWidget.vue'
const router = useRouter()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,49 @@
<template>
<!--
FinanceTransactionsView /finance/transactions (rendered inside FinanceLayout)
PURPOSE: Shows the full paginated transaction history.
DESIGN:
Back button only (title is in top navbar via FinanceLayout)
Dashboard-style bottom-aligned content area
-->
<!-- MAIN CONTENT CONTAINER (hybrid: FleetView width + Dashboard bottom-alignment) -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
<!-- Back button only (title is in top navbar via FinanceLayout) -->
<div class="mb-8 flex items-center justify-end">
<button
@click="router.push('/finance')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('common.back') }}
</button>
</div>
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<TransactionHistory />
</div>
</div>
</template>
<script setup lang="ts">
/*
* ── Imports ──────────────────────────────────────────────────────────
* PURPOSE: TransactionHistory handles all state internally (loading,
* error, empty, pagination, filters).
* THOUGHT: The white card wrapper ensures visual consistency with the
* dashboard's styling when rendered on the garage-bg backdrop.
* Added useRouter for the back button.
*/
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import TransactionHistory from '../components/finance/TransactionHistory.vue'
const router = useRouter()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,52 @@
<template>
<!--
FinanceWalletsView /finance/wallets (rendered inside FinanceLayout)
PURPOSE: Shows the full wallet balance breakdown (4 pockets) using
the existing WalletBalanceCard component.
DESIGN:
Back button only (title is in top navbar via FinanceLayout)
Dashboard-style bottom-aligned content area
WalletBalanceCard rendered inside a white dashboard-style card
-->
<!-- MAIN CONTENT CONTAINER (hybrid: FleetView width + Dashboard bottom-alignment) -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
<!-- Back button only (title is in top navbar via FinanceLayout) -->
<div class="mb-8 flex items-center justify-end">
<button
@click="router.push('/finance')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('common.back') }}
</button>
</div>
<!-- Wallet Balance Overview (4 pockets) -->
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<WalletBalanceCard />
</div>
</div>
</template>
<script setup lang="ts">
/*
* ── Imports ──────────────────────────────────────────────────────────
* PURPOSE: Reuses WalletBalanceCard — handles data fetching internally.
* THOUGHT: The rounded-2xl border bg-white shadow-sm wrapper ensures
* the card matches the dashboard's visual style even without
* the layout's glassmorphism.
* Added useRouter for the back button.
*/
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import WalletBalanceCard from '../components/finance/WalletBalanceCard.vue'
const router = useRouter()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div class="relative min-h-screen text-white">
<!-- Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Header -->
<BaseHeader>
<template #left>
<HeaderLogo />
</template>
<template #center>
<span class="text-sm font-semibold text-white/70 tracking-wide">
{{ t('dashboard.settings') }}
</span>
</template>
<template #right>
<HeaderCompanySwitcher />
<HeaderProfile />
</template>
</BaseHeader>
<!-- Content -->
<div class="relative z-10 pt-20">
<div class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div class="rounded-2xl border border-white/10 bg-black/40 p-6 backdrop-blur-xl shadow-xl shadow-black/20">
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<h2 class="text-xl font-bold text-white">{{ t('dashboard.settings') }}</h2>
</div>
<!-- Placeholder for future settings content -->
<div class="rounded-xl border border-white/10 bg-white/5 p-6 text-center">
<p class="text-white/50 text-sm">{{ t('profile.settingsPlaceholder') }}</p>
</div>
<!-- Back button -->
<div class="mt-8 text-center">
<button
@click="router.push('/profile')"
class="inline-flex items-center gap-2 rounded-xl border border-white/[0.12] bg-white/[0.04] px-6 py-3 text-white/70 transition-all duration-200 hover:border-[#00E5A0]/40 hover:text-white hover:bg-white/[0.08] backdrop-blur-sm cursor-pointer"
>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
{{ t('common.back') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import BaseHeader from '../components/layout/BaseHeader.vue'
import HeaderLogo from '../components/header/HeaderLogo.vue'
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
import HeaderProfile from '../components/header/HeaderProfile.vue'
const router = useRouter()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,140 @@
<template>
<div class="relative min-h-screen text-white">
<!-- Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Header -->
<BaseHeader>
<template #left>
<HeaderLogo />
</template>
<template #center>
<span class="text-sm font-semibold text-white/70 tracking-wide">
{{ t('dashboard.trustScore') }}
</span>
</template>
<template #right>
<HeaderCompanySwitcher />
<HeaderProfile />
</template>
</BaseHeader>
<!-- Content -->
<div class="relative z-10 pt-20">
<div class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div class="rounded-2xl border border-white/10 bg-black/40 p-6 backdrop-blur-xl shadow-xl shadow-black/20">
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<h2 class="text-xl font-bold text-white">{{ t('dashboard.trustScore') }}</h2>
</div>
<!-- Trust Score Gauge -->
<div class="rounded-xl border border-white/10 bg-white/5 p-6 mb-6">
<div class="flex items-center justify-between mb-2">
<span class="text-lg font-bold text-white">{{ trustScoreLabel }}</span>
<span class="text-4xl font-extrabold" :class="trustScoreColor">{{ trustScorePercent }}%</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-slate-700">
<div
class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500"
:style="{ width: trustScorePercent + '%' }"
/>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
<p class="text-xs text-white/50 uppercase tracking-wider mb-1">{{ t('profile.completion') }}</p>
<p class="text-2xl font-bold text-[#00E5A0]">{{ profileCompletion }}%</p>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
<p class="text-xs text-white/50 uppercase tracking-wider mb-1">{{ t('header.myCompanies') }}</p>
<p class="text-2xl font-bold text-white">{{ organizationsCount }}</p>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
<p class="text-xs text-white/50 uppercase tracking-wider mb-1">{{ t('dashboard.totalVehicles') }}</p>
<p class="text-2xl font-bold text-white">{{ vehiclesCount }}</p>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
<p class="text-xs text-white/50 uppercase tracking-wider mb-1">{{ t('finance.rewards') }}</p>
<p class="text-2xl font-bold text-amber-500">{{ serviceCoins }}</p>
</div>
</div>
<!-- Back button -->
<div class="mt-8 text-center">
<button
@click="router.push('/dashboard')"
class="inline-flex items-center gap-2 rounded-xl border border-white/[0.12] bg-white/[0.04] px-6 py-3 text-white/70 transition-all duration-200 hover:border-[#00E5A0]/40 hover:text-white hover:bg-white/[0.08] backdrop-blur-sm cursor-pointer"
>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
{{ t('common.backToGarage') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle'
import { useFinanceStore } from '../stores/finance'
import BaseHeader from '../components/layout/BaseHeader.vue'
import HeaderLogo from '../components/header/HeaderLogo.vue'
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
import HeaderProfile from '../components/header/HeaderProfile.vue'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
const financeStore = useFinanceStore()
const trustScorePercent = computed(() => 75)
const trustScoreLabel = computed(() => {
const pct = trustScorePercent.value
if (pct >= 90) return 'A+'
if (pct >= 75) return 'A'
if (pct >= 60) return 'B'
if (pct >= 40) return 'C'
return 'D'
})
const trustScoreColor = computed(() => {
const pct = trustScorePercent.value
if (pct >= 75) return 'text-emerald-400'
if (pct >= 50) return 'text-amber-400'
return 'text-red-400'
})
const profileCompletion = computed(() => {
const p = authStore.user?.person
if (!p) return 0
let total = 0
let filled = 0
if (p.first_name) filled++; total++
if (p.last_name) filled++; total++
if (p.phone) filled++; total++
return Math.round((filled / total) * 100)
})
const organizationsCount = computed(() => authStore.myOrganizations.length)
const vehiclesCount = computed(() => vehicleStore.vehicles.length)
const serviceCoins = computed(() => {
const coins = financeStore.serviceCoins
if (Number.isInteger(coins)) return coins.toLocaleString()
return coins.toFixed(2)
})
</script>

View File

@@ -0,0 +1,136 @@
<template>
<div class="min-h-screen bg-slate-100">
<!-- Top Navigation Bar -->
<div class="sticky top-0 z-30 bg-white border-b border-slate-200 shadow-sm">
<div class="mx-auto max-w-5xl px-4 sm:px-6">
<div class="flex items-center justify-between h-16">
<!-- Back button -->
<button
@click="goBack"
class="flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-slate-800 transition-colors"
>
<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="M15 19l-7-7 7-7" />
</svg>
{{ t('common.back') }}
</button>
<!-- Title -->
<h1 class="text-lg font-bold text-slate-800">💰 {{ t('finance.walletDetails') }}</h1>
<!-- Spacer -->
<div class="w-20" />
</div>
</div>
</div>
<!-- Main Content -->
<div class="mx-auto max-w-5xl px-4 sm:px-6 py-8 space-y-6">
<!-- Card 1: Wallet Balance Overview (4 pockets) -->
<WalletBalanceCard
:highlight="highlightPocket"
@loaded="onBalanceLoaded"
/>
<!-- Card 2: Quick Actions -->
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm p-6">
<div class="flex gap-4">
<button
@click="handleTopUp"
class="flex-1 px-6 py-3 bg-gradient-to-r from-sf-accent to-emerald-500 hover:from-sf-accent/90 hover:to-emerald-600 text-white rounded-2xl font-medium text-sm transition-all shadow-md hover:shadow-lg active:scale-[0.98]"
>
{{ t('finance.topUp') }}
</button>
<button
disabled
class="flex-1 px-6 py-3 rounded-2xl font-medium text-sm bg-slate-200 text-slate-400 cursor-not-allowed opacity-50 shadow-md"
:title="t('finance.payoutComingSoon')"
>
💸 {{ t('finance.requestPayout') }}
</button>
</div>
</div>
<!-- Card 3: Transaction History -->
<TransactionHistory ref="historyRef" />
</div>
</div>
</template>
<script setup lang="ts">
/**
* WalletView.vue
*
* Dedicated Wallet / Finance page matching the /finance/wallet route.
* Reads the ?highlight= query parameter to visually emphasise
* the Service Coins pocket (or any other pocket) on page load.
*
* Components:
* - WalletBalanceCard — 4-pocket balance overview
* - TransactionHistory — paginated transaction list
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import WalletBalanceCard from '../components/finance/WalletBalanceCard.vue'
import TransactionHistory from '../components/finance/TransactionHistory.vue'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
// ── Child component refs ──
const historyRef = ref<InstanceType<typeof TransactionHistory> | null>(null)
// ── Read highlight query param ──
// Supported values: "service-coins", "earned", "purchased", "voucher"
const highlightPocket = computed<string | undefined>(() => {
const h = route.query.highlight
if (typeof h === 'string' && ['service-coins', 'earned', 'purchased', 'voucher'].includes(h)) {
return h
}
return undefined
})
// ── Navigation ──
function goBack() {
router.back()
}
function onBalanceLoaded() {
// After balance data is loaded, optionally trigger history refresh
if (historyRef.value) {
historyRef.value.fetchTransactions()
}
}
function handleTopUp() {
const toast = document.createElement('div')
toast.className =
'fixed top-6 right-6 z-[99999] bg-slate-800 text-white px-6 py-3 rounded-xl shadow-2xl text-sm font-medium animate-slide-in'
toast.textContent = '\uD83D\uDD14 Fizetési kapu hamarosan elérhet\u0151!'
document.body.appendChild(toast)
setTimeout(() => {
toast.classList.add('animate-slide-out')
setTimeout(() => toast.remove(), 300)
}, 3000)
}
</script>
<style scoped>
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
.animate-slide-in {
animation: slideIn 0.3s ease-out forwards;
}
.animate-slide-out {
animation: slideOut 0.3s ease-in forwards;
}
</style>

View File

@@ -524,12 +524,34 @@ function closeCostWizard() {
// ── Cost Entry Wizard (Edit) ──
function openEditWizard(cost: any) {
// Find the vehicle for this cost
const vehicleId = cost.asset_id || cost.vehicle_id
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
editCostData.value = cost
showEditWizard.value = true
/**
* Open the edit modal for a specific expense.
* P0 BUGFIX (2026-07-26): Fetches the enriched expense from the new
* GET /expenses/by-id/{id} endpoint instead of passing the raw list object.
* This ensures category_name, parent_category_id, service_provider_name,
* vendor_name, description, and mileage_at_cost are all resolved.
*/
async function openEditWizard(cost: any) {
const expenseId = cost.id
if (!expenseId) return
try {
const res = await api.get(`/expenses/by-id/${expenseId}`)
const enriched = res.data?.data || res.data
// Find the vehicle for this cost
const vehicleId = enriched.asset_id || cost.asset_id
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
editCostData.value = enriched
showEditWizard.value = true
} catch (err: any) {
console.error('[CostsView] Failed to fetch enriched expense:', err)
// Fallback: use the raw list object if the endpoint fails
const vehicleId = cost.asset_id || cost.vehicle_id
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
editCostData.value = cost
showEditWizard.value = true
}
}
function closeEditWizard() {

View File

@@ -0,0 +1,251 @@
# 🔧 Admin OOM Crash Fix & User Relations Repair — Code Mode Hand-off
**Author:** Architect Mode
**Date:** 2026-07-26
**Target Mode:** `code` (Fast Coder)
---
## 📊 Diagnosztikai Összefoglaló
### Issue 1: Admin Frontend OOM (`admin.servicefinder.hu` → 500)
**Jelenlegi állapot:**
- A [`sf_admin_frontend`](docker-compose.yml:281) konténer jelenleg FUT (742MiB RAM, 0.42%), az OOM korábban történt.
- A [`Dockerfile.dev`](frontend_admin/Dockerfile.dev:1) `node:20-slim`-et használ, a CMD `npm run dev` (Nuxt dev mode SSR + HMR).
- A [`docker-compose.yml`](docker-compose.yml:289) szekcióban **nincs** `NODE_OPTIONS` környezeti változó és **nincs** `deploy.resources.limits.memory` beállítva.
- A bot-scanner forgalom (`.env` probing) csak Vue Router warningokat generál, nem ez okozza az OOM-t.
**Root Cause:** A Nuxt 3 dev módban az SSR (Server-Side Rendering) + HMR (Hot Module Replacement) + a dashboard oldal API hívása (`/api/v1/admin/users/stats`) együtt képes kimeríteni a Node.js default heap limitjét (~2GB). A `node:20-slim` image-nek nincs explicit `--max-old-space-size` beállítása.
### Issue 2: User 86 Wallet Status & Missing User Relations
**User 86 (`zs.gyongyossy@gmail.com`) státusza:**
- Wallet: **RENDBEN**`identity.wallets` id=12, earned=0, purchased=0, service_coins=0, currency=HUF, org_id=45
- Subscription: **RENDBEN**`finance.user_subscriptions` id=9, tier_id=13 (`private_free_v1`), is_active=true
**6 aktív user-nek NINCS walletje és subscription-je:**
| ID | Email | Wallet | Subscription |
|----|-------|--------|-------------|
| **1** | `superadmin@profibot.hu` | ❌ MISSING | ❌ MISSING |
| 136 | `no_perm_1782770270_8c9970@test.com` | ❌ MISSING | ❌ MISSING |
| 138 | `no_perm_1782778366_243628@test.com` | ❌ MISSING | ❌ MISSING |
| 153 | `commission_test_gen2@test.com` | ❌ MISSING | ❌ MISSING |
| 154 | `commission_test_gen1@test.com` | ❌ MISSING | ❌ MISSING |
| 155 | `commission_test_buyer@test.com` | ❌ MISSING | ❌ MISSING |
**Végpont viselkedés:** [`get_wallet_summary`](backend/app/services/billing_engine.py:598) `ValueError`-t dob ha nincs wallet → a végpont 404-et ad. Ez helyes.
---
## 🛠️ Javítási Terv
### FIX-1: Admin Frontend Memory Limit
**Fájl:** [`docker-compose.yml`](docker-compose.yml:281-299)
**Módosítás — a `sf_admin_frontend` szekcióban:**
1. Az `environment` blokkhoz add hozzá:
```yaml
- NODE_OPTIONS=--max-old-space-size=4096
```
2. Adj hozzá egy `deploy` blokkot:
```yaml
deploy:
resources:
limits:
memory: 8G
```
**A módosított szekció:**
```yaml
sf_admin_frontend:
build:
context: ./frontend_admin
dockerfile: Dockerfile.dev
container_name: sf_admin_frontend
env_file: .env
ports:
- "8502:8502"
environment:
- NUXT_PORT=8502
- NUXT_HOST=0.0.0.0
- NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu
- NODE_OPTIONS=--max-old-space-size=4096 # ← NEW
volumes:
- ./frontend_admin:/app
- /app/node_modules
networks:
- sf_net
- shared_db_net
restart: unless-stopped
deploy: # ← NEW
resources:
limits:
memory: 8G
```
**Futtatás:**
```bash
docker compose up -d --force-recreate sf_admin_frontend
docker compose logs --tail=20 sf_admin_frontend
```
---
### FIX-2: User Relations Repair Script
**Fájl létrehozása:** [`backend/scripts/repair_user_relations.py`](backend/scripts/repair_user_relations.py)
**Cél:** Minden `is_active = true` user számára biztosítja:
1. Personal Organization (`fleet.organizations`) — ha még nincs
2. Wallet (`identity.wallets`) — ha még nincs
3. User Subscription (`finance.user_subscriptions`, tier=private_free_v1) — ha még nincs
**Adatbázis séma referencia:**
| Tábla | Séma | Kulcs mezők |
|-------|------|-------------|
| `wallets` | `identity` | id (serial PK), user_id (FK), earned_credits, purchased_credits, service_coins, currency, organization_id |
| `user_subscriptions` | `finance` | id (serial PK), user_id (FK), tier_id (FK→system.subscription_tiers), valid_from, is_active |
| `organizations` | `fleet` | id (serial PK), name, full_name, display_name, folder_slug, owner_id (FK), org_type, status, is_active, ... |
**Alapértelmezett értékek:**
- **Wallet:** `earned_credits=0`, `purchased_credits=0`, `service_coins=0`, `currency='HUF'`
- **Subscription:** `tier_id=13` (`private_free_v1`), `valid_from=now()`, `is_active=true`
- **Organization:** `name="{user.email}'s Garage"`, `org_type='individual'`, `status='active'`, `owner_id=user.id`
**Script pszeudokód:**
```python
#!/usr/bin/env python3
"""
User Relations Repair Script — Idempotens: csak hiányzó rekordokat hoz létre.
Futtatás: docker compose exec sf_api python3 /app/backend/scripts/repair_user_relations.py
"""
import asyncio, sys
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/backend')
from app.core.database import async_session_factory
from sqlalchemy import select
from app.models.identity import User, Wallet
from app.models.finance import UserSubscription
from app.models.fleet import Organization
from datetime import datetime, timezone
from decimal import Decimal
DEFAULT_TIER_ID = 13
DEFAULT_CURRENCY = "HUF"
async def ensure_organization(db, user):
result = await db.execute(select(Organization).where(Organization.owner_id == user.id))
org = result.scalar_one_or_none()
if org:
return org.id
folder_slug = f"user-{user.id}-{int(datetime.now(timezone.utc).timestamp())}"
org = Organization(
name=f"{user.email}'s Garage", full_name=f"{user.email}'s Personal Garage",
display_name=f"{user.email}'s Garage", folder_slug=folder_slug,
owner_id=user.id, org_type="individual", status="active",
is_active=True, is_deleted=False, subscription_plan="FREE",
base_asset_limit=5, purchased_extra_slots=0,
notification_settings={}, external_integration_config={},
is_verified=False, first_registered_at=datetime.now(timezone.utc),
current_lifecycle_started_at=datetime.now(timezone.utc),
lifecycle_index=1, is_anonymized=False, default_currency="HUF",
country_code="HU", language="hu", is_ownership_transferable=False,
visual_settings={}, is_affiliate_partner=False, aliases=[],
tags=[], settings={}, custom_features={},
)
db.add(org)
await db.flush()
return org.id
async def ensure_wallet(db, user, org_id):
result = await db.execute(select(Wallet).where(Wallet.user_id == user.id))
if result.scalar_one_or_none():
return False
wallet = Wallet(user_id=user.id, earned_credits=Decimal('0'),
purchased_credits=Decimal('0'), service_coins=Decimal('0'),
currency=DEFAULT_CURRENCY, organization_id=org_id)
db.add(wallet)
await db.flush()
return True
async def ensure_subscription(db, user):
result = await db.execute(select(UserSubscription).where(UserSubscription.user_id == user.id))
if result.scalar_one_or_none():
return False
sub = UserSubscription(user_id=user.id, tier_id=DEFAULT_TIER_ID,
valid_from=datetime.now(timezone.utc), is_active=True)
db.add(sub)
await db.flush()
return True
async def repair_all_users():
async with async_session_factory() as db:
result = await db.execute(select(User).where(User.is_active == True))
users = result.scalars().all()
stats = {"total": len(users), "wallets_created": 0, "subs_created": 0, "errors": []}
for user in users:
try:
org_id = await ensure_organization(db, user)
if await ensure_wallet(db, user, org_id):
stats["wallets_created"] += 1
if await ensure_subscription(db, user):
stats["subs_created"] += 1
except Exception as e:
stats["errors"].append(f"User {user.id}: {e}")
await db.commit()
print(f"=== REPAIR: total={stats['total']} wallets_new={stats['wallets_created']} subs_new={stats['subs_created']} errors={len(stats['errors'])}")
for e in stats["errors"]: print(f" ERR: {e}")
if __name__ == "__main__":
asyncio.run(repair_all_users())
```
**Futtatás:**
```bash
docker compose exec sf_api python3 /app/backend/scripts/repair_user_relations.py
```
**Ellenőrzés:**
```sql
SELECT u.id, u.email, w.id as wallet_id, s.id as sub_id
FROM identity.users u
LEFT JOIN identity.wallets w ON u.id = w.user_id
LEFT JOIN finance.user_subscriptions s ON u.id = s.user_id
WHERE u.id IN (1, 136, 138, 153, 154, 155);
```
---
### FIX-3 (OPTIONAL): get_wallet_summary Auto-Create
**Architect döntés:** **NEM módosítjuk.** A 404 helyes válasz. A repair script után a probléma megszűnik. A wallet auto-create a regisztrációs flow-ban garantálandó — külön feladat.
---
## 📋 Végrehajtási Sorrend (Code Mode)
| # | Feladat | Fájl | Parancs |
|---|---------|------|---------|
| 1 | `NODE_OPTIONS` + memory limit | [`docker-compose.yml`](docker-compose.yml:289) | Szerkesztés |
| 2 | Admin restart | — | `docker compose up -d --force-recreate sf_admin_frontend` |
| 3 | Repair script létrehozása | [`backend/scripts/repair_user_relations.py`](backend/scripts/repair_user_relations.py) | Fájl létrehozása |
| 4 | Repair script futtatása | — | `docker compose exec sf_api python3 /app/backend/scripts/repair_user_relations.py` |
| 5 | SQL ellenőrzés | — | MCP Postgres query |
| 6 | Böngésző ellenőrzés | — | `https://admin.servicefinder.hu/` |
---
## ⚠️ Kockázatok
1. **superadmin (ID=1):** Lehet hogy szándékosan nincs walletje. Ha nem kívánatos, skip-elni kell ID=1-et a scriptben.
2. **Organization folder_slug:** Egyedi kell legyen. A `user-{id}-{timestamp}` minta garantálja.
3. **Tier ID=13:** Ellenőrizve — létezik `system.subscription_tiers`-ben, `tier_level=0`.
4. **Docker restart:** ~30-60 másodperc downtime a Nuxt újraépítése miatt.
5. **A repair script IDEMPOTENS:** Többször futtatható, nem duplikál.

View File

@@ -0,0 +1,200 @@
# 🔧 Code Mode Hand-off Blueprint: 4-Bug Fix Bundle
**Created:** 2026-07-26 | **Mode:** Architect → Code
**Target Branch:** `main`
---
## 📊 Root Cause Analysis Summary
| # | Bug | Root Cause File | Severity |
|---|-----|----------------|----------|
| 1 | Logo navigation traps user in org scope | HeaderLogo.vue:33 | Medium |
| 2 | GET /wallet/balance → 500 for user 106 | billing_engine.py:625-633 | Critical |
| 3 | "705,000" shown for all users (N/A — no mock found) | N/A — see analysis below | Info |
| 4 | [intlify] Not found 'subscription.vehicles' | SubscriptionStatusWidget.vue:67,81 | Low |
---
## 🐛 Bug 1: Logo Navigation — Always Redirect to /dashboard
### File
`frontend_app/src/components/header/HeaderLogo.vue`
### Current Behavior (Lines 32-38)
```typescript
const targetRoute = computed(() => {
if (route.path.startsWith('/organization/')) {
const orgId = route.params.id
return orgId ? `/organization/${orgId}` : route.path
}
return '/dashboard'
})
```
When user is on `/organization/5/vehicles`, `route.path.startsWith('/organization/')` is `true`, so the logo navigates to `/organization/5` — the org root — NOT to `/dashboard`. This traps the user; there is no way to return to the private garage dashboard via the logo.
### Root Cause
The logo's `targetRoute` logic intentionally scopes the user to the org when on org pages, but this prevents escaping back to `/dashboard`.
### Fix
**Replace the entire `targetRoute` computed with:**
```typescript
const targetRoute = computed(() => '/dashboard')
```
**Rationale:** The `HeaderCompanySwitcher` component already provides organization context switching. The logo should always be a "global home" escape hatch. This follows the web standard: clicking the brand logo always returns to the root dashboard.
### Lines to Change
- **DELETE:** Lines 32-38
- **REPLACE WITH:** Single line `const targetRoute = computed(() => '/dashboard')`
### Verification
1. Log in as any user
2. Navigate to `/organization/:id/vehicles`
3. Click the `HeaderLogo` → must navigate to `/dashboard` (not stay on org pages)
---
## 🐛 Bug 2: Backend 500 Error — `float(None)` on Wallet Balance
### File
`backend/app/services/billing_engine.py``AtomicTransactionManager.get_wallet_summary()`
### Current Behavior (Lines 622-635)
```python
return {
"wallet_id": wallet.id,
"balances": {
"earned": float(wallet.earned_credits), # 💥 CRASHES if None
"purchased": float(wallet.purchased_credits), # 💥 CRASHES if None
"service_coins": float(wallet.service_coins), # 💥 CRASHES if None
"voucher": float(voucher_balance),
"total": float(
wallet.earned_credits + # 💥 CRASHES if None
wallet.purchased_credits +
wallet.service_coins +
voucher_balance
)
},
```
### Root Cause
The `Wallet` model defines credit columns as:
```python
earned_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
```
`server_default` only applies at the DB level on INSERT. If a wallet row was created with explicit `None` values, or if the DB column contains `NULL` (e.g., from a migration or manual SQL), then `wallet.earned_credits` will be Python `None`. Calling `float(None)` raises `TypeError`, which falls through to the generic `Exception` handler returning HTTP 500.
Also: if the wallet row does NOT exist at all, `get_wallet_summary()` raises `ValueError(f"Wallet not found for user_id={user_id}")` → which IS caught as 404. So the 500 specifically means: **wallet row EXISTS but has NULL credit columns**.
### Fix
**Option A (Defensive — Preferred): Wrap all `float()` calls with `or 0`**
Change lines 625-634 to:
```python
"earned": float(wallet.earned_credits or 0),
"purchased": float(wallet.purchased_credits or 0),
"service_coins": float(wallet.service_coins or 0),
"voucher": float(voucher_balance or 0),
"total": float(
(wallet.earned_credits or 0) +
(wallet.purchased_credits or 0) +
(wallet.service_coins or 0) +
(voucher_balance or 0)
)
```
**Option B (Root Fix — also recommended): Run a DB repair script to fix NULL columns**
```sql
UPDATE identity.wallets SET earned_credits = 0 WHERE earned_credits IS NULL;
UPDATE identity.wallets SET purchased_credits = 0 WHERE purchased_credits IS NULL;
UPDATE identity.wallets SET service_coins = 0 WHERE service_coins IS NULL;
```
**BOTH Option A and Option B should be applied.**
### Verification
1. Find NULL wallets: `SELECT id, user_id FROM identity.wallets WHERE earned_credits IS NULL OR purchased_credits IS NULL OR service_coins IS NULL;`
2. Apply both fixes
3. GET /api/v1/billing/wallet/balance with JWT for user 106 → must return 200
---
## 🐛 Bug 3: "705,000" Mock Data — NOT a Code Issue
### Finding
**No mock/hardcoded data was found in any frontend file.** Searched all .vue, .ts, and .json files for 705000 — zero results.
The finance store properly fetches from the API and has zero-fallback:
```typescript
const totalCredits = computed(() => balance.value?.total ?? 0)
```
The "705,000" value is from a real database record — likely the admin test user's wallet. User 106 sees an error because their wallet has NULL credits. Once Bug 2 is fixed, user 106 will see 0 (either no wallet → creates one, or NULL columns → returned as 0).
### Action
**No code change needed for Bug 3.** Fixing Bug 2 resolves the 500 error cascade.
---
## 🐛 Bug 4: Missing i18n Keys in SubscriptionStatusWidget
### File
`frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue`
### Missing Keys (Lines 52, 53, 67, 81)
- `subscription.vehicles` (Line 67, 81)
- `subscription.unlimited` (Line 81)
- `subscription.timeRemaining` (Line 52)
- `subscription.days` (Line 53)
None of these exist in `hu.ts` or `en.ts`.
### Fix
Add to BOTH `frontend_app/src/i18n/hu.ts` AND `frontend_app/src/i18n/en.ts` inside the `subscription` block, after `indefinite`:
**hu.ts — add:**
```typescript
vehicles: 'jármű',
unlimited: 'korlátlan',
timeRemaining: 'Hátralévő idő',
days: 'nap',
```
**en.ts — add:**
```typescript
vehicles: 'vehicles',
unlimited: 'unlimited',
timeRemaining: 'Time Remaining',
days: 'days',
```
---
## 📋 Execution Order (Code Mode)
1. **Bug 1** — HeaderLogo.vue: Change targetRoute to always return /dashboard
2. **Bug 4** — hu.ts + en.ts: Add 4 missing i18n keys
3. **Bug 2** — billing_engine.py: Add `or 0` to all float() conversions
4. **Bug 2 (DB)** — Run SQL to NULL→0 on identity.wallets credit columns
5. **Bug 3** — Verify fix, no code change needed
---
## 🔗 Files Summary
| File | Bug | Operation |
|------|-----|-----------|
| frontend_app/src/components/header/HeaderLogo.vue | #1 | Replace lines 32-38 |
| frontend_app/src/i18n/hu.ts | #4 | Add 4 keys after line 83 |
| frontend_app/src/i18n/en.ts | #4 | Add 4 keys after line 83 |
| backend/app/services/billing_engine.py | #2 | Modify lines 625-634 |
| identity.wallets (DB) | #2 | SQL UPDATE NULL→0 |
---
*Blueprint ready for Code Mode hand-off.*

View File

@@ -0,0 +1,307 @@
# 🔵 Finance Module — FleetView Layout Clone + Enum Fix Blueprint
**Date:** 2026-07-26
**Author:** Architect Mode
**Status:** Pending Code Mode Execution
**Priority:** MEDIUM — Layout fix and enum data repair
---
## STEP 1: THE WORKING LAYOUT — FleetView Analysis
**Route:** `/organization/:org_id/vehicles`
**Component:** [`FleetView.vue`](frontend_app/src/views/vehicles/FleetView.vue) wrapped in [`OrganizationLayout.vue`](frontend_app/src/layouts/OrganizationLayout.vue)
### FleetView.vue DOM Hierarchy (Key Elements)
```html
<div class="relative min-h-screen">
<!-- Fixed background -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Dark overlay -->
<div class="fixed inset-0 z-0 bg-slate-900/65 pointer-events-none"></div>
<!-- ═══ MAIN CONTENT CONTAINER ═══ -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Page header: flex justify-between -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">{{ title }}</h1>
<p class="mt-1 text-sm text-white/60">{{ subtitle }}</p>
</div>
<div class="flex items-center gap-3">
<!-- Action buttons + Back button -->
</div>
</div>
<!-- Content Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Cards -->
</div>
</div>
</div>
```
### OrganizationLayout.vue Role
[`OrganizationLayout.vue`](frontend_app/src/layouts/OrganizationLayout.vue) provides:
- `BaseHeader` with company name + hamburger menu
- `main class="relative z-10"``<router-view />`
- **NO background** — the child view (`FleetView`) provides its own
### FinanceLayout.vue Current State
[`FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue) provides:
- `BaseHeader` with `HeaderLogo` + `LanguageSwitcher` + `HeaderProfile`
- **ALREADY HAS background** — `fixed inset-0 z-0` + `bg-[url('@/assets/garage-bg.png')]` + dark overlay `bg-black/30`
- `main class="relative z-10"``<router-view />`
### Critical Difference: FinanceLayout vs OrganizationLayout
| Property | OrganizationLayout | FinanceLayout |
|----------|-------------------|---------------|
| Background | ❌ None (child provides) | ✅ Already provides `garage-bg.png` |
| Dark overlay | ❌ None | ✅ Already provides `bg-black/30` |
| Header | Company name + hamburger | Logo + lang switcher + profile |
| `main` class | `relative z-10` | `relative z-10` |
**Conclusion:** Since `FinanceLayout` already provides the background + overlay, the child views should NOT duplicate them. The fix is to make `FinanceMainView.vue` use the **same inner container pattern** as `FleetView` (line 12: `relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8`) but SKIP the background/overlay divs (which are in FinanceLayout).
### What's Currently Wrong with FinanceMainView
Current structure (after previous fixes):
```html
<!-- FinanceMainView.vue template: -->
<div class="flex flex-col justify-end min-h-[85vh] pb-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
<div class="mb-8 flex items-center justify-between"> ...header... </div>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"> ...cards... </div>
</div>
</div>
```
Problems:
1. **`justify-end min-h-[85vh]`** — This is the DashboardView pattern (bottom-aligned). FleetView uses `py-8` (top-aligned). The finance cards should be top-aligned with proper padding.
2. **`lg:grid-cols-5`** — Dashboard uses 5-column for its 5 cards. Finance has 4 cards. Should use whatever fits well.
3. No `relative` on outer wrapper — needed for z-index context.
---
## STEP 2: THE FIX — Clone FleetView Inner Structure
For [`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue):
```html
<template>
<div class="relative min-h-screen">
<!-- ═══ MAIN CONTENT CONTAINER (FleetView.vue line 12 clone) ═══ -->
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- ── Page header ── -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">💰 {{ t('menu.finance') }}</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
</div>
<!-- ── 4-Card Grid ── -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
...cards...
</div>
</div>
</div>
</template>
```
**Key changes:**
1. Outer wrapper: `<div class="relative min-h-screen">` (replaces `flex flex-col justify-end min-h-[85vh] pb-8`)
2. Content container: `<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">` — exact clone of FleetView:12
3. Header stays nested inside the `mx-auto` container
4. Grid: `lg:grid-cols-4` instead of `lg:grid-cols-5` (4 cards instead of 5)
5. NO background divs (FinanceLayout provides them)
### Same fix for other 3 finance views
[`FinanceWalletsView.vue`](frontend_app/src/views/FinanceWalletsView.vue), [`FinancePackagesView.vue`](frontend_app/src/views/FinancePackagesView.vue), [`FinanceTransactionsView.vue`](frontend_app/src/views/FinanceTransactionsView.vue) — each needs the same structural fix: remove `flex flex-col justify-start min-h-[85vh] py-8` wrapper and replace with `relative min-h-screen` + `relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8`.
---
## STEP 3: LEDGER_STATUS ENUM FIX
### 3A: The Enum Definition
[`audit.py`](backend/app/models/system/audit.py:70-75):
```python
class LedgerStatus(str, enum.Enum):
PENDING = "PENDING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
REFUNDED = "REFUNDED"
REFUND = "REFUND"
```
### 3B: The Seed Script (BROKEN)
[`seed_financial_ledger.py:20`](backend/scripts/seed_financial_ledger.py:20):
```python
DB_STATUSES = ["pending", "completed", "failed", "cancelled"]
```
Mapping to valid enum values:
| Seed value | Valid Enum | Reason |
|-----------|-----------|--------|
| `pending` → | `PENDING` | Case fix only |
| `completed` → | `SUCCESS` | Closest semantic match (completed payment = success) |
| `failed` → | `FAILED` | Case fix only |
| `cancelled` → | `REFUNDED` | Cancelled payments are typically refunded |
### 3C: Fix Plan
**Fix A — Seed script:**
```python
DB_STATUSES = ["PENDING", "SUCCESS", "FAILED", "REFUNDED"]
```
**Fix B — Database cleanup SQL:**
```sql
UPDATE audit.financial_ledger SET status = 'PENDING' WHERE status::text = 'pending';
UPDATE audit.financial_ledger SET status = 'SUCCESS' WHERE status::text = 'completed';
UPDATE audit.financial_ledger SET status = 'FAILED' WHERE status::text = 'failed';
UPDATE audit.financial_ledger SET status = 'REFUNDED' WHERE status::text = 'cancelled';
```
---
## STEP 4: CODE MODE HAND-OFF — Task List
### Task 1: Restructure FinanceMainView layout (FleetView clone)
**File:** [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue)
Replace the entire `<template>` section. The current outer structure:
```html
<div class="flex flex-col justify-end min-h-[85vh] pb-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
<!-- header inside container (already correct from previous fix) -->
<!-- grid: lg:grid-cols-5 -->
</div>
</div>
```
Replace with:
```html
<div class="relative min-h-screen">
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- header (same as current, already correct) -->
<!-- grid: change lg:grid-cols-5 → lg:grid-cols-4 -->
</div>
</div>
```
1. Change the outermost `<div>`: remove `flex flex-col justify-end min-h-[85vh] pb-8` → add `relative min-h-screen`
2. Change the content container: `mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full``relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8` (add `relative z-10` and `py-8`, remove `w-full`)
3. Change all card grid: `lg:grid-cols-5``lg:grid-cols-4` (4 cards, not 5)
4. Also change Card 1: remove `lg:col-span-2` since on a 4-col grid each card gets 1 column
### Task 2: Restructure FinanceWalletsView layout
**File:** [`frontend_app/src/views/FinanceWalletsView.vue`](frontend_app/src/views/FinanceWalletsView.vue)
Same structural change:
- Outer: `flex flex-col justify-start min-h-[85vh] py-8``relative min-h-screen`
- Inner content: `mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full``relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8`
### Task 3: Restructure FinancePackagesView layout
**File:** [`frontend_app/src/views/FinancePackagesView.vue`](frontend_app/src/views/FinancePackagesView.vue)
Same structural change as Task 2.
### Task 4: Restructure FinanceTransactionsView layout
**File:** [`frontend_app/src/views/FinanceTransactionsView.vue`](frontend_app/src/views/FinanceTransactionsView.vue)
Same structural change as Task 2.
### Task 5: Fix seed_financial_ledger.py ledger_status enum (3A)
**File:** [`backend/scripts/seed_financial_ledger.py:20`](backend/scripts/seed_financial_ledger.py:20)
Change:
```python
DB_STATUSES = ["pending", "completed", "failed", "cancelled"]
```
To:
```python
DB_STATUSES = ["PENDING", "SUCCESS", "FAILED", "REFUNDED"]
```
### Task 6: Fix existing DB ledger_status bad data (3B)
Run SQL:
```bash
docker compose exec shared-postgres psql -U service_finder_app -d service_finder -c "
UPDATE audit.financial_ledger SET status = 'PENDING' WHERE status::text = 'pending';
UPDATE audit.financial_ledger SET status = 'SUCCESS' WHERE status::text = 'completed';
UPDATE audit.financial_ledger SET status = 'FAILED' WHERE status::text = 'failed';
UPDATE audit.financial_ledger SET status = 'REFUNDED' WHERE status::text = 'cancelled';
SELECT status::text, COUNT(*) as cnt FROM audit.financial_ledger GROUP BY status ORDER BY status;
"
```
### Task 7: Full verification
1. **Frontend build:**
```bash
docker compose exec sf_public_frontend npm run build 2>&1 | tail -5
```
2. **Backend wallet balance API (no enum crash):**
```bash
docker compose exec sf_api python3 -c "
import requests, json
r = requests.post('http://localhost:8000/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'})
token = r.json()['access_token']
resp = requests.get('http://localhost:8000/api/v1/billing/wallet/balance', headers={'Authorization': f'Bearer {token}'})
print(f'Status: {resp.status_code}')
if resp.status_code == 200:
d = resp.json()
print(f'total={d.get(\"total\")} earned={d.get(\"earned\")}')
else:
print(f'ERROR: {resp.text}')
"
```
---
## 📋 Summary
| # | Task | File(s) |
|---|------|---------|
| 1 | Clone FleetView layout to FinanceMainView | [`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue) |
| 2 | Clone FleetView layout to FinanceWalletsView | [`FinanceWalletsView.vue`](frontend_app/src/views/FinanceWalletsView.vue) |
| 3 | Clone FleetView layout to FinancePackagesView | [`FinancePackagesView.vue`](frontend_app/src/views/FinancePackagesView.vue) |
| 4 | Clone FleetView layout to FinanceTransactionsView | [`FinanceTransactionsView.vue`](frontend_app/src/views/FinanceTransactionsView.vue) |
| 5 | Fix seed enum: `ledger_status` | [`seed_financial_ledger.py:20`](backend/scripts/seed_financial_ledger.py:20) |
| 6 | Fix DB: normalize `ledger_status` values | PostgreSQL `audit.financial_ledger` |
| 7 | Verify: build + API | Build + `/billing/wallet/balance` |
## ⚠️ DO NOT MODIFY
- [`FleetView.vue`](frontend_app/src/views/vehicles/FleetView.vue) — reference pattern
- [`OrganizationLayout.vue`](frontend_app/src/layouts/OrganizationLayout.vue) — reference pattern
- [`FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue) — already correct (provides background)
- [`BaseHeader.vue`](frontend_app/src/components/layout/BaseHeader.vue) — working
- [`router/index.ts`](frontend_app/src/router/index.ts) — correct

View File

@@ -0,0 +1,287 @@
# 🔵 Finance Module — Mock Data & Back Button Blueprint
**Date:** 2026-07-26
**Author:** Architect Mode
**Status:** Pending Code Mode Execution
**Priority:** MEDIUM — UI polish, no app-breaking bugs
---
## STEP 1: MOCK DATA ANALYSIS
### 1.1 Dashboard Financial Card (ProfileTrustCard.vue)
**File:** [`frontend_app/src/components/dashboard/ProfileTrustCard.vue`](frontend_app/src/components/dashboard/ProfileTrustCard.vue)
| Field | Current Value | Source | Is Mock? |
|-------|--------------|--------|----------|
| `financeStore.totalCredits` (line 40) | Live API data | `financeStore.fetchWalletBalance()``GET /billing/wallet/balance` | **❌ LIVE** — already fetches real data |
| `financeStore.serviceCoins` (line 43) | Live API data | Same store | **❌ LIVE** |
| `financeStore.totalCredits` stat tile (line 51) | Live API data | Same store | **❌ LIVE** |
| Transaction count (line 57) | `—` hardcoded | Static placeholder | **✅ MOCK** |
| Package info (line 63) | `•` hardcoded | Static placeholder | **✅ MOCK** |
| `financeStore.voucherBalance` (line 69) | Live API data | Same store | **❌ LIVE** |
**Conclusion:** The wallet balance numbers (`totalCredits`, `serviceCoins`, `voucherBalance`) ARE live and fetched from the API. However, two stat tiles show static placeholders that render identically for all users.
### 1.2 Gamification Card (GamificationCard.vue)
**File:** [`frontend_app/src/components/dashboard/GamificationCard.vue`](frontend_app/src/components/dashboard/GamificationCard.vue)
| Field | Current Value | Source | Is Mock? |
|-------|--------------|--------|----------|
| Score (line 18) | `2,450` hardcoded | Literal number in template | **✅ MOCK** — same for all users |
| Badges (lines 56-61) | 4 hardcoded entries | Static array in script | **✅ MOCK** — same for all users |
**Evidence:**
- [`GamificationCard.vue`](frontend_app/src/components/dashboard/GamificationCard.vue:18): `<p class="text-3xl font-extrabold text-emerald-600">2,450</p>` — literal HTML
- [`GamificationCard.vue`](frontend_app/src/components/dashboard/GamificationCard.vue:56-61): `const badges = ref([{ icon: '🌱', label: 'Eco Driver' }, ...])` — never updated from API
**Fix plan:**
- Import a gamification/Pinía store (check if one exists) to fetch real user score
- If no gamification store exists yet, create a computed that reads from `authStore` or call an API endpoint
- Replace hardcoded badges with data fetched from a gamification endpoint
### 1.3 FinanceMainView Cards
**File:** [`frontend_app/src/components/../views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue)
**Card 1 (Wallet):** Uses `financeStore.totalCredits`, `financeStore.purchasedCredits`, `financeStore.serviceCoins`, `financeStore.earnedCredits`, `financeStore.voucherBalance` — all live data from `financeStore.fetchWalletBalance()`
**Card 2 (Packages):** Uses `authStore.user?.subscription_plan` — live data ✅
**Card 3 (Transactions):** Static placeholder with emoji — by design (placeholder page) ✅
**Card 4 (Invoices):** Static placeholder with 🚧 — by design ("Hamarosan") ✅
---
## STEP 2: BACK BUTTON LAYOUT ANALYSIS
### 2.1 The Gold Standard: FleetView.vue Header
**File:** [`frontend_app/src/views/vehicles/FleetView.vue`](frontend_app/src/views/vehicles/FleetView.vue:13-57)
The FleetView header pattern:
```html
<!-- Page header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">{{ t('garage.title') }}</h1>
<p class="mt-1 text-sm text-white/60">{{ t('garage.subtitle', { count: ... }) }}</p>
</div>
<div class="flex items-center gap-3">
<!-- Action buttons on the right -->
<button ...>Add Vehicle</button>
<button @click="router.push('/dashboard')" class="...bg-white/10...">
<svg>...</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
</div>
```
**Key structural elements:**
1. Container: `class="mb-8 flex items-center justify-between"`
2. Left side: `<div>` with `<h1>` title + optional `<p>` subtitle
3. Right side: `<div class="flex items-center gap-3">` with action buttons
4. Back button: `bg-white/10 px-4 py-2 text-sm text-white/80 hover:bg-white/20 hover:text-white` + left-arrow SVG
### 2.2 Current Finance Views: Teleport Anti-Pattern
All 4 finance views currently use `<Teleport to="#header-teleport-target">` to inject a page title into the global header:
- [`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue:38-40): `<Teleport v-if="teleportReady" to="#header-teleport-target">`
- [`FinanceWalletsView.vue`](frontend_app/src/views/FinanceWalletsView.vue:19-21)
- [`FinancePackagesView.vue`](frontend_app/src/views/FinancePackagesView.vue:17-19)
- [`FinanceTransactionsView.vue`](frontend_app/src/views/FinanceTransactionsView.vue:15-17)
All 4 need the same structural change.
### 2.3 The Fix: FleetView Pattern Applied to Finance
For each finance view:
1. **REMOVE** the entire `<Teleport ...>` block
2. **REMOVE** `teleportReady`, `onMounted`, `onBeforeUnmount` boilerplate
3. **ADD** a local content header matching FleetView:
```html
<!-- Page header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">💰 {{ t('menu.finance') }}</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
</div>
```
4. **PRESERVE** the existing card grid / content area below the header
---
## STEP 3: CODE MODE HAND-OFF — Explicit Task List
Execute in order. Do NOT modify FleetView.vue, PrivateLayout.vue, BaseHeader.vue, or router/index.ts.
### Task A: Fix Mock Data in GamificationCard.vue
**File:** [`frontend_app/src/components/dashboard/GamificationCard.vue`](frontend_app/src/components/dashboard/GamificationCard.vue)
1. **Check if a gamification store exists:**
```bash
ls frontend_app/src/stores/gamification* 2>/dev/null || echo "No gamification store"
```
If a store exists, import and use it. If not, create a simple onMounted fetch to a gamification endpoint.
2. **Replace hardcoded score (line 18):**
- Change `<p class="text-3xl font-extrabold text-emerald-600">2,450</p>`
- To: `<p class="text-3xl font-extrabold text-emerald-600">{{ userScore }}</p>`
- Add script logic: `const userScore = computed(...)` linked to real data source
3. **Replace hardcoded badges (lines 56-61):**
- Change the static `badges` array to a computed or ref that fetches from API
- Keep the same HTML template rendering — just change data source
### Task B: Fix Mock Data in ProfileTrustCard.vue
**File:** [`frontend_app/src/components/dashboard/ProfileTrustCard.vue`](frontend_app/src/components/dashboard/ProfileTrustCard.vue)
1. **Fix transaction count (line 57):**
- Change `<p class="text-lg font-bold text-slate-800">—</p>`
- To: `<p class="text-lg font-bold text-slate-800">{{ transactionCount }}</p>`
- Add: `const transactionCount = computed(() => 0)` — placeholder for now, will wire to real API later
2. **Fix package info (line 63):**
- Change `<p class="text-lg font-bold text-slate-800">•</p>`
- To: `<p class="text-lg font-bold text-slate-800">{{ authStore.user?.subscription_plan || '—' }}</p>`
- Add: `import { useAuthStore } from '../../stores/auth'` and `const authStore = useAuthStore()`
### Task C: Remove Teleport & Add Local Header — FinanceMainView.vue
**File:** [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue)
1. **DELETE lines 38-40** — the entire Teleport block:
```html
<Teleport v-if="teleportReady" to="#header-teleport-target">
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('menu.finance') }}</span>
</Teleport>
```
2. **ADD local content header** immediately after the Teleport block removal (before line 42's `<div class="flex flex-col...">`):
```html
<!-- ── Page header (FleetView.vue pattern) ── -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">💰 {{ t('menu.finance') }}</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
</div>
```
3. **Clean up script:** Remove `teleportReady`, `ref`, and lifecycle hooks that are no longer needed:
- Line 237: Change `import { computed, ref, onMounted, onBeforeUnmount } from 'vue'` to `import { computed, onMounted } from 'vue'`
- Line 243: Delete `const teleportReady = ref(false)`
- Line 278: Delete `teleportReady.value = true`
- Lines 282-284: Delete `onBeforeUnmount` block
### Task D: Remove Teleport & Add Local Header — FinanceWalletsView.vue
**File:** [`frontend_app/src/views/FinanceWalletsView.vue`](frontend_app/src/views/FinanceWalletsView.vue)
1. **DELETE lines 19-21** — the Teleport block
2. **ADD** local content header (after line 21 deletion, before line 23's `<div class="flex flex-col...">`):
```html
<!-- ── Page header (FleetView.vue pattern) ── -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">💰 {{ t('finance.wallet') }}</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="$router.push('/finance')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('common.back') }}
</button>
</div>
</div>
```
(Note: back button goes to `/finance` for sub-pages)
3. **Clean up script:** Change imports: remove `ref, onMounted, onBeforeUnmount`, remove `teleportReady` and lifecycle hooks.
- Also add: `import { useRouter } from 'vue-router'` and `const router = useRouter()` for the back button (or use `$router` in template)
### Task E: Remove Teleport & Add Local Header — FinancePackagesView.vue
**File:** [`frontend_app/src/views/FinancePackagesView.vue`](frontend_app/src/views/FinancePackagesView.vue)
1. **DELETE lines 17-19** — the Teleport block
2. **ADD** local content header with title "📦 {{ t('subscription.title') }}" and back button to `/finance`
3. **Clean up script:** Same as Task D — remove teleport boilerplate, add router import if not present
### Task F: Remove Teleport & Add Local Header — FinanceTransactionsView.vue
**File:** [`frontend_app/src/views/FinanceTransactionsView.vue`](frontend_app/src/views/FinanceTransactionsView.vue)
1. **DELETE lines 15-17** — the Teleport block
2. **ADD** local content header with title "📋 {{ t('finance.transactionHistory') }}" and back button to `/finance`
3. **Clean up script:** Same as Task D — remove teleport boilerplate, add router import if not present
### Task G: Verify
1. **Frontend build check:**
```bash
docker compose exec sf_public_frontend npm run build 2>&1 | tail -5
```
Must show `✓ built in X.XXs` with no errors.
2. **Backend imports:**
```bash
docker compose exec sf_api python3 -c "from app.main import app; print('OK')"
```
---
## 📋 Summary Table
| Task | File | Action |
|------|------|--------|
| A | [`GamificationCard.vue`](frontend_app/src/components/dashboard/GamificationCard.vue) | Replace `2,450` with live score, replace hardcoded badges |
| B | [`ProfileTrustCard.vue`](frontend_app/src/components/dashboard/ProfileTrustCard.vue) | Replace `` and `` with live/computed values |
| C | [`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue) | Remove Teleport, add FleetView-style local header + back button |
| D | [`FinanceWalletsView.vue`](frontend_app/src/views/FinanceWalletsView.vue) | Remove Teleport, add local header + back button to `/finance` |
| E | [`FinancePackagesView.vue`](frontend_app/src/views/FinancePackagesView.vue) | Remove Teleport, add local header + back button to `/finance` |
| F | [`FinanceTransactionsView.vue`](frontend_app/src/views/FinanceTransactionsView.vue) | Remove Teleport, add local header + back button to `/finance` |
| G | Build + verify | `npm run build`, backend imports check |
## ⚠️ DO NOT MODIFY
- [`FleetView.vue`](frontend_app/src/views/vehicles/FleetView.vue) — reference pattern only
- [`FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue) — layout is correct
- [`BaseHeader.vue`](frontend_app/src/components/layout/BaseHeader.vue) — working header
- [`router/index.ts`](frontend_app/src/router/index.ts) — correct route config
- [`DashboardView.vue`](frontend_app/src/views/DashboardView.vue) — working dashboard

View File

@@ -0,0 +1,257 @@
# 🔴 Finance Module — Corrective Blueprint (4 Failures)
**Date:** 2026-07-26
**Author:** Architect Mode
**Status:** Pending Code Mode Execution
**Priority:** HIGH — Visual misalignment, duplicate back button, mock data, backend enum crash
---
## STEP 1: FAILURE ANALYSIS
### FAILURE 1: Wrong Dashboard Card — Mock Data Remains
**Finding:** The dashboard at [`DashboardView.vue`](frontend_app/src/views/DashboardView.vue:72) uses [`ProfileTrustCard`](frontend_app/src/components/dashboard/ProfileTrustCard.vue) (Card 5). This IS the correct card and it WAS correctly modified by the previous blueprint — it now fetches live `financeStore` data and shows `subscriptionPlanName` from `authStore`.
However, [`FinancialCard.vue`](frontend_app/src/components/dashboard/FinancialCard.vue) is a **separate, unused** component that also fetches live wallet data and has a "Top Up" button. It is NOT imported or rendered anywhere on the dashboard. It appears to be a legacy/standalone variant.
**The actual source of "mock data" on the dashboard is the `transactionCount` computed (always 0) and the `subscriptionPlanName` which shows `—` if the user has no subscription plan set.**
**Fix plan:**
- The `ProfileTrustCard` correctly fetches live `financeStore` data already.
- The `GamificationCard` at [`GamificationCard.vue`](frontend_app/src/components/dashboard/GamificationCard.vue:18) was changed from hardcoded `2,450` to `computed(() => 0)` — specifically requested by the previous blueprint as a "placeholder ready to wire up."
- If the user sees mock data, it's the gamification score (0) and badges (empty array). These ARE mock/placeholder by design until a gamification store is created.
**No additional changes needed for the dashboard card itself.**
### FAILURE 2: Back Button Still in Top Global Navbar
**Root Cause:** The previous blueprint removed `<Teleport>` from all 4 child views BUT did NOT remove `<HeaderBackButton>` from [`FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue:42-45).
**Evidence:**
```html
<!-- FinanceLayout.vue:42-45 -->
<HeaderBackButton
:to="backTarget"
:label="t('common.back')"
/>
```
This `HeaderBackButton` is rendered in the `#left` slot of `BaseHeader`, which is the global sticky top navbar. Removing the Teleport only removed the page title — the back button in the layout persists.
**Fix:** Delete lines 42-45 from [`FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue). Also remove the unused `backTarget` computed (lines 95-100) and clean up the `useRoute` import if no longer needed.
### FAILURE 3: Title Misalignment — Header Outside Content Container
**Root Cause:** The newly added local header in [`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue:39-55) is placed OUTSIDE the main content container.
**Evidence:**
- Header at line 40: `<div class="mb-8 flex items-center justify-between">`**no max-w, no px, no mx-auto**
- Content container at line 59: `<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">`
- The FleetView pattern at [`FleetView.vue`](frontend_app/src/views/vehicles/FleetView.vue:12) wraps EVERYTHING in `class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"` — including the header.
**Fix:** Wrap the local header in the same responsive container, OR add matching `mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full` classes to the header div. The cleanest approach: restructure so header + cards share the same container.
Apply the same fix to all 4 finance views (FinanceWalletsView, FinancePackagesView, FinanceTransactionsView).
### FAILURE 4: Backend Enum Error — 'credit' is not among defined enum values
**Root Cause:** The [`seed_financial_ledger.py`](backend/scripts/seed_financial_ledger.py:18) seed script inserts lowercase `"credit"` and `"debit"` strings into the PostgreSQL `audit.ledger_entry_type` enum column, but the enum definition at [`audit.py`](backend/app/models/system/audit.py:58-60) only allows `"CREDIT"` and `"DEBIT"` (UPPERCASE).
**Evidence:**
- [`audit.py:58-60`](backend/app/models/system/audit.py:58-60):
```python
class LedgerEntryType(str, enum.Enum):
DEBIT = "DEBIT"
CREDIT = "CREDIT"
```
- [`seed_financial_ledger.py:18`](backend/scripts/seed_financial_ledger.py:18):
```python
DB_ENTRY_TYPES = ["credit", "debit"] # ❌ lowercase!
```
- The seed script uses `psycopg2` (sync) with explicit `::audit.ledger_entry_type` cast at line 105 — but psycopg2 may allow case-insensitive casting or the data was inserted before the enum was strict.
- When the frontend calls `GET /billing/wallet/balance`, SQLAlchemy reads all `FinancialLedger` rows and tries to map `"credit"` to the enum — this crashes with the error seen in the screenshot.
**Fix (TWO parts):**
**Part A — Fix the seed script:**
- [`seed_financial_ledger.py:18`](backend/scripts/seed_financial_ledger.py:18): Change `["credit", "debit"]` → `["CREDIT", "DEBIT"]`
**Part B — Fix existing bad data in the database:**
Run SQL to normalize all lowercase entries:
```sql
UPDATE audit.financial_ledger SET entry_type = 'CREDIT' WHERE entry_type::text = 'credit';
UPDATE audit.financial_ledger SET entry_type = 'DEBIT' WHERE entry_type::text = 'debit';
```
Also check and fix wallet_type and status similarly.
---
## STEP 2: CODE MODE HAND-OFF — Explicit Task List
Execute in order. Do NOT modify FleetView.vue, PrivateLayout.vue, BaseHeader.vue, HeaderBackButton.vue, or router/index.ts.
### Task 1: Remove HeaderBackButton from FinanceLayout.vue (FAILURE 2)
**File:** [`frontend_app/src/layouts/FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue)
1. **DELETE lines 37-46** — the entire `HeaderBackButton` block and its comment:
```html
<!--
Dynamic back button:
- On /finance exactly → back to /dashboard
- On any sub-route (/finance/wallets, etc.) → back to /finance
-->
<HeaderBackButton
:to="backTarget"
:label="t('common.back')"
/>
```
2. **DELETE the `backTarget` computed** (lines 90-100):
```ts
const backTarget = computed(() => {
if (route.path === '/finance') {
return '/dashboard'
}
return '/finance'
})
```
3. **Clean up imports:** Remove `import { computed } from 'vue'` (line 78) and `import { useRoute } from 'vue-router'` (line 79) if they're no longer used. Also remove `const route = useRoute()` (line 87). Remove `import HeaderBackButton from '../components/header/HeaderBackButton.vue'` (line 84).
### Task 2: Fix Header Alignment in FinanceMainView.vue (FAILURE 3)
**File:** [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue)
1. **DELETE lines 39-55** (the standalone header div)
2. **INSERT the header INSIDE the content container**: Move it to be the first child of line 59's `<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">`:
```html
<!-- ── Bottom-aligned card grid — matches DashboardView.vue exactly ── -->
<div class="flex flex-col justify-end min-h-[85vh] pb-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
<!-- ── Page header (FleetView.vue pattern) ── -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">💰 {{ t('menu.finance') }}</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
</div>
<!-- 4-card grid — identical to DashboardView grid -->
<div class="grid ...">
...
</div>
</div>
</div>
```
### Task 3: Fix Header Alignment in FinanceWalletsView.vue (FAILURE 3)
**File:** [`frontend_app/src/views/FinanceWalletsView.vue`](frontend_app/src/views/FinanceWalletsView.vue)
Same pattern as Task 2: Move the header inside the `mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full` container. The current structure:
```
<div class="mb-8 flex items-center justify-between"> ...header... </div>
<div class="flex flex-col justify-start min-h-[85vh] py-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
...content...
</div>
</div>
```
Should become:
```
<div class="flex flex-col justify-start min-h-[85vh] py-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
<div class="mb-8 flex items-center justify-between"> ...header... </div>
...content...
</div>
</div>
```
### Task 4: Fix Header Alignment in FinancePackagesView.vue (FAILURE 3)
**File:** [`frontend_app/src/views/FinancePackagesView.vue`](frontend_app/src/views/FinancePackagesView.vue)
Same pattern as Task 3 — move header inside the container.
### Task 5: Fix Header Alignment in FinanceTransactionsView.vue (FAILURE 3)
**File:** [`frontend_app/src/views/FinanceTransactionsView.vue`](frontend_app/src/views/FinanceTransactionsView.vue)
Same pattern as Task 3 — move header inside the container.
### Task 6: Fix Backend Enum Error — Seed Script (FAILURE 4A)
**File:** [`backend/scripts/seed_financial_ledger.py`](backend/scripts/seed_financial_ledger.py)
1. **Line 18:** Change:
```python
DB_ENTRY_TYPES = ["credit", "debit"]
```
To:
```python
DB_ENTRY_TYPES = ["CREDIT", "DEBIT"]
```
### Task 7: Fix Backend Enum Error — Existing Database Data (FAILURE 4B)
**Run these SQL commands** to normalize any existing lowercase entries:
```bash
docker compose exec shared-postgres psql -U service_finder_app -d service_finder -c "
UPDATE audit.financial_ledger SET entry_type = 'CREDIT' WHERE entry_type::text = 'credit';
UPDATE audit.financial_ledger SET entry_type = 'DEBIT' WHERE entry_type::text = 'debit';
-- Also check wallet_type
SELECT DISTINCT entry_type::text, wallet_type::text, status::text FROM audit.financial_ledger;
"
```
### Task 8: Verify
1. **Frontend build:**
```bash
docker compose exec sf_public_frontend npm run build 2>&1 | tail -5
```
2. **Backend wallet endpoint:**
```bash
docker compose exec sf_api python3 -c "
import requests
r = requests.post('http://localhost:8000/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'})
token = r.json()['access_token']
resp = requests.get('http://localhost:8000/api/v1/billing/wallet/balance', headers={'Authorization': f'Bearer {token}'})
print(f'Status: {resp.status_code}')
print(resp.json())
"
```
---
## 📋 Summary Table
| # | Failure | Root Cause | File(s) to Fix |
|---|---------|-----------|-----------------|
| 1 | Mock data on dashboard | `GamificationCard` score is placeholder 0; `transactionCount` is placeholder 0 — by design from previous blueprint, pending gamification/transaction stores | None needed now (wiring to stores is future work) |
| 2 | Back button still in top menu | [`FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue:42-45) still has `<HeaderBackButton>` in `#left` slot | [`FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue) |
| 3 | Title misaligned to edge | Header is outside `mx-auto max-w-7xl px-4...` container | All 4 finance views |
| 4 | Backend enum crash: `'credit' is not among defined enum values` | [`seed_financial_ledger.py:18`](backend/scripts/seed_financial_ledger.py:18) uses lowercase `"credit"/"debit"` but enum only accepts `"CREDIT"/"DEBIT"` | [`seed_financial_ledger.py`](backend/scripts/seed_financial_ledger.py) + DB cleanup SQL |
## ⚠️ DO NOT MODIFY
- [`FleetView.vue`](frontend_app/src/views/vehicles/FleetView.vue) — reference only
- [`PrivateLayout.vue`](frontend_app/src/layouts/PrivateLayout.vue) — working
- [`BaseHeader.vue`](frontend_app/src/components/layout/BaseHeader.vue) — working
- [`HeaderBackButton.vue`](frontend_app/src/components/header/HeaderBackButton.vue) — working component, just needs to not be used in FinanceLayout
- [`router/index.ts`](frontend_app/src/router/index.ts) — correct
- [`DashboardView.vue`](frontend_app/src/views/DashboardView.vue) — working

View File

@@ -0,0 +1,213 @@
# 🔴 Finance Module Recovery Blueprint — Root Cause Analysis & Recovery Plan
**Date:** 2026-07-26
**Author:** Architect Mode
**Status:** Pending Code Mode Execution
**Priority:** CRITICAL — Multiple systemic bugs across Frontend & Backend
---
## STEP 1: ARCHITECTURAL COMPARISON — Dashboard vs. Finance
### 1.1 Layout Wrapper Comparison
| Aspect | DashboardView (Working) | FinanceMainView (Broken) |
|--------|-------------------------|--------------------------|
| **Parent Layout** | PrivateLayout — provides BaseHeader with hamburger menu | FinanceLayout — provides same BaseHeader but with HeaderBackButton |
| **Background** | Self-contained: `fixed inset-0` + `bg-[url('@/assets/garage-bg.png')]` in the component itself | Duplicated in layout: same `fixed inset-0` background in FinanceLayout |
| **Header Teleport** | NO Teleport usage — DashboardView owns its own header via the background div | USES Teleport: FinanceMainView teleports `<span>` into `#header-teleport-target` |
| **Router Nesting** | Dash is a child of PrivateLayout | Finance is a child of FinanceLayout |
### 1.2 Card Component Comparison
| Aspect | Working Dashboard Cards | Broken Finance Cards |
|--------|------------------------|---------------------|
| **Root Element** | Single `<div>` with CSS classes directly on the card (e.g., MyVehiclesCard.vue:2-4) | Same pattern: plain `<div>` with identical CSS ✅ |
| **Click Handler** | Invisible overlay `<div>` with `@click` (e.g., MyVehiclesCard.vue:6-9) | Same pattern: invisible overlay with `@click` router push ✅ |
| **Data Binding** | Uses Pinia stores (financeStore, vehicleStore, authStore) | Same Pinia stores used ✅ |
| **Component Import** | Imports card components from dashboard/ | Standalone views — no card components imported, HTML inline |
### 1.3 Key Architectural Difference
**The only structural difference is the Teleport usage.** The working `DashboardView` uses `<Teleport to="body">` ONLY for its Flip Modal overlay, which is always available in the DOM. The broken `FinanceMainView` uses `<Teleport to="#header-teleport-target">` — a target that is conditionally rendered inside `BaseHeader`, which itself is nested inside `FinanceLayout`.
---
## STEP 2: ROOT CAUSE ANALYSIS OF 4 KNOWN BUGS
### 🔴 BUG 1: Teleport Freeze — `Failed to locate Teleport target with selector "#header-teleport-target"`
**Root Cause:** Vue Router lifecycle timing mismatch between Teleport mount/unmount and DOM availability.
**Evidence:**
- `FinanceMainView.vue:38-40` uses `<Teleport to="#header-teleport-target">` to inject a page title
- The target `#header-teleport-target` is defined in `BaseHeader.vue:15`
- `BaseHeader` is rendered inside `FinanceLayout.vue:35`
**Failure scenario:**
1. User navigates FROM `/finance` TO `/dashboard`
2. Vue Router starts unmounting the `/finance` route tree: FinanceMainView → FinanceLayout → BaseHeader
3. FinanceMainView tries to unmount its Teleported `<span>` from `#header-teleport-target`
4. BUT BaseHeader (which contains the target) has already been destroyed or is in the process of being destroyed
5. Vue throws: `Failed to locate Teleport target with selector "#header-teleport-target"`
6. The app freezes because the Teleport cleanup throws an unhandled error that halts the transition
**The same issue can happen on MOUNT:** if FinanceMainView renders before BaseHeader finishes mounting, the Teleport target doesn't exist yet.
**Contrast with DashboardView:** The working `DashboardView:101` teleports to `"body"` — which **always** exists in the DOM, regardless of component lifecycle.
### 🔴 BUG 2: Component Resolution Error — `Failed to resolve component: TechDataTab`
**Root Cause:** Missing import statement in `VehicleDetailModal.vue`.
**Evidence:**
- `VehicleDetailModal.vue:284` uses `<TechDataTab />` in its template
- The `<script setup>` section (lines 892-903) imports: `VehiclePlateBadge`, `ProviderAutocomplete`, `ProviderQuickAddModal` — but **NOT** `TechDataTab`
- `VehicleDetailsView.vue:112` correctly imports `TechDataTab from '../../components/vehicles/tabs/TechDataTab.vue'`
- This is a copy-paste bug: the `<TechDataTab />` usage was copied from VehicleDetailsView but the import was not
**Failure scenario:**
1. User opens VehicleDetailModal and clicks the "Műszaki adatok" tab
2. Vue tries to render `<TechDataTab />` but cannot resolve the component
3. Vue emits warning: `[Vue warn]: Failed to resolve component: TechDataTab`
4. The tab shows blank/empty content
**Note:** This bug is NOT caused by the finance module changes — it's a pre-existing bug in VehicleDetailModal.vue.
### 🟡 BUG 3: Fragment/Emit Warning — `Extraneous non-emits event listeners (deleted)`
**Root Cause:** `DashboardView.vue:169` listens for `@deleted` but the current `VehicleFormModal.vue` does not declare or emit `deleted`.
**Evidence:**
- `DashboardView.vue:164-170`: `<VehicleFormModal @deleted="onVehicleDeleted" />`
- `VehicleFormModal.vue:1778-1782`:
```ts
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved'): void
// NO "deleted" event declared!
}>()
```
**Failure scenario:**
1. Vue 3 sees `@deleted` on the component but no matching emit declaration
2. Vue tries to fall through and treat it as a native DOM event on the root element
3. Since VehicleFormModal's root is `<Teleport>` (a fragment-like component), Vue cannot attach native DOM listeners
4. Vue warns: `Extraneous non-emits event listeners (deleted) were passed to component`
### 🔴 BUG 4: Backend API Crash — HTTP 500 on `GET /api/v1/providers/categories`
**Root Cause:** The backend endpoint fails with an unhandled exception during ExpertiseTag query or ExpertiseCategoryOut serialization.
**Evidence:**
- `providers.py:149-183`: The `/categories` endpoint queries ExpertiseTag and constructs ExpertiseCategoryOut objects
- The endpoint wraps everything in try/except and returns HTTP 500 on any exception
- Git status shows modified schema files — possible schema mismatch
**Required diagnostic:** Run `docker compose logs --tail 100 sf_api` after reproducing the 500 error.
---
## STEP 3: THE RECOVERY BLUEPRINT
### Phase 1: Stabilize the Frontend
#### Fix 1.1: Teleport Target Lifecycle (BUG 1)
- **File:** `frontend_app/src/views/FinanceMainView.vue`
- **Solution:** Wrap Teleport in a v-if guard tied to onMounted/onBeforeUnmount lifecycle:
```html
<Teleport v-if="teleportReady" to="#header-teleport-target">
```
- **Also apply to:** FinanceWalletsView, FinancePackagesView, FinanceTransactionsView
#### Fix 1.2: Missing TechDataTab Import (BUG 2)
- **File:** `frontend_app/src/components/vehicle/VehicleDetailModal.vue`
- **Action:** Add `import TechDataTab from '../vehicles/tabs/TechDataTab.vue'` after line 903
#### Fix 1.3: Fragment Emit Warning (BUG 3)
- **File:** `frontend_app/src/components/dashboard/VehicleFormModal.vue`
- **Action:** Add `(e: 'deleted'): void` to defineEmits and emit on deletion
### Phase 2: Diagnose & Fix the Backend
#### Fix 2.1: Backend 500 on providers/categories (BUG 4)
- Restart sf_api container and capture live logs
- Trigger the endpoint and analyze the Python traceback
- Fix based on actual error (model/schema/DB issue)
---
## STEP 4: CODE MODE HAND-OFF INSTRUCTIONS
### Task 1: Fix Teleport Lifecycle (BUG 1) — CRITICAL
1. Open: `frontend_app/src/views/FinanceMainView.vue`
2. Add `ref` and `onBeforeUnmount` to vue imports (line 237)
3. Add: `const teleportReady = ref(false)`
4. In onMounted, add: `teleportReady.value = true`
5. Add: `onBeforeUnmount(() => { teleportReady.value = false })`
6. Change line 38: `<Teleport v-if="teleportReady" to="#header-teleport-target">`
7. Apply same pattern to: `FinanceWalletsView.vue`, `FinancePackagesView.vue`, `FinanceTransactionsView.vue`
### Task 2: Fix Missing TechDataTab Import (BUG 2) — HIGH
1. Open: `frontend_app/src/components/vehicle/VehicleDetailModal.vue`
2. After line 903, add:
```ts
import TechDataTab from '../vehicles/tabs/TechDataTab.vue'
```
### Task 3: Fix Fragment Emit Warning (BUG 3) — MEDIUM
1. Open: `frontend_app/src/components/dashboard/VehicleFormModal.vue`
2. At line 1778-1782, add `(e: 'deleted'): void` to defineEmits
3. Find delete/archive handler, add `emit('deleted')` after successful operation
### Task 4: Diagnose Backend 500 (BUG 4) — HIGH
1. Run: `docker compose restart sf_api`
2. Run diagnostic query:
```bash
docker compose exec sf_api python3 -c "
import asyncio
from app.database import async_session
from sqlalchemy import select, text
from app.models.data.expertise_tag import ExpertiseTag
async def main():
async with async_session() as db:
stmt = select(ExpertiseTag).order_by(ExpertiseTag.level, ExpertiseTag.id).limit(10)
result = await db.execute(stmt)
tags = result.scalars().all()
print(f'Query OK: {len(tags)} tags')
asyncio.run(main())
"
```
3. Based on output, fix providers.py or the ExpertiseTag model
### Task 5: Verify All Fixes — MANDATORY
1. Frontend build: `cd frontend_app && npm run build 2>&1 | tail -20`
2. Backend imports: `docker compose exec sf_api python3 -c "from app.main import app; print('OK')"`
3. Navigate `/finance` → all 4 cards → `/dashboard` — verify no freeze
4. VehicleDetailModal → "Műszaki adatok" tab — verify renders
---
## 📋 Summary Table
| # | Bug | Severity | Root Cause | File(s) to Fix |
|---|-----|----------|------------|-----------------|
| 1 | Teleport Freeze | CRITICAL | Teleport target lifecycle mismatch | FinanceMainView.vue:38 + 3 sub-views |
| 2 | TechDataTab not found | HIGH | Missing import | VehicleDetailModal.vue:284 |
| 3 | Fragment emit warning | MEDIUM | Missing emit declaration | VehicleFormModal.vue:1778 |
| 4 | Backend 500 error | HIGH | Unhandled exception in /providers/categories | providers.py:149-183 (needs live diagnosis) |
## ⚠️ DO NOT MODIFY
The following files are working correctly and MUST NOT be changed:
- `DashboardView.vue` — working dashboard
- `PrivateLayout.vue` — working layout
- `BaseHeader.vue` — working header
- `FinancialCard.vue` — working card
- `MyVehiclesCard.vue` — working card
- `router/index.ts` — correct route configuration

View File

@@ -0,0 +1,219 @@
# 🔵 Finance Module — Navbar Title & Vertical Alignment Fix Blueprint
**Date:** 2026-07-26
**Author:** Architect Mode
**Status:** Pending Code Mode Execution
**Priority:** MEDIUM — UX polish
---
## STEP 1: LOGO NAVIGATION ANALYSIS
**File:** [`HeaderLogo.vue`](frontend_app/src/components/header/HeaderLogo.vue)
**Finding:** The logo ALREADY navigates to `/dashboard` correctly (line 37). The [`targetRoute`](frontend_app/src/components/header/HeaderLogo.vue:32-38) computed property checks if the user is on an `/organization/` page (routes to org root), otherwise routes to `/dashboard`.
**Conclusion:** Logo navigation is already correct. If it doesn't work on the Garage page, the issue is likely a z-index/CSS `pointer-events` conflict with the dark overlay in FleetView. But the `router-link` itself is properly configured.
**No code changes needed for the logo.**
---
## STEP 2: TOP NAVBAR TITLE RESTORATION
**The Problem:** Previous blueprints removed ALL `<Teleport to="#header-teleport-target">` usage from finance views. This removed BOTH the back button AND the page title. We need the title back in the navbar center, but WITHOUT the back button.
**The Solution (Cleanest approach):** Inject the title directly from [`FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue) using the `#center` slot of `BaseHeader`. The layout component already knows which route is active via `useRoute()`. It can compute a dynamic title for the center slot.
**Current FinanceLayout `#center` slot:**
```html
<template #center>
<!-- Teleport target for child route page titles -->
</template>
```
**Fix:** Replace with:
```html
<template #center>
<span class="text-white font-bold text-sm tracking-wide">{{ financeTitle }}</span>
</template>
```
**Add computed in script:**
```ts
const financeTitle = computed(() => {
if (route.path === '/finance') return '💰 ' + t('menu.finance')
if (route.path.startsWith('/finance/wallets')) return '💰 ' + t('finance.wallet')
if (route.path.startsWith('/finance/packages')) return '📦 ' + t('subscription.title')
if (route.path.startsWith('/finance/transactions')) return '📋 ' + t('finance.transactionHistory')
return '💰 ' + t('menu.finance')
})
```
This puts the title in the navbar center WITHOUT any Teleport from child views. No Teleport lifecycle issues. No back button duplication. The title appears in the exact same spot as the original Teleported title did.
**Also:** Remove the duplicate `<h1>` titles from all 4 finance views' local content headers since the title is now in the navbar. Keep the back button in the local header (right-aligned).
---
## STEP 3: VERTICAL ALIGNMENT FIX — Push Cards to Bottom
**The Problem:** The FleetView clone used `py-8` (top-aligned), but the user wants cards at the bottom of the screen (like the Dashboard).
**Dashboard pattern (from [`DashboardView.vue`](frontend_app/src/views/DashboardView.vue:39)):**
```html
<div class="flex flex-col justify-end min-h-[85vh] pb-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
<!-- cards -->
</div>
</div>
```
**Current FinanceMainView (after FleetView clone):**
```html
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- header + cards -->
</div>
```
**Fix — Hybrid approach (FleetView container + Dashboard bottom-alignment):**
Each finance view's outer structure should become:
```html
<div class="relative min-h-screen">
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
<!-- back button row (right-aligned, no title) -->
<div class="mb-8 flex items-center justify-end">
<button @click="router.push('/dashboard')" ...>{{ t('garage.backToDashboard') }}</button>
</div>
<!-- cards grid -->
</div>
</div>
```
Key points:
- Outer `relative min-h-screen` stays (for z-index context)
- Inner container gets BOTH `mx-auto max-w-7xl...` (responsive width) AND `flex flex-col justify-end min-h-[85vh] pb-8` (bottom alignment)
- Header simplified to just a right-aligned back button (no title — title is in navbar)
- Same pattern applied to all 4 finance views
---
## STEP 4: CODE MODE HAND-OFF — Explicit Task List
### Task 1: Add dynamic title to FinanceLayout navbar center
**File:** [`frontend_app/src/layouts/FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue)
1. **Replace the `#center` slot** (currently empty with just a comment):
```html
<template #center>
<span class="text-white font-bold text-sm tracking-wide">{{ financeTitle }}</span>
</template>
```
2. **Add `computed` import** (if not present — it was removed in previous cleanup, so add back):
```ts
import { computed } from 'vue'
```
Add to existing `vue` import line alongside `useI18n`.
3. **Add `useRoute` import:**
```ts
import { useRoute } from 'vue-router'
```
(Was removed in previous cleanup — add back)
4. **Add route and computed after `const { t } = useI18n()`:**
```ts
const route = useRoute()
const financeTitle = computed(() => {
if (route.path === '/finance') return '💰 ' + t('menu.finance')
if (route.path.startsWith('/finance/wallets')) return '💰 ' + t('finance.wallet')
if (route.path.startsWith('/finance/packages')) return '📦 ' + t('subscription.title')
if (route.path.startsWith('/finance/transactions')) return '📋 ' + t('finance.transactionHistory')
return '💰 ' + t('menu.finance')
})
```
### Task 2: Fix vertical alignment + simplify header in FinanceMainView
**File:** [`frontend_app/src/views/FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue)
**Change the outer container structure (lines 39-40):**
FROM:
```html
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
```
TO:
```html
<div class="relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8">
```
**Simplify the header (lines 41-57):** Remove the left-side `<h1>` title (it's now in the navbar). Keep only the right-aligned back button:
```html
<!-- ── Back button (title in top navbar) ── -->
<div class="mb-8 flex items-center justify-end">
<button
@click="router.push('/dashboard')"
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ t('garage.backToDashboard') }}
</button>
</div>
```
### Task 3: Fix vertical alignment + simplify header in FinanceWalletsView
**File:** [`frontend_app/src/views/FinanceWalletsView.vue`](frontend_app/src/views/FinanceWalletsView.vue)
1. Change outer container to `relative z-10 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 flex flex-col justify-end min-h-[85vh] pb-8`
2. Simplify header to right-aligned back button only (same pattern as Task 2), pointing to `/finance`
### Task 4: Fix vertical alignment + simplify header in FinancePackagesView
**File:** [`frontend_app/src/views/FinancePackagesView.vue`](frontend_app/src/views/FinancePackagesView.vue)
Same as Task 3 — right-aligned back button to `/finance`, no title.
### Task 5: Fix vertical alignment + simplify header in FinanceTransactionsView
**File:** [`frontend_app/src/views/FinanceTransactionsView.vue`](frontend_app/src/views/FinanceTransactionsView.vue)
Same as Task 3 — right-aligned back button to `/finance`, no title.
### Task 6: Build verification
```bash
docker compose exec sf_public_frontend npm run build 2>&1 | tail -5
```
Must show `✓ built in X.XXs` with no errors.
---
## 📋 Summary
| # | Fix | File(s) |
|---|-----|---------|
| 1 | Title in top navbar via FinanceLayout center slot | [`FinanceLayout.vue`](frontend_app/src/layouts/FinanceLayout.vue) |
| 2 | Bottom-aligned cards + simplified header | [`FinanceMainView.vue`](frontend_app/src/views/FinanceMainView.vue) |
| 3 | Bottom-aligned cards + simplified header | [`FinanceWalletsView.vue`](frontend_app/src/views/FinanceWalletsView.vue) |
| 4 | Bottom-aligned cards + simplified header | [`FinancePackagesView.vue`](frontend_app/src/views/FinancePackagesView.vue) |
| 5 | Bottom-aligned cards + simplified header | [`FinanceTransactionsView.vue`](frontend_app/src/views/FinanceTransactionsView.vue) |
| 6 | Build verification | `npm run build` |
## ⚠️ DO NOT MODIFY
- [`HeaderLogo.vue`](frontend_app/src/components/header/HeaderLogo.vue) — already correct
- [`BaseHeader.vue`](frontend_app/src/components/layout/BaseHeader.vue) — working
- [`FleetView.vue`](frontend_app/src/views/vehicles/FleetView.vue) — reference only
- [`PrivateLayout.vue`](frontend_app/src/layouts/PrivateLayout.vue) — working
- [`router/index.ts`](frontend_app/src/router/index.ts) — correct

View File

@@ -0,0 +1,156 @@
# Logic Spec: Fix 401 Unauthorized on Admin Ledger Endpoint
**Date:** 2026-07-25
**Severity:** P0 (Blocker — admin users cannot access Financial Ledger page)
**Related Issue:** Frontend redirects to login screen on `/finance/ledger`
---
## 1. Root Cause Analysis
### 1.1 Authentication Architecture
The FastAPI backend uses `OAuth2PasswordBearer` ([`deps.py:27-29`](backend/app/api/deps.py:27)) which extracts the JWT token **exclusively** from the `Authorization: Bearer <token>` HTTP header. It does **NOT** read tokens from cookies.
```python
# backend/app/api/deps.py:27-29
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
```
The auth dependency chain for the ledger endpoint:
```
GET /admin/finance/ledger
→ RequirePermission("finance:view") [deps.py:283]
→ get_current_active_user [deps.py:94]
→ get_current_user [deps.py:56]
→ get_current_token_payload [deps.py:31]
→ reusable_oauth2 (OAuth2PasswordBearer) [deps.py:27]
→ extracts token from Authorization: Bearer header
```
### 1.2 The Bug
The [`ledger.vue:348`](frontend_admin/pages/finance/ledger.vue:348) uses **native `fetch()`**:
```typescript
const res = await fetch(`/api/v1/admin/finance/ledger?${params.toString()}`, {
credentials: 'include',
})
```
This sends cookies (including `access_token` cookie) but does **NOT** send the `Authorization: Bearer` header. The `OAuth2PasswordBearer` dependency never sees a token → 401 Unauthorized.
### 1.3 Why Other Admin Pages Work
Every other admin page uses `$fetch()` (Nuxt's ofetch wrapper) with a `getHeaders()` helper that explicitly adds the `Authorization` header:
```typescript
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
return tokenCookie.value
? { Authorization: `Bearer ${tokenCookie.value}` }
: {}
}
```
Examples:
- [`commission-rules.vue:694`](frontend_admin/pages/finance/commission-rules.vue:694) — has `getHeaders()`
- [`providers/index.vue:288`](frontend_admin/pages/providers/index.vue:288) — has `getHeaders()`
- [`gamification/ledger.vue:244`](frontend_admin/pages/gamification/ledger.vue:244) — has `getHeaders()`
The `ledger.vue` page **omitted** both `$fetch` and the `getHeaders()` helper — it's the only admin page with this pattern.
### 1.4 What the 401 Interceptor Does
The [`api.ts` plugin](frontend_admin/plugins/api.ts:5) overrides `globalThis.fetch` to detect 401 responses. When a 401 is detected on any non-login URL, it calls `authStore.logout()` and redirects to `/login`. This is why the user sees a redirect — the interceptor fires, clears the token, and forces a redirect.
**The interceptor is working correctly.** The problem is the request never had a token attached in the first place.
---
## 2. Fix Plan
### 2.1 Primary Fix: Frontend — [`ledger.vue`](frontend_admin/pages/finance/ledger.vue)
**File to modify:** `frontend_admin/pages/finance/ledger.vue`
**Changes:**
1. Add `getHeaders()` helper function in the `<script setup>` block:
```typescript
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
return tokenCookie.value
? { Authorization: `Bearer ${tokenCookie.value}` }
: {}
}
```
2. Add `useCookie` import at the top:
```typescript
import { useCookie } from '#app'
```
3. Replace the native `fetch()` call with `$fetch()` and pass headers:
```typescript
// BEFORE (broken):
const res = await fetch(`/api/v1/admin/finance/ledger?${params.toString()}`, {
credentials: 'include',
})
// AFTER (fixed):
const data: LedgerResponse = await $fetch('/api/v1/admin/finance/ledger', {
headers: getHeaders(),
params: params, // $fetch handles query params natively
})
```
4. Adjust error handling — `$fetch` throws on non-2xx responses.
### 2.2 No Backend Changes Required
The [`finance_admin.py`](backend/app/api/v1/endpoints/finance_admin.py:81-96) security dependencies are **correct**:
```python
@router.get("/ledger", response_model=FinancialLedgerListResponse)
async def list_financial_ledger(
...
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("finance:view")),
):
```
This is consistent with working endpoints like:
- [`admin_commission.py:72`](backend/app/api/v1/endpoints/admin_commission.py:72): `current_user: User = Depends(get_current_staff)`
- [`finance_admin.py:30-31`](backend/app/api/v1/endpoints/finance_admin.py:30): `list_issuers` uses identical pattern and works
The 401 is purely a **frontend auth header omission** — the backend auth chain and permission check are correct.
---
## 3. Verification Steps
1. After applying the fix, the frontend dev server should be running.
2. Log in as admin (`admin@profibot.hu` / `Admin123!`).
3. Navigate to `/finance/ledger`.
4. Verify:
- No 401 error in browser DevTools Network tab
- `Authorization: Bearer <token>` header is present in the request
- Ledger data loads with pagination, filtering
- No redirect to `/login`
---
## 4. Impact Assessment
| Component | Change | Risk |
|-----------|--------|------|
| `ledger.vue` | Add `getHeaders()`, `useCookie` import, switch to `$fetch` | Low — exact same pattern used by 20+ other admin pages |
| Backend | None | N/A |
| Other pages | None | N/A |