felhasználói felületre pü
This commit is contained in:
572
.roo/history.md
572
.roo/history.md
@@ -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
|
**Dátum:** 2026-07-25
|
||||||
**Scope:** Backend + Frontend (SimpleFuelModal, ProviderAutocomplete, ProviderQuickAddModal)
|
|
||||||
|
|
||||||
### 🔧 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
|
**Dátum:** 2026-07-26
|
||||||
**Scope:** Backend (expenses.py, provider_service.py) + Frontend (SimpleFuelModal.vue)
|
|
||||||
|
|
||||||
### 🔍 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 |
|
### Verifikáció
|
||||||
|--------|-------|----------|
|
- `POST /api/v1/expenses/` → **HTTP 201** ✅
|
||||||
| `verified_org` | `fleet.organizations` | Organization.id ✅ |
|
- Biztosítási költség sikeresen létrejött
|
||||||
| `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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P0 BUGFIX - Odometer Source of Truth & Sync (Gitea #406)
|
## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413)
|
||||||
|
|
||||||
**Dátum:** 2026-07-10
|
**Dátum:** 2026-07-25
|
||||||
**Scope:** Frontend (SimpleFuelModal.vue)
|
|
||||||
|
|
||||||
### 🔧 Módosított fájlok
|
### Probléma
|
||||||
|
...
|
||||||
|
|
||||||
#### 1. Frontend: [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:228)
|
### Documentation
|
||||||
- **Fix:** `fetchLastOdometer()` most már a `/assets/vehicles/{asset_id}` végpontot hívja a `/expenses/{asset_id}` helyett.
|
`docs/p0_expense_500_import_bugfix_2026-07-26.md`
|
||||||
- **Logika:** A `Vehicle.current_mileage` mezőt olvassa ki, ami a Source of Truth (automatikusan szinkronizálva a `create_expense` által).
|
**Gitea Issue:** #421 (closed)
|
||||||
- **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
|
|
||||||
|
|||||||
@@ -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(social.router, prefix="/social", tags=["Social & Leaderboard"])
|
||||||
api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"])
|
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="/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(analytics.router, prefix="/analytics", tags=["Analytics"])
|
||||||
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
|
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
|
||||||
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])
|
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ from app.models.vehicle.asset import Asset
|
|||||||
from app.models.vehicle.asset import AssetEvent
|
from app.models.vehicle.asset import AssetEvent
|
||||||
from app.models.vehicle.asset import OdometerReading
|
from app.models.vehicle.asset import OdometerReading
|
||||||
from app.models.marketplace.organization import Organization
|
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.identity.social import ServiceProvider
|
||||||
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
|
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
|
||||||
from app.models.system.system import SystemParameter
|
from app.models.system.system import SystemParameter
|
||||||
@@ -54,6 +54,123 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
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 ──
|
# ── FUEL CATEGORY IDS ──
|
||||||
# These are the category IDs that represent fuel costs.
|
# These are the category IDs that represent fuel costs.
|
||||||
# Used for auto-approval logic: fuel costs are always auto-approved.
|
# 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.
|
Uses the JSONB permissions field from fleet.org_roles.
|
||||||
Returns True if the user's role has the capability, False otherwise.
|
Returns True if the user's role has the capability, False otherwise.
|
||||||
"""
|
|
||||||
from app.models.fleet.org_role import OrgRole
|
|
||||||
|
|
||||||
stmt = (
|
NOTE: Two-step query (not JOIN) to avoid PG ENUM vs varchar type mismatch.
|
||||||
select(OrgRole.permissions)
|
OrganizationMember.role is PG ENUM 'fleet.orguserrole', OrgRole.name_key is varchar.
|
||||||
.join(OrganizationMember, OrganizationMember.role == OrgRole.name)
|
A direct JOIN would cause: operator does not exist: fleet.orguserrole = character varying
|
||||||
.where(
|
See assets.py:_get_user_permissions() for the same pattern.
|
||||||
OrganizationMember.user_id == user_id,
|
"""
|
||||||
OrganizationMember.organization_id == organization_id,
|
# Step 1: Get the user's role name (Python string — asyncpg auto-converts ENUM)
|
||||||
OrganizationMember.status == "active",
|
member_stmt = select(OrganizationMember.role).where(
|
||||||
)
|
OrganizationMember.user_id == user_id,
|
||||||
)
|
OrganizationMember.organization_id == organization_id,
|
||||||
result = await db.execute(stmt)
|
OrganizationMember.status == "active",
|
||||||
row = result.scalar_one_or_none()
|
).limit(1)
|
||||||
if row and isinstance(row, dict):
|
member_result = await db.execute(member_stmt)
|
||||||
return row.get(capability, False)
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/finance_admin.py
|
# /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.
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func, text
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from app.models.marketplace.finance import Issuer
|
from app.models.system.audit import FinancialLedger
|
||||||
from app.schemas.finance import IssuerResponse, IssuerUpdate
|
from app.schemas.finance import IssuerResponse, IssuerUpdate, FinancialLedgerListResponse, FinancialLedgerResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Issuer Endpoints (existing)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
|
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
|
||||||
async def list_issuers(
|
async def list_issuers(
|
||||||
db: AsyncSession = Depends(deps.get_db),
|
db: AsyncSession = Depends(deps.get_db),
|
||||||
@@ -27,6 +34,7 @@ async def list_issuers(
|
|||||||
List all Issuers (billing entities).
|
List all Issuers (billing entities).
|
||||||
Protected by RequirePermission("finance:view").
|
Protected by RequirePermission("finance:view").
|
||||||
"""
|
"""
|
||||||
|
from app.models.marketplace.finance import Issuer
|
||||||
result = await db.execute(select(Issuer).order_by(Issuer.id))
|
result = await db.execute(select(Issuer).order_by(Issuer.id))
|
||||||
issuers = result.scalars().all()
|
issuers = result.scalars().all()
|
||||||
return issuers
|
return issuers
|
||||||
@@ -44,6 +52,7 @@ async def update_issuer(
|
|||||||
Update an Issuer's details (activate/deactivate, revenue limit, API config).
|
Update an Issuer's details (activate/deactivate, revenue limit, API config).
|
||||||
Protected by RequirePermission("finance:edit").
|
Protected by RequirePermission("finance:edit").
|
||||||
"""
|
"""
|
||||||
|
from app.models.marketplace.finance import Issuer
|
||||||
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
|
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
|
||||||
issuer = result.scalar_one_or_none()
|
issuer = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -61,4 +70,134 @@ async def update_issuer(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(issuer)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -163,11 +163,7 @@ async def list_expertise_categories(
|
|||||||
ExpertiseCategoryOut(
|
ExpertiseCategoryOut(
|
||||||
id=t.id,
|
id=t.id,
|
||||||
key=t.key,
|
key=t.key,
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
name_i18n=t.name_i18n if t.name_i18n else {},
|
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,
|
category=t.category,
|
||||||
level=t.level,
|
level=t.level,
|
||||||
parent_id=t.parent_id,
|
parent_id=t.parent_id,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ class AssetCostResponse(AssetCostBase):
|
|||||||
# Derived / enriched fields (not in DB model directly)
|
# Derived / enriched fields (not in DB model directly)
|
||||||
category_name: Optional[str] = None # Resolved from CostCategory relationship
|
category_name: Optional[str] = None # Resolved from CostCategory relationship
|
||||||
category_code: 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']
|
description: Optional[str] = None # Extracted from data['description']
|
||||||
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
|
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
|
# P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban
|
||||||
service_provider_id: Optional[int] = None
|
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)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -34,6 +34,34 @@ class IssuerResponse(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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):
|
class IssuerUpdate(BaseModel):
|
||||||
"""Update schema for Issuer entities (PATCH)."""
|
"""Update schema for Issuer entities (PATCH)."""
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/schemas/system.py
|
# /opt/docker/dev/service_finder/backend/app/schemas/system.py
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from typing import Dict, Any, Optional
|
from typing import Any, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class SystemParameterBase(BaseModel):
|
class SystemParameterBase(BaseModel):
|
||||||
description: Optional[str] = None
|
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_level: str = 'global'
|
||||||
scope_id: Optional[str] = None
|
scope_id: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
@@ -18,7 +18,7 @@ class SystemParameterCreate(SystemParameterBase):
|
|||||||
|
|
||||||
class SystemParameterUpdate(BaseModel):
|
class SystemParameterUpdate(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
value: Optional[Dict[str, Any]] = None
|
value: Optional[Any] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -320,6 +320,15 @@ async def seed_params():
|
|||||||
"scope_level": "global"
|
"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
|
"key": "dvla_api_en
|
||||||
@@ -573,16 +573,22 @@ class AtomicTransactionManager:
|
|||||||
# Convert to dictionary format
|
# Convert to dictionary format
|
||||||
transactions = []
|
transactions = []
|
||||||
for entry in ledger_entries:
|
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({
|
transactions.append({
|
||||||
"id": entry.id,
|
"id": entry.id,
|
||||||
"user_id": entry.user_id,
|
"user_id": entry.user_id,
|
||||||
"amount": float(entry.amount),
|
"amount": float(entry.amount),
|
||||||
"entry_type": entry.entry_type.value,
|
"entry_type": entry.entry_type.value,
|
||||||
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
|
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
|
||||||
"description": entry.description,
|
"description": desc,
|
||||||
"transaction_id": str(entry.transaction_id),
|
"transaction_id": str(entry.transaction_id),
|
||||||
"reference_type": entry.reference_type,
|
"reference_type": None,
|
||||||
"reference_id": entry.reference_id,
|
"reference_id": None,
|
||||||
"balance_after": float(entry.balance_after) if entry.balance_after else 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
|
"created_at": entry.created_at.isoformat() if entry.created_at else None
|
||||||
})
|
})
|
||||||
@@ -622,15 +628,15 @@ class AtomicTransactionManager:
|
|||||||
return {
|
return {
|
||||||
"wallet_id": wallet.id,
|
"wallet_id": wallet.id,
|
||||||
"balances": {
|
"balances": {
|
||||||
"earned": float(wallet.earned_credits),
|
"earned": float(wallet.earned_credits or 0),
|
||||||
"purchased": float(wallet.purchased_credits),
|
"purchased": float(wallet.purchased_credits or 0),
|
||||||
"service_coins": float(wallet.service_coins),
|
"service_coins": float(wallet.service_coins or 0),
|
||||||
"voucher": float(voucher_balance),
|
"voucher": float(voucher_balance or 0),
|
||||||
"total": float(
|
"total": float(
|
||||||
wallet.earned_credits +
|
(wallet.earned_credits or 0) +
|
||||||
wallet.purchased_credits +
|
(wallet.purchased_credits or 0) +
|
||||||
wallet.service_coins +
|
(wallet.service_coins or 0) +
|
||||||
voucher_balance
|
(voucher_balance or 0)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
"recent_transactions": recent_transactions,
|
"recent_transactions": recent_transactions,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from app.models.marketplace.commission import (
|
|||||||
CommissionTier,
|
CommissionTier,
|
||||||
)
|
)
|
||||||
from app.models.identity.identity import User, Wallet
|
from app.models.identity.identity import User, Wallet
|
||||||
|
from app.models.system import SystemParameter
|
||||||
from app.models.system.audit import (
|
from app.models.system.audit import (
|
||||||
FinancialLedger,
|
FinancialLedger,
|
||||||
LedgerEntryType,
|
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(
|
async def _is_user_recently_active(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
inactivity_days: int = 180,
|
inactivity_days: Optional[int] = None,
|
||||||
) -> bool:
|
) -> 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:
|
A user is considered active if:
|
||||||
1. Their subscription is active (subscription_expires_at > now), OR
|
1. Their subscription is active (subscription_expires_at > now), OR
|
||||||
2. They have a FinancialLedger entry within the inactivity window.
|
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:
|
Args:
|
||||||
db: Database session.
|
db: Database session.
|
||||||
user_id: The user to check.
|
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:
|
Returns:
|
||||||
True if the user is recently active, False otherwise.
|
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
|
# Check subscription status first
|
||||||
user = await _lookup_user(db, user_id)
|
user = await _lookup_user(db, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
|
|||||||
@@ -393,22 +393,42 @@ class FinancialOrchestrator:
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# 3. Pénztárca egyenleg visszaállítása
|
# 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(
|
wallet_query = select(Wallet).where(
|
||||||
and_(
|
Wallet.user_id == original_entry.user_id
|
||||||
Wallet.user_id == original_entry.user_id,
|
|
||||||
Wallet.wallet_type == original_entry.wallet_type
|
|
||||||
)
|
|
||||||
).with_for_update()
|
).with_for_update()
|
||||||
|
|
||||||
wallet_result = await db.execute(wallet_query)
|
wallet_result = await db.execute(wallet_query)
|
||||||
wallet = wallet_result.scalar_one_or_none()
|
wallet = wallet_result.scalar_one_or_none()
|
||||||
|
|
||||||
if wallet:
|
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(
|
await db.execute(
|
||||||
update(Wallet)
|
update(Wallet)
|
||||||
.where(Wallet.id == wallet.id)
|
.where(Wallet.id == wallet.id)
|
||||||
.values(balance=new_balance)
|
.values(**update_values)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Számlakiállító bevételének csökkentése
|
# 4. Számlakiállító bevételének csökkentése
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
🤖 Inactivity Monitor Worker (Robot-21)
|
🤖 Inactivity Monitor Worker (Robot-21)
|
||||||
Detects users who have been inactive for 180+ days and flags them so the
|
Detects users who have been inactive for N+ days (configured via system_parameters)
|
||||||
MLM Commission Engine can bypass them for Gen1/Gen2 reward calculations.
|
and flags them so the MLM Commission Engine can bypass them for Gen1/Gen2 rewards.
|
||||||
|
|
||||||
Process:
|
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
|
AND is_active = True AND is_deleted = False
|
||||||
2. Set is_active = False
|
3. Set is_active = False
|
||||||
3. Set custom_permissions['inactivity_suspended'] = True
|
4. Set custom_permissions['inactivity_suspended'] = True
|
||||||
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
|
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
|
||||||
4. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
|
5. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
|
||||||
5. Send notification
|
6. Send notification
|
||||||
|
|
||||||
MLM Integration:
|
MLM Integration:
|
||||||
- The custom_permissions['inactivity_suspended'] flag is checked by the
|
- 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.database import AsyncSessionLocal
|
||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||||
|
from app.models.system import SystemParameter
|
||||||
from app.services.notification_service import NotificationService
|
from app.services.notification_service import NotificationService
|
||||||
|
|
||||||
logger = logging.getLogger("inactivity-monitor-worker")
|
logger = logging.getLogger("inactivity-monitor-worker")
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||||
PROCESS_NAME = "Inactivity-Monitor"
|
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:
|
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:
|
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):
|
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:
|
Returns:
|
||||||
dict: Statistics (processed_count, suspended_users, errors)
|
dict: Statistics (processed_count, suspended_users, errors)
|
||||||
"""
|
"""
|
||||||
|
inactivity_days = await _get_inactivity_threshold(db)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
cutoff = now - timedelta(days=INACTIVITY_DAYS)
|
cutoff = now - timedelta(days=inactivity_days)
|
||||||
stats = {"processed": 0, "suspended": [], "errors": []}
|
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 ──
|
# ── Phase A: Users with last_activity_at ──
|
||||||
# NOTE: We use a subquery approach to avoid PostgreSQL's
|
# NOTE: We use a subquery approach to avoid PostgreSQL's
|
||||||
@@ -114,7 +146,7 @@ async def process_inactive_users(db: AsyncSession) -> dict:
|
|||||||
try:
|
try:
|
||||||
# Determine the reference timestamp for this user
|
# Determine the reference timestamp for this user
|
||||||
ref_ts = user.last_activity_at or user.created_at
|
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
|
# 1. Deactivate the user
|
||||||
user.is_active = False
|
user.is_active = False
|
||||||
@@ -141,7 +173,7 @@ async def process_inactive_users(db: AsyncSession) -> dict:
|
|||||||
),
|
),
|
||||||
"inactivity_days": days_since,
|
"inactivity_days": days_since,
|
||||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||||
"cutoff_days": INACTIVITY_DAYS,
|
"cutoff_days": inactivity_days,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.add(ledger_entry)
|
db.add(ledger_entry)
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ async def process_expired_subscriptions(db: AsyncSession) -> dict:
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
amount=0.0,
|
amount=0.0,
|
||||||
entry_type=LedgerEntryType.DEBIT,
|
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",
|
transaction_type="SUBSCRIPTION_EXPIRED",
|
||||||
description=f"Előfizetés lejárt: {old_plan} → FREE",
|
description=f"Előfizetés lejárt: {old_plan} → FREE",
|
||||||
reference_type="subscription",
|
reference_type="subscription",
|
||||||
|
|||||||
219
backend/scripts/repair_user_relations.py
Normal file
219
backend/scripts/repair_user_relations.py
Normal 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())
|
||||||
155
backend/scripts/seed_financial_ledger.py
Normal file
155
backend/scripts/seed_financial_ledger.py
Normal 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()
|
||||||
@@ -290,6 +290,7 @@ services:
|
|||||||
- NUXT_PORT=8502
|
- NUXT_PORT=8502
|
||||||
- NUXT_HOST=0.0.0.0
|
- NUXT_HOST=0.0.0.0
|
||||||
- NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu
|
- NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu
|
||||||
|
- NODE_OPTIONS=--max-old-space-size=4096
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend_admin:/app
|
- ./frontend_admin:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
@@ -297,6 +298,10 @@ services:
|
|||||||
- sf_net
|
- sf_net
|
||||||
- shared_db_net
|
- shared_db_net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 8G
|
||||||
|
|
||||||
# --- PUBLIC FRONTEND (Epic 11 - Public Portal) ---
|
# --- PUBLIC FRONTEND (Epic 11 - Public Portal) ---
|
||||||
sf_public_frontend:
|
sf_public_frontend:
|
||||||
|
|||||||
181
docs/financial_module_comprehensive_audit_2026-07-25.md
Normal file
181
docs/financial_module_comprehensive_audit_2026-07-25.md
Normal 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.*
|
||||||
203
docs/p0_expense_500_import_bugfix_2026-07-26.md
Normal file
203
docs/p0_expense_500_import_bugfix_2026-07-26.md
Normal 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*
|
||||||
248
docs/p0_expense_edit_modal_root_cause_analysis.md
Normal file
248
docs/p0_expense_edit_modal_root_cause_analysis.md
Normal 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 |
|
||||||
149
docs/p0_wallet_balance_500_bugfix_2026-07-26.md
Normal file
149
docs/p0_wallet_balance_500_bugfix_2026-07-26.md
Normal 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.
|
||||||
@@ -2205,7 +2205,7 @@ const _FMnHxsRb1XnhSkgrcxwDEP1elfSPjrP7Wr27yXkb3Aw = (function(nitro) {
|
|||||||
* Released under the MIT License.
|
* Released under the MIT License.
|
||||||
*/
|
*/
|
||||||
const _create = Object.create;
|
const _create = Object.create;
|
||||||
const create$2 = (obj = null) => _create(obj);
|
const create = (obj = null) => _create(obj);
|
||||||
/* eslint-enable */
|
/* eslint-enable */
|
||||||
/**
|
/**
|
||||||
* Useful Utilities By Evan you
|
* Useful Utilities By Evan you
|
||||||
@@ -2240,7 +2240,7 @@ function deepCopy(src, des) {
|
|||||||
// if src[key] is an object/array, set des[key]
|
// if src[key] is an object/array, set des[key]
|
||||||
// to empty object/array to prevent setting by reference
|
// to empty object/array to prevent setting by reference
|
||||||
if (isObject(src[key]) && !isObject(des[key])) {
|
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])) {
|
if (isNotObjectOrIsArray(des[key]) || isNotObjectOrIsArray(src[key])) {
|
||||||
// replace with src[key] when:
|
// replace with src[key] when:
|
||||||
@@ -2259,18 +2259,18 @@ function deepCopy(src, des) {
|
|||||||
const __nuxtMock = { runWithContext: async (fn) => await fn() };
|
const __nuxtMock = { runWithContext: async (fn) => await fn() };
|
||||||
const merger = createDefu((obj, key, value) => {
|
const merger = createDefu((obj, key, value) => {
|
||||||
if (key === "messages" || key === "datetimeFormats" || key === "numberFormats") {
|
if (key === "messages" || key === "datetimeFormats" || key === "numberFormats") {
|
||||||
obj[key] ??= create$2(null);
|
obj[key] ??= create(null);
|
||||||
deepCopy(value, obj[key]);
|
deepCopy(value, obj[key]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
async function loadVueI18nOptions(vueI18nConfigs) {
|
async function loadVueI18nOptions(vueI18nConfigs) {
|
||||||
const nuxtApp = __nuxtMock;
|
const nuxtApp = __nuxtMock;
|
||||||
let vueI18nOptions = { messages: create$2(null) };
|
let vueI18nOptions = { messages: create(null) };
|
||||||
for (const configFile of vueI18nConfigs) {
|
for (const configFile of vueI18nConfigs) {
|
||||||
const resolver = await configFile().then((x) => isModule(x) ? x.default : x);
|
const resolver = await configFile().then((x) => isModule(x) ? x.default : x);
|
||||||
const resolved = isFunction(resolver) ? await nuxtApp.runWithContext(() => resolver()) : resolver;
|
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;
|
vueI18nOptions.fallbackLocale ??= false;
|
||||||
return vueI18nOptions;
|
return vueI18nOptions;
|
||||||
@@ -2416,6 +2416,7 @@ var menu$1 = {
|
|||||||
finance_management: "Pénzügyek Kezelése",
|
finance_management: "Pénzügyek Kezelése",
|
||||||
packages: "Csomagok",
|
packages: "Csomagok",
|
||||||
commission_rules: "Jutalék Szabályok",
|
commission_rules: "Jutalék Szabályok",
|
||||||
|
financial_ledger: "Pénzügyi Napló",
|
||||||
gamification: "Gamification",
|
gamification: "Gamification",
|
||||||
game_settings: "Játékmenet Beállítások",
|
game_settings: "Játékmenet Beállítások",
|
||||||
point_rules: "Pontszabályok",
|
point_rules: "Pontszabályok",
|
||||||
@@ -2429,6 +2430,7 @@ var menu$1 = {
|
|||||||
statistics: "Statisztikák",
|
statistics: "Statisztikák",
|
||||||
leaderboard: "Ranglista",
|
leaderboard: "Ranglista",
|
||||||
points_ledger: "Pontnapló",
|
points_ledger: "Pontnapló",
|
||||||
|
ledger: "Pontnapló",
|
||||||
gamification_hq: "Gamification HQ",
|
gamification_hq: "Gamification HQ",
|
||||||
system_config: "Rendszer Konfig",
|
system_config: "Rendszer Konfig",
|
||||||
system_parameters: "Rendszerparaméterek",
|
system_parameters: "Rendszerparaméterek",
|
||||||
@@ -2441,16 +2443,31 @@ const locale_menu_46json_d8dcbc3e = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
var dashboard$1 = {
|
var dashboard$1 = {
|
||||||
title: "Gamification HQ",
|
title: "Admin Dashboard",
|
||||||
subtitle: "Gamification rendszer áttekintő irányítópultja",
|
subtitle: "Rendszer áttekintő irányítópult",
|
||||||
loading: "Dashboard betöltése...",
|
loading: "Dashboard betöltése...",
|
||||||
error: "Nem sikerült betölteni a dashboard adatokat.",
|
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ó",
|
total_users: "Összes Felhasználó",
|
||||||
active_season: "Aktív Szezon",
|
active_season: "Aktív Szezon",
|
||||||
pending_contributions: "Függő Hozzájárulások",
|
pending_contributions: "Függő Hozzájárulások",
|
||||||
xp_earned_today: "Ma Szerzett XP",
|
xp_earned_today: "Ma Szerzett XP",
|
||||||
none: "Nincs",
|
none: "Nincs",
|
||||||
quick_actions: "Gyors Műveletek",
|
|
||||||
manage_point_rules: "Pontszabályok kezelése",
|
manage_point_rules: "Pontszabályok kezelése",
|
||||||
manage_badges: "Kitüntetések kezelése",
|
manage_badges: "Kitüntetések kezelése",
|
||||||
manage_seasons: "Szezonok kezelése",
|
manage_seasons: "Szezonok kezelése",
|
||||||
@@ -3495,6 +3512,11 @@ const locale_gamification_46json_15470408 = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
var system$1 = {
|
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: {
|
config: {
|
||||||
title: "Rendszer Konfig",
|
title: "Rendszer Konfig",
|
||||||
subtitle: "Gamification master konfiguráció szerkesztése",
|
subtitle: "Gamification master konfiguráció szerkesztése",
|
||||||
@@ -3519,7 +3541,18 @@ var system$1 = {
|
|||||||
updated: "Konfiguráció frissítve!",
|
updated: "Konfiguráció frissítve!",
|
||||||
save_error: "Hiba történt a mentés során.",
|
save_error: "Hiba történt a mentés során.",
|
||||||
json_applied: "JSON alkalmazva a form mezőkre!",
|
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: 30–730 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: {
|
params: {
|
||||||
title: "Rendszerparaméterek",
|
title: "Rendszerparaméterek",
|
||||||
@@ -3778,6 +3811,82 @@ const locale_commission_46json_2229a18b = {
|
|||||||
commission_rules: commission_rules$1
|
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 = {
|
var common$5 = {
|
||||||
loading: "Loading...",
|
loading: "Loading...",
|
||||||
saving: "Saving...",
|
saving: "Saving...",
|
||||||
@@ -3897,6 +4006,7 @@ var menu = {
|
|||||||
finance_management: "Finance Management",
|
finance_management: "Finance Management",
|
||||||
packages: "Packages",
|
packages: "Packages",
|
||||||
commission_rules: "Commission Rules",
|
commission_rules: "Commission Rules",
|
||||||
|
financial_ledger: "Financial Ledger",
|
||||||
gamification: "Gamification",
|
gamification: "Gamification",
|
||||||
game_settings: "Game Settings",
|
game_settings: "Game Settings",
|
||||||
point_rules: "Point Rules",
|
point_rules: "Point Rules",
|
||||||
@@ -3910,6 +4020,7 @@ var menu = {
|
|||||||
statistics: "Statistics",
|
statistics: "Statistics",
|
||||||
leaderboard: "Leaderboard",
|
leaderboard: "Leaderboard",
|
||||||
points_ledger: "Points Ledger",
|
points_ledger: "Points Ledger",
|
||||||
|
ledger: "Points Ledger",
|
||||||
gamification_hq: "Gamification HQ",
|
gamification_hq: "Gamification HQ",
|
||||||
system_config: "System Config",
|
system_config: "System Config",
|
||||||
system_parameters: "System Parameters",
|
system_parameters: "System Parameters",
|
||||||
@@ -4976,6 +5087,11 @@ const locale_gamification_46json_111ebf57 = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
var system = {
|
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: {
|
config: {
|
||||||
title: "System Config",
|
title: "System Config",
|
||||||
subtitle: "Gamification master configuration editor",
|
subtitle: "Gamification master configuration editor",
|
||||||
@@ -5000,7 +5116,18 @@ var system = {
|
|||||||
updated: "Configuration updated!",
|
updated: "Configuration updated!",
|
||||||
save_error: "Failed to save configuration.",
|
save_error: "Failed to save configuration.",
|
||||||
json_applied: "JSON applied to form fields!",
|
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: 30–730 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: {
|
params: {
|
||||||
title: "System Parameters",
|
title: "System Parameters",
|
||||||
@@ -5259,6 +5386,82 @@ const locale_commission_46json_ea60861a = {
|
|||||||
commission_rules: commission_rules
|
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 = {
|
var common$4 = {
|
||||||
loading: "Wird geladen...",
|
loading: "Wird geladen...",
|
||||||
saving: "Wird gespeichert...",
|
saving: "Wird gespeichert...",
|
||||||
@@ -5775,6 +5978,11 @@ const localeLoaders = {
|
|||||||
key: "locale_commission_46json_2229a18b",
|
key: "locale_commission_46json_2229a18b",
|
||||||
load: () => Promise.resolve(locale_commission_46json_2229a18b),
|
load: () => Promise.resolve(locale_commission_46json_2229a18b),
|
||||||
cache: true
|
cache: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "locale_finance_46json_ae59f62d",
|
||||||
|
load: () => Promise.resolve(locale_finance_46json_ae59f62d),
|
||||||
|
cache: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
en: [
|
en: [
|
||||||
@@ -5822,6 +6030,11 @@ const localeLoaders = {
|
|||||||
key: "locale_commission_46json_ea60861a",
|
key: "locale_commission_46json_ea60861a",
|
||||||
load: () => Promise.resolve(locale_commission_46json_ea60861a),
|
load: () => Promise.resolve(locale_commission_46json_ea60861a),
|
||||||
cache: true
|
cache: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "locale_finance_46json_5597e160",
|
||||||
|
load: () => Promise.resolve(locale_finance_46json_5597e160),
|
||||||
|
cache: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
de: [
|
de: [
|
||||||
@@ -6512,22 +6725,7 @@ _1xyvuM2CQDhMlDxE8qY1t3Wi66ocgNUNDNce4pREOrU,
|
|||||||
_wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
|
_wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
|
||||||
];
|
];
|
||||||
|
|
||||||
const assets = {
|
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"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function readAsset (id) {
|
function readAsset (id) {
|
||||||
const serverDir = dirname$1(fileURLToPath(globalThis._importMeta_.url));
|
const serverDir = dirname$1(fileURLToPath(globalThis._importMeta_.url));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"id":"dev","timestamp":1784853723611}
|
{"id":"dev","timestamp":1785068325212}
|
||||||
@@ -1 +1 @@
|
|||||||
{"id":"dev","timestamp":1784853723611,"prerendered":[]}
|
{"id":"dev","timestamp":1785068325212,"prerendered":[]}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"date": "2026-07-24T12:30:18.036Z",
|
"date": "2026-07-26T12:18:53.544Z",
|
||||||
"preset": "nitro-dev",
|
"preset": "nitro-dev",
|
||||||
"framework": {
|
"framework": {
|
||||||
"name": "nuxt",
|
"name": "nuxt",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"dev": {
|
"dev": {
|
||||||
"pid": 19,
|
"pid": 19,
|
||||||
"workerAddress": {
|
"workerAddress": {
|
||||||
"socketPath": "\u0000nitro-worker-19-30-30-3992.sock"
|
"socketPath": "\u0000nitro-worker-19-1-1-8775.sock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
6
frontend_admin/.nuxt/nuxt.d.ts
vendored
6
frontend_admin/.nuxt/nuxt.d.ts
vendored
@@ -1,8 +1,8 @@
|
|||||||
/// <reference types="@nuxtjs/i18n" />
|
|
||||||
/// <reference types="@nuxtjs/tailwindcss" />
|
|
||||||
/// <reference types="@pinia/nuxt" />
|
/// <reference types="@pinia/nuxt" />
|
||||||
/// <reference types="@nuxt/telemetry" />
|
/// <reference types="@nuxtjs/i18n" />
|
||||||
/// <reference types="@nuxt/devtools" />
|
/// <reference types="@nuxt/devtools" />
|
||||||
|
/// <reference types="@nuxtjs/tailwindcss" />
|
||||||
|
/// <reference types="@nuxt/telemetry" />
|
||||||
/// <reference path="types/nitro-layouts.d.ts" />
|
/// <reference path="types/nitro-layouts.d.ts" />
|
||||||
/// <reference path="types/builder-env.d.ts" />
|
/// <reference path="types/builder-env.d.ts" />
|
||||||
/// <reference types="nuxt" />
|
/// <reference types="nuxt" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/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 "@nuxtjs/tailwindcss/config-ctx"
|
||||||
import configMerger from "@nuxtjs/tailwindcss/merger";
|
import configMerger from "@nuxtjs/tailwindcss/merger";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"nuxt": [
|
||||||
|
"../node_modules/nuxt"
|
||||||
|
],
|
||||||
"@unhead/vue": [
|
"@unhead/vue": [
|
||||||
"../node_modules/@unhead/vue"
|
"../node_modules/@unhead/vue"
|
||||||
],
|
],
|
||||||
@@ -16,9 +19,6 @@
|
|||||||
"@nuxt/schema": [
|
"@nuxt/schema": [
|
||||||
"../node_modules/@nuxt/schema"
|
"../node_modules/@nuxt/schema"
|
||||||
],
|
],
|
||||||
"nuxt": [
|
|
||||||
"../node_modules/nuxt"
|
|
||||||
],
|
|
||||||
"vue-i18n": [
|
"vue-i18n": [
|
||||||
"../node_modules/vue-i18n/dist/vue-i18n",
|
"../node_modules/vue-i18n/dist/vue-i18n",
|
||||||
"../node_modules/vue-i18n"
|
"../node_modules/vue-i18n"
|
||||||
@@ -47,9 +47,6 @@
|
|||||||
"../node_modules/@intlify/message-compiler/dist/message-compiler",
|
"../node_modules/@intlify/message-compiler/dist/message-compiler",
|
||||||
"../node_modules/@intlify/message-compiler"
|
"../node_modules/@intlify/message-compiler"
|
||||||
],
|
],
|
||||||
"nitropack": [
|
|
||||||
"../node_modules/nitropack"
|
|
||||||
],
|
|
||||||
"defu": [
|
"defu": [
|
||||||
"../node_modules/defu"
|
"../node_modules/defu"
|
||||||
],
|
],
|
||||||
@@ -68,6 +65,9 @@
|
|||||||
"unplugin-vue-router/client": [
|
"unplugin-vue-router/client": [
|
||||||
"../node_modules/unplugin-vue-router/client"
|
"../node_modules/unplugin-vue-router/client"
|
||||||
],
|
],
|
||||||
|
"nitropack": [
|
||||||
|
"../node_modules/nitropack"
|
||||||
|
],
|
||||||
"vite/client": [
|
"vite/client": [
|
||||||
"../node_modules/vite/client"
|
"../node_modules/vite/client"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
"@@/*": [
|
"@@/*": [
|
||||||
"../*"
|
"../*"
|
||||||
],
|
],
|
||||||
|
"nuxt": [
|
||||||
|
"../node_modules/nuxt"
|
||||||
|
],
|
||||||
"@unhead/vue": [
|
"@unhead/vue": [
|
||||||
"../node_modules/@unhead/vue"
|
"../node_modules/@unhead/vue"
|
||||||
],
|
],
|
||||||
@@ -44,9 +47,6 @@
|
|||||||
"@nuxt/schema": [
|
"@nuxt/schema": [
|
||||||
"../node_modules/@nuxt/schema"
|
"../node_modules/@nuxt/schema"
|
||||||
],
|
],
|
||||||
"nuxt": [
|
|
||||||
"../node_modules/nuxt"
|
|
||||||
],
|
|
||||||
"vue-i18n": [
|
"vue-i18n": [
|
||||||
"../node_modules/vue-i18n"
|
"../node_modules/vue-i18n"
|
||||||
],
|
],
|
||||||
@@ -68,9 +68,6 @@
|
|||||||
"@intlify/message-compiler": [
|
"@intlify/message-compiler": [
|
||||||
"../node_modules/@intlify/message-compiler"
|
"../node_modules/@intlify/message-compiler"
|
||||||
],
|
],
|
||||||
"nitropack": [
|
|
||||||
"../node_modules/nitropack"
|
|
||||||
],
|
|
||||||
"defu": [
|
"defu": [
|
||||||
"../node_modules/defu"
|
"../node_modules/defu"
|
||||||
],
|
],
|
||||||
@@ -89,6 +86,9 @@
|
|||||||
"unplugin-vue-router/client": [
|
"unplugin-vue-router/client": [
|
||||||
"../node_modules/unplugin-vue-router/client"
|
"../node_modules/unplugin-vue-router/client"
|
||||||
],
|
],
|
||||||
|
"nitropack": [
|
||||||
|
"../node_modules/nitropack"
|
||||||
|
],
|
||||||
"vite/client": [
|
"vite/client": [
|
||||||
"../node_modules/vite/client"
|
"../node_modules/vite/client"
|
||||||
],
|
],
|
||||||
|
|||||||
73
frontend_admin/i18n/locales/en/finance.json
Normal file
73
frontend_admin/i18n/locales/en/finance.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"finance_management": "Finance Management",
|
"finance_management": "Finance Management",
|
||||||
"packages": "Packages",
|
"packages": "Packages",
|
||||||
"commission_rules": "Commission Rules",
|
"commission_rules": "Commission Rules",
|
||||||
|
"financial_ledger": "Financial Ledger",
|
||||||
"gamification": "Gamification",
|
"gamification": "Gamification",
|
||||||
"game_settings": "Game Settings",
|
"game_settings": "Game Settings",
|
||||||
"point_rules": "Point Rules",
|
"point_rules": "Point Rules",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
"leaderboard": "Leaderboard",
|
"leaderboard": "Leaderboard",
|
||||||
"points_ledger": "Points Ledger",
|
"points_ledger": "Points Ledger",
|
||||||
|
"ledger": "Points Ledger",
|
||||||
"gamification_hq": "Gamification HQ",
|
"gamification_hq": "Gamification HQ",
|
||||||
"system_config": "System Config",
|
"system_config": "System Config",
|
||||||
"system_parameters": "System Parameters",
|
"system_parameters": "System Parameters",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"system": {
|
"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": {
|
"config": {
|
||||||
"title": "System Config",
|
"title": "System Config",
|
||||||
"subtitle": "Gamification master configuration editor",
|
"subtitle": "Gamification master configuration editor",
|
||||||
@@ -24,7 +29,18 @@
|
|||||||
"updated": "Configuration updated!",
|
"updated": "Configuration updated!",
|
||||||
"save_error": "Failed to save configuration.",
|
"save_error": "Failed to save configuration.",
|
||||||
"json_applied": "JSON applied to form fields!",
|
"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: 30–730 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": {
|
"params": {
|
||||||
"title": "System Parameters",
|
"title": "System Parameters",
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
{
|
{
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Gamification HQ",
|
"title": "Admin Dashboard",
|
||||||
"subtitle": "Gamification rendszer áttekintő irányítópultja",
|
"subtitle": "Rendszer áttekintő irányítópult",
|
||||||
"loading": "Dashboard betöltése...",
|
"loading": "Dashboard betöltése...",
|
||||||
"error": "Nem sikerült betölteni a dashboard adatokat.",
|
"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ó",
|
"total_users": "Összes Felhasználó",
|
||||||
"active_season": "Aktív Szezon",
|
"active_season": "Aktív Szezon",
|
||||||
"pending_contributions": "Függő Hozzájárulások",
|
"pending_contributions": "Függő Hozzájárulások",
|
||||||
"xp_earned_today": "Ma Szerzett XP",
|
"xp_earned_today": "Ma Szerzett XP",
|
||||||
"none": "Nincs",
|
"none": "Nincs",
|
||||||
"quick_actions": "Gyors Műveletek",
|
|
||||||
"manage_point_rules": "Pontszabályok kezelése",
|
"manage_point_rules": "Pontszabályok kezelése",
|
||||||
"manage_badges": "Kitüntetések kezelése",
|
"manage_badges": "Kitüntetések kezelése",
|
||||||
"manage_seasons": "Szezonok kezelése",
|
"manage_seasons": "Szezonok kezelése",
|
||||||
|
|||||||
73
frontend_admin/i18n/locales/hu/finance.json
Normal file
73
frontend_admin/i18n/locales/hu/finance.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"finance_management": "Pénzügyek Kezelése",
|
"finance_management": "Pénzügyek Kezelése",
|
||||||
"packages": "Csomagok",
|
"packages": "Csomagok",
|
||||||
"commission_rules": "Jutalék Szabályok",
|
"commission_rules": "Jutalék Szabályok",
|
||||||
|
"financial_ledger": "Pénzügyi Napló",
|
||||||
"gamification": "Gamification",
|
"gamification": "Gamification",
|
||||||
"game_settings": "Játékmenet Beállítások",
|
"game_settings": "Játékmenet Beállítások",
|
||||||
"point_rules": "Pontszabályok",
|
"point_rules": "Pontszabályok",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"statistics": "Statisztikák",
|
"statistics": "Statisztikák",
|
||||||
"leaderboard": "Ranglista",
|
"leaderboard": "Ranglista",
|
||||||
"points_ledger": "Pontnapló",
|
"points_ledger": "Pontnapló",
|
||||||
|
"ledger": "Pontnapló",
|
||||||
"gamification_hq": "Gamification HQ",
|
"gamification_hq": "Gamification HQ",
|
||||||
"system_config": "Rendszer Konfig",
|
"system_config": "Rendszer Konfig",
|
||||||
"system_parameters": "Rendszerparaméterek",
|
"system_parameters": "Rendszerparaméterek",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"system": {
|
"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": {
|
"config": {
|
||||||
"title": "Rendszer Konfig",
|
"title": "Rendszer Konfig",
|
||||||
"subtitle": "Gamification master konfiguráció szerkesztése",
|
"subtitle": "Gamification master konfiguráció szerkesztése",
|
||||||
@@ -24,7 +29,18 @@
|
|||||||
"updated": "Konfiguráció frissítve!",
|
"updated": "Konfiguráció frissítve!",
|
||||||
"save_error": "Hiba történt a mentés során.",
|
"save_error": "Hiba történt a mentés során.",
|
||||||
"json_applied": "JSON alkalmazva a form mezőkre!",
|
"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: 30–730 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": {
|
"params": {
|
||||||
"title": "Rendszerparaméterek",
|
"title": "Rendszerparaméterek",
|
||||||
|
|||||||
@@ -352,6 +352,11 @@ const menuGroups: MenuGroup[] = [
|
|||||||
label: 'menu.commission_rules',
|
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>',
|
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>',
|
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',
|
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>',
|
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('/users')) return 'menu.users'
|
||||||
if (route.path.startsWith('/logs')) return 'menu.system_logs'
|
if (route.path.startsWith('/logs')) return 'menu.system_logs'
|
||||||
if (route.path.startsWith('/packages')) return 'menu.packages'
|
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('/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.startsWith('/providers')) {
|
||||||
if (route.path === '/providers/pending') return 'providers.pending_title'
|
if (route.path === '/providers/pending') return 'providers.pending_title'
|
||||||
if (route.path.startsWith('/providers/')) return 'providers.provider_detail'
|
if (route.path.startsWith('/providers/')) return 'providers.provider_detail'
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default defineNuxtConfig({
|
|||||||
files: [
|
files: [
|
||||||
'hu/common.json', 'hu/menu.json', 'hu/dashboard.json', 'hu/providers.json',
|
'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/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: [
|
files: [
|
||||||
'en/common.json', 'en/menu.json', 'en/dashboard.json', 'en/providers.json',
|
'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/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' },
|
{ code: 'de', iso: 'de-DE', file: 'de.json', name: 'Deutsch' },
|
||||||
|
|||||||
387
frontend_admin/pages/finance/ledger.vue
Normal file
387
frontend_admin/pages/finance/ledger.vue
Normal 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>
|
||||||
@@ -215,6 +215,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Impact Calculator -->
|
||||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-8">
|
<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">
|
<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 conversionLogic = reactive<ConversionLogic>({ socialToCreditRate: 100 })
|
||||||
const levelRewards = reactive<LevelRewards>({ creditsPer10Levels: 50 })
|
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 calculator = reactive({ level: 5, activities: 3, socialPoints: 50 })
|
||||||
|
|
||||||
const calculatedDailyXp = computed(() => {
|
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() {
|
async function fetchConfig() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = false
|
error.value = false
|
||||||
@@ -525,6 +627,7 @@ function formatNumber(n: number): string {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchConfig()
|
fetchConfig()
|
||||||
|
fetchInactivityThreshold()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
28
frontend_admin/pages/logs.vue
Normal file
28
frontend_admin/pages/logs.vue
Normal 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>
|
||||||
81
frontend_admin/pages/system/settings.vue
Normal file
81
frontend_admin/pages/system/settings.vue
Normal 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>
|
||||||
@@ -969,22 +969,130 @@ function resetForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit Cost Hydration ──
|
// ── 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) => {
|
watch(() => props.editCost, (cost) => {
|
||||||
if (!cost) return
|
if (!cost) return
|
||||||
// Hydrate form from existing expense data
|
|
||||||
|
// ── Basic fields (unchanged) ──
|
||||||
form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
|
form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
|
||||||
form.mainCategory = String(cost.category_id || '')
|
form.netAmount = cost.data?.net_amount ?? cost.amount_net ?? 0
|
||||||
form.subCategory = String(cost.sub_category_id || '')
|
form.vatRate = cost.data?.vat_rate ?? 27
|
||||||
form.netAmount = cost.data?.net_amount || cost.amount_net || 0
|
form.grossAmount = cost.amount_gross ?? 0
|
||||||
form.vatRate = cost.data?.vat_rate || 27
|
|
||||||
form.grossAmount = cost.amount_gross || 0
|
|
||||||
form.currency = cost.currency || 'HUF'
|
form.currency = cost.currency || 'HUF'
|
||||||
form.invoiceNumber = cost.data?.invoice_number || ''
|
form.invoiceNumber = cost.data?.invoice_number ?? ''
|
||||||
form.invoiceDate = cost.data?.invoice_date || ''
|
form.invoiceDate = cost.data?.invoice_date ?? ''
|
||||||
form.paymentDeadline = cost.data?.payment_deadline || ''
|
form.paymentDeadline = cost.data?.payment_deadline ?? ''
|
||||||
form.paymentMethod = cost.data?.payment_method || 'TRANSFER'
|
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 #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 ──
|
// ── File Methods ──
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="flex-1 space-y-3">
|
<div class="flex-1 space-y-3">
|
||||||
<!-- Score card -->
|
<!-- Score card -->
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
<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>
|
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Achievement badges -->
|
<!-- Achievement badges -->
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -52,11 +52,13 @@ const emit = defineEmits<{
|
|||||||
'open-card': [cardId: string]
|
'open-card': [cardId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── Placeholder badges ──
|
// ── Reactive user score (placeholder 0 — wire to gamification store when available) ──
|
||||||
const badges = ref([
|
const userScore = computed(() => 0)
|
||||||
{ icon: '🌱', label: 'Eco Driver' },
|
|
||||||
{ icon: '📏', label: '10k km Club' },
|
// ── Reactive badges (empty array — wire to gamification API when available) ──
|
||||||
{ icon: '🔧', label: 'Service Pro' },
|
interface BadgeItem {
|
||||||
{ icon: '⭐', label: 'Top Rater' },
|
icon: string
|
||||||
])
|
label: string
|
||||||
|
}
|
||||||
|
const badges = ref<BadgeItem[]>([])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,63 +1,154 @@
|
|||||||
<template>
|
<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
|
<BaseCard
|
||||||
hoverable
|
hoverable
|
||||||
@click="$emit('open-card', 'stats')"
|
@click="router.push('/finance')"
|
||||||
>
|
>
|
||||||
<!-- ═══ HEADER: Profile title ═══ -->
|
<!-- ═══ HEADER: Finance title ═══ -->
|
||||||
<template #header>
|
<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>
|
</template>
|
||||||
|
|
||||||
<!-- ═══ BODY: User info + Trust Score + Stats ═══ -->
|
<!-- ═══ BODY: Quick finance overview (clickable → /finance) ═══ -->
|
||||||
<div class="flex-1 space-y-3">
|
<div class="flex-1 space-y-3">
|
||||||
<!-- User info -->
|
<!-- Balance summary row -->
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
<div class="rounded-xl border border-slate-200 bg-gradient-to-br from-amber-50 to-white p-3">
|
||||||
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
|
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
|
||||||
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
|
{{ 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>
|
</div>
|
||||||
<!-- Trust Score -->
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
<!-- Quick stat tiles (2×2 grid) -->
|
||||||
<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 -->
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<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">
|
<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-lg font-bold text-slate-800">{{ financeStore.totalCredits }}</p>
|
||||||
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
|
<p class="text-xs text-slate-400">{{ t('finance.wallet') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tranzakciók (Transactions count) -->
|
||||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
<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-lg font-bold text-slate-800">{{ transactionCount }}</p>
|
||||||
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ FOOTER: CTA button ═══ -->
|
<!-- ═══ FOOTER: 3 action buttons ═══ -->
|
||||||
<template #footer>
|
<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">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
{{ t('common.profileSettings') }} →
|
<!-- Pénztárcák (Wallets) -->
|
||||||
</button>
|
<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>
|
</template>
|
||||||
</BaseCard>
|
</BaseCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
|
import { useFinanceStore } from '../../stores/finance'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { useVehicleStore } from '../../stores/vehicle'
|
|
||||||
import BaseCard from '../ui/BaseCard.vue'
|
import BaseCard from '../ui/BaseCard.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const financeStore = useFinanceStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const vehicleStore = useVehicleStore()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
// ── Reactive computed values for stat tiles ──
|
||||||
'open-card': [cardId: string]
|
|
||||||
}>()
|
/** 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>
|
</script>
|
||||||
|
|||||||
@@ -1779,6 +1779,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
(e: 'saved'): void
|
(e: 'saved'): void
|
||||||
|
(e: 'deleted'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── Tab Configuration ──
|
// ── Tab Configuration ──
|
||||||
|
|||||||
302
frontend_app/src/components/finance/TransactionHistory.vue
Normal file
302
frontend_app/src/components/finance/TransactionHistory.vue
Normal 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'"
|
||||||
|
>
|
||||||
|
← {{ 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>
|
||||||
188
frontend_app/src/components/finance/WalletBalanceCard.vue
Normal file
188
frontend_app/src/components/finance/WalletBalanceCard.vue
Normal 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>
|
||||||
@@ -18,22 +18,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the target route for the logo link.
|
* Always navigate to /dashboard when clicking the logo.
|
||||||
* - If the user is on an /organization/:id page, navigate to the org root.
|
* This serves as a global "escape hatch" back to the private garage,
|
||||||
* - Otherwise, navigate to /dashboard (private garage).
|
* regardless of the current route (org, vehicles, etc.).
|
||||||
*/
|
*/
|
||||||
const targetRoute = computed(() => {
|
const targetRoute = computed(() => '/dashboard')
|
||||||
if (route.path.startsWith('/organization/')) {
|
|
||||||
const orgId = route.params.id
|
|
||||||
return orgId ? `/organization/${orgId}` : route.path
|
|
||||||
}
|
|
||||||
return '/dashboard'
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -901,6 +901,7 @@ import VehiclePlateBadge from './VehiclePlateBadge.vue'
|
|||||||
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
|
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
|
||||||
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
|
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
|
||||||
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
|
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
|
||||||
|
import TechDataTab from '../vehicles/tabs/TechDataTab.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export default {
|
|||||||
garageLimit: 'Garage Limit',
|
garageLimit: 'Garage Limit',
|
||||||
managePlan: 'Manage Plan',
|
managePlan: 'Manage Plan',
|
||||||
indefinite: 'Indefinite',
|
indefinite: 'Indefinite',
|
||||||
|
vehicles: 'vehicles',
|
||||||
|
unlimited: 'unlimited',
|
||||||
|
timeRemaining: 'Time Remaining',
|
||||||
|
days: 'days',
|
||||||
},
|
},
|
||||||
landing: {
|
landing: {
|
||||||
openGarage: 'Open Garage',
|
openGarage: 'Open Garage',
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export default {
|
|||||||
garageLimit: 'Garázs Limit',
|
garageLimit: 'Garázs Limit',
|
||||||
managePlan: 'Csomag Kezelése',
|
managePlan: 'Csomag Kezelése',
|
||||||
indefinite: 'Határozatlan',
|
indefinite: 'Határozatlan',
|
||||||
|
vehicles: 'jármű',
|
||||||
|
unlimited: 'korlátlan',
|
||||||
|
timeRemaining: 'Hátralévő idő',
|
||||||
|
days: 'nap',
|
||||||
},
|
},
|
||||||
landing: {
|
landing: {
|
||||||
openGarage: 'Garázs Nyitása',
|
openGarage: 'Garázs Nyitása',
|
||||||
|
|||||||
89
frontend_app/src/layouts/FinanceLayout.vue
Normal file
89
frontend_app/src/layouts/FinanceLayout.vue
Normal 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>
|
||||||
@@ -84,6 +84,18 @@ const router = createRouter({
|
|||||||
component: () => import('../views/ProfileView.vue'),
|
component: () => import('../views/ProfileView.vue'),
|
||||||
meta: { requiresAuth: true }
|
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',
|
path: '/verify',
|
||||||
name: 'verify',
|
name: 'verify',
|
||||||
@@ -139,6 +151,51 @@ const router = createRouter({
|
|||||||
component: () => import('../views/admin/AdminServicesView.vue')
|
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 }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -68,10 +68,8 @@
|
|||||||
@open-card="openCard"
|
@open-card="openCard"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Card 5: 🛡️ My Profile & Trust (Profil & Trust Score) -->
|
<!-- Card 5: 💰 Pénzügy (Finance Overview) -->
|
||||||
<ProfileTrustCard
|
<ProfileTrustCard />
|
||||||
@open-card="openCard"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Subscription Status & Ad Placement Row ── -->
|
<!-- ── Subscription Status & Ad Placement Row ── -->
|
||||||
|
|||||||
266
frontend_app/src/views/FinanceMainView.vue
Normal file
266
frontend_app/src/views/FinanceMainView.vue
Normal 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>
|
||||||
58
frontend_app/src/views/FinancePackagesView.vue
Normal file
58
frontend_app/src/views/FinancePackagesView.vue
Normal 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>
|
||||||
49
frontend_app/src/views/FinanceTransactionsView.vue
Normal file
49
frontend_app/src/views/FinanceTransactionsView.vue
Normal 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>
|
||||||
52
frontend_app/src/views/FinanceWalletsView.vue
Normal file
52
frontend_app/src/views/FinanceWalletsView.vue
Normal 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>
|
||||||
70
frontend_app/src/views/ProfileSettingsView.vue
Normal file
70
frontend_app/src/views/ProfileSettingsView.vue
Normal 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>
|
||||||
140
frontend_app/src/views/ProfileStatsView.vue
Normal file
140
frontend_app/src/views/ProfileStatsView.vue
Normal 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>
|
||||||
136
frontend_app/src/views/WalletView.vue
Normal file
136
frontend_app/src/views/WalletView.vue
Normal 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>
|
||||||
@@ -524,12 +524,34 @@ function closeCostWizard() {
|
|||||||
|
|
||||||
// ── Cost Entry Wizard (Edit) ──
|
// ── Cost Entry Wizard (Edit) ──
|
||||||
|
|
||||||
function openEditWizard(cost: any) {
|
/**
|
||||||
// Find the vehicle for this cost
|
* Open the edit modal for a specific expense.
|
||||||
const vehicleId = cost.asset_id || cost.vehicle_id
|
* P0 BUGFIX (2026-07-26): Fetches the enriched expense from the new
|
||||||
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
|
* GET /expenses/by-id/{id} endpoint instead of passing the raw list object.
|
||||||
editCostData.value = cost
|
* This ensures category_name, parent_category_id, service_provider_name,
|
||||||
showEditWizard.value = true
|
* 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() {
|
function closeEditWizard() {
|
||||||
|
|||||||
251
plans/admin_crash_and_user_repair_blueprint.md
Normal file
251
plans/admin_crash_and_user_repair_blueprint.md
Normal 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.
|
||||||
200
plans/bugfix_quad_blueprint_2026-07-26.md
Normal file
200
plans/bugfix_quad_blueprint_2026-07-26.md
Normal 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.*
|
||||||
307
plans/finance_layout_clone_and_enum_fix_2026-07-26.md
Normal file
307
plans/finance_layout_clone_and_enum_fix_2026-07-26.md
Normal 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
|
||||||
287
plans/finance_mock_data_and_back_button_blueprint_2026-07-26.md
Normal file
287
plans/finance_mock_data_and_back_button_blueprint_2026-07-26.md
Normal 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
|
||||||
257
plans/finance_module_corrective_blueprint_2026-07-26.md
Normal file
257
plans/finance_module_corrective_blueprint_2026-07-26.md
Normal 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
|
||||||
213
plans/finance_module_recovery_blueprint_2026-07-26.md
Normal file
213
plans/finance_module_recovery_blueprint_2026-07-26.md
Normal 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
|
||||||
219
plans/finance_navbar_and_alignment_fix_2026-07-26.md
Normal file
219
plans/finance_navbar_and_alignment_fix_2026-07-26.md
Normal 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
|
||||||
156
plans/logic_spec_fix_401_ledger_endpoint.md
Normal file
156
plans/logic_spec_fix_401_ledger_endpoint.md
Normal 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 |
|
||||||
Reference in New Issue
Block a user