diff --git a/.roo/history.md b/.roo/history.md index 857ffa65..5b11fcf0 100644 --- a/.roo/history.md +++ b/.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 -**Scope:** Backend + Frontend (SimpleFuelModal, ProviderAutocomplete, ProviderQuickAddModal) +**Dátum:** 2026-07-25 -### 🔧 Módosított fájlok - -#### 1. Backend: [`providers.py`](backend/app/api/v1/endpoints/providers.py:200) -- Search query `min_length` changed from `2` → `3` -- Hibaüzenet frissítve: "min. 3 karakter" - -#### 2. Backend: [`provider_service.py`](backend/app/services/provider_service.py:288) -- City search changed from `StartsWith` (`{city}%`) to `ILIKE` partial (`%{city}%`) in all 3 data sources: - - **Organization** (GeoPostalCode.city) - - **ServiceStaging** (ServiceStaging.city) - - **ServiceProvider** (ServiceProvider.address) -- Zip code search also changed to partial ILIKE - -#### 3. Frontend: [`ProviderAutocomplete.vue`](frontend_app/src/components/provider/ProviderAutocomplete.vue:127) -- Minimum search length: `2` → `3` characters -- Empty state condition updated: `searchQuery.length >= 3` - -#### 4. Frontend: [`ProviderQuickAddModal.vue`](frontend_app/src/components/provider/ProviderQuickAddModal.vue:539) -- New `prefillName?: string` prop added -- Watch on `isOpen` now sets `form.name = props.prefillName` when provided - -#### 5. Frontend: [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue) (complete rewrite) - -**Magic Triangle (Auto-calculation):** -- 3 fields: `amountGross`, `unitPrice`, `quantity` -- Tracks `lastEditedField` to determine which field to auto-calculate -- Formula: `amount_gross = unit_price × quantity` -- 2-decimal precision via `roundTo2Decimals()` - -**Odometer Hint:** -- On modal open, fetches last expense via `GET /expenses/{asset_id}?limit=1&sort=date_desc` -- Shows last known `mileage_at_cost` as a hint below the odometer input - -**Delayed New Provider Flow:** -- If user types a raw vendor name without selecting a provider: - 1. Expense is saved with `external_vendor_name` set - 2. `unit_price` and `quantity` are sent in `data` JSONB payload - 3. After save, `ProviderQuickAddModal` opens automatically with `prefillName` prop - 4. User can create the provider with the name pre-filled - -### ✅ Verifikáció -1. **Sync Engine** → `docker exec sf_api python -m app.scripts.sync_engine` → 1287/1287 elements OK, 0 error -2. **Python syntax** → All 3 backend files compile successfully (0 syntax errors) -3. **Vue TypeScript** → `vue-tsc --noEmit` → Only pre-existing errors (Logo.vue.js, logo1.vue.js), no new errors -4. **Backend search** → 3-char minimum enforced, partial ILIKE city search across all 3 sources -5. **Magic Triangle** → Auto-calculates based on last edited field, 2-decimal precision -6. **Delayed New Provider** → Expense saved first, then ProviderQuickAddModal opens with prefillName +... --- -## P0 BUGFIX: SimpleFuelModal 500 POST crash + 5 frontend issues (Gitea #405) +## Fix: POST /api/v1/expenses/ — 500 Internal Server Error (PG ENUM vs varchar JOIN) -**Dátum:** 2026-07-10 -**Scope:** Backend (expenses.py, provider_service.py) + Frontend (SimpleFuelModal.vue) +**Dátum:** 2026-07-26 -### 🔍 Root Cause Analysis: 500 POST /expenses/ crash +### Probléma +A `POST /api/v1/expenses/` végpont 500-as hibát dobott insurance (biztosítás) költség létrehozásakor. -**Hiba:** `ForeignKeyViolationError` on `fk_asset_costs_vendor_org` +### Gyökér-ok (két rétegben) +1. **Stale `.pyc` cache** → `ModuleNotFoundError: No module named 'app.models.fleet.org_role'` — A konténer régi bytecode-ot futtatott. Megoldva cache törléssel. +2. **PG ENUM vs varchar típusütközés** → `operator does not exist: fleet.orguserrole = character varying` — A `_check_org_capability()` függvény JOIN-t használt `OrganizationMember.role` (ENUM) és `OrgRole.name_key` (varchar) között, amit a PostgreSQL nem tud implicit konvertálni. -**Kiváltó ok:** A frontend [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue) a `vendor_organization_id` mezőbe mindig a `selectedProvider.value!.id` értéket küldte, függetlenül attól, hogy a provider milyen forrásból származott. A [`ProviderAutocomplete`](frontend_app/src/components/provider/ProviderAutocomplete.vue) három forrásból ad vissza találatokat: +### Javítás +[`expenses.py:93-117`](backend/app/api/v1/endpoints/expenses.py:93): +- A JOIN-os lekérdezést két különálló SELECT-re bontottam (az `assets.py:_get_user_permissions()` mintájára) +- **1. lépés:** `select(OrganizationMember.role)` → Python string (asyncpg auto-konvertálja az ENUM-ot) +- **2. lépés:** `select(OrgRole.permissions).where(OrgRole.name_key == role_string)` — varchar=varchar összehasonlítás +- Függvény interfésze változatlan -| Source | Tábla | ID típus | -|--------|-------|----------| -| `verified_org` | `fleet.organizations` | Organization.id ✅ | -| `staged_data` | `marketplace.service_staging` | ServiceStaging.id ❌ | -| `crowd_added` | `marketplace.service_providers` | ServiceProvider.id ❌ | - -Az FK constraint `fk_asset_costs_vendor_org` a [`fleet_finance.asset_costs.vendor_organization_id`](backend/app/models/fleet_finance/models.py:130) oszlopon van, ami a `fleet.organizations.id`-ra hivatkozik. Amikor a felhasználó kiválasztott egy "Shell"-t, ami a `crowd_added` forrásból jött (ServiceProvider.id=6), a frontend `vendor_organization_id=6`-ot küldött, ami nem létezik a `fleet.organizations` táblában → **500-as hiba**. - -### 🔧 FIX 1: Backend FK-safe validation (expenses.py) - -**Fájl:** [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py:618) - -A `create_expense` végpontban, mielőtt létrehoznánk az `AssetCost` rekordot, egy FK-ellenőrző blokk fut le: - -```python -# ── P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation ── -resolved_vendor_org_id = expense.vendor_organization_id -if resolved_vendor_org_id is not None: - org_check_stmt = select(Organization.id).where( - Organization.id == resolved_vendor_org_id, - Organization.is_deleted == False, - ) - org_check_result = await db.execute(org_check_stmt) - org_exists = org_check_result.scalar_one_or_none() - if org_exists is None: - logger.warning( - f"vendor_organization_id={resolved_vendor_org_id} does not exist " - f"in fleet.organizations. Setting to None and using service_provider_id." - ) - resolved_vendor_org_id = None - if resolved_provider_id is None: - resolved_provider_id = expense.vendor_organization_id -``` - -**Logika:** Ha a `vendor_organization_id` nem létezik a `fleet.organizations` táblában, `None`-ra állítjuk, és az ID-t áthelyezzük a `service_provider_id` mezőbe. Ez biztosítja, hogy: -1. Az FK constraint soha nem sérül meg -2. A provider referencia nem vész el (átkerül a `service_provider_id`-ba) -3. A frontend nem kap 500-as hibát - -**További javítás:** Hiányzó `SystemParameter` import hozzáadva. - -### 🔧 FIX 2: Frontend source-aware provider routing (SimpleFuelModal.vue) - -**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:handleSubmit) - -A `handleSubmit` függvény most a provider `source` mezője alapján dönti el, hova kerüljön az ID: - -```typescript -const providerSource = selectedProvider.value?.source || null -const payload: Record = { - 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 - -``` - -A `form` state tartalmazza: `currency: 'HUF' as string` - -### 🔧 FIX 4: Magic Triangle @blur events - -**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:script) - -**Probléma:** A korábbi `@input` események végtelen rekurziót okoztak, mert amikor az `autoCalculate()` visszaírt egy mezőbe, az újra kiváltotta az `@input` eseményt. - -**Megoldás:** Mindhárom mező (`amountGross`, `unitPrice`, `quantity`) `@blur` eseményt használ: - -```typescript -function onAmountGrossBlur(e: Event) { - const val = parseFloat((e.target as HTMLInputElement).value) || 0 - form.amountGross = val - lastEditedField.value = 'amount_gross' - autoCalculate() -} -``` - -**Logika:** -1. A `@blur` csak akkor fut le, amikor a felhasználó elhagyja a mezőt (pl. Tab-bal vagy kattintással) -2. A `lastEditedField` nyomon követi, melyik mezőt szerkesztették utoljára -3. Az `autoCalculate()` a hiányzó mezőt számolja ki: `amount_gross = unit_price × quantity` -4. Nincs végtelen rekurzió, mert a `@blur` nem triggerelődik programozott értékadásra - -### 🔧 FIX 5: Odometer hint response parsing - -**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:fetchOdometerHint) - -**Probléma:** A `GET /expenses/{asset_id}` végpont `{ status, total, data: [...] }` formátumban adja vissza az adatokat, de a kód egy sima tömböt várt. - -**Megoldás:** -```typescript -const body = res.data as { status?: string; total?: number; data?: Array<{ mileage_at_cost?: number | null }> } -const expenses = body.data || [] -``` - -### 🔧 FIX 6: providers/search 500 error (AddressOut validation) - -**Fájl:** [`backend/app/services/provider_service.py`](backend/app/services/provider_service.py:537) - -**Probléma:** A `search_providers()` függvényben az `AddressOut` Pydantic v2 modell hibát dobott, amikor minden mező `None` volt. - -**Megoldás:** Try-except blokk az `AddressOut` konstrukció körül: -```python -if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]): - try: - address_detail = AddressOut(...) - except Exception as addr_e: - logger.warning(...) - address_detail = None -``` - -### ✅ Verifikáció -1. **Python syntax check** → `expenses.py: OK`, `provider_service.py: OK` -2. **Sync Engine** → 1287/1287 elements OK, 0 fixes needed -3. **Frontend TypeScript** → All type-safe, no new compilation errors +### Verifikáció +- `POST /api/v1/expenses/` → **HTTP 201** ✅ +- Biztosítási költség sikeresen létrejött --- -## P0 BUGFIX - Odometer Source of Truth & Sync (Gitea #406) +## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413) -**Dátum:** 2026-07-10 -**Scope:** Frontend (SimpleFuelModal.vue) +**Dátum:** 2026-07-25 -### 🔧 Módosított fájlok +### Probléma +... -#### 1. Frontend: [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:228) -- **Fix:** `fetchLastOdometer()` most már a `/assets/vehicles/{asset_id}` végpontot hívja a `/expenses/{asset_id}` helyett. -- **Logika:** A `Vehicle.current_mileage` mezőt olvassa ki, ami a Source of Truth (automatikusan szinkronizálva a `create_expense` által). -- **Korábbi hiba:** A frontend a `/expenses/{asset_id}` végpontot hívta, ami a költségek listáját adta vissza, nem az aktuális km-állást. - -#### 2. Backend: [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:635) -- **Ellenőrizve:** A `create_expense` függvény (635-637. sor) már tartalmazza az `asset.current_mileage` szinkronizálást: - ```python - if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0): - asset.current_mileage = expense.mileage_at_cost - ``` -- **Nincs változtatás:** A backend logika helyes, nem volt szükség módosításra. - -#### 3. Frontend: [`ComplexExpenseModal.vue`](frontend_app/src/components/dashboard/ComplexExpenseModal.vue) -- **Nincs változtatás:** Ez a komponens nem tartalmaz odometer mezőt, így nem érintett. - -### ✅ Verifikáció -1. **Python syntax check** → `expenses.py: OK` -2. **Sync Engine** → 1287/1287 elements OK, 0 fixes needed -3. **Frontend** → `fetchLastOdometer()` most a `/assets/vehicles/{id}` végpontot használja, ami a `current_mileage`-t adja vissza - ---- - -## P0 EPIC - CostEntryWizard UI/UX Parity & Categories (Gitea #407) - -**Dátum:** 2026-07-10 -**Scope:** Frontend (CostEntryWizard.vue) - -### 🔧 Módosított fájlok - -#### 1. Frontend: [`CostEntryWizard.vue`](frontend_app/src/components/cost/CostEntryWizard.vue) - -**P0 EPIC 1: Global Vehicle Selection** -- `useVehicleStore` importálva, `fetchUserVehicles()` metódus hozzáadva -- `selectedVehicleId` ref + `userVehicles` ref a járműlista kezelésére -- `effectiveVehicle` computed: prioritás szerint oldja fel a járművet (prop > dropdown) -- Template: `` 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 ` + {{ t('gamification.config.days') }} + +

+ {{ t('gamification.config.inactivity_range') }} +

+ + + +
+
+
{{ t('gamification.config.effective_threshold') }}
+
{{ inactivityDays || 180 }} {{ t('gamification.config.days') }}
+
+ {{ t('gamification.config.last_changed') }}: {{ lastUpdated || t('gamification.config.never') }} +
+
+
+ + + +
+ + + {{ t('gamification.config.has_changes') }} + +
+ +

@@ -387,6 +446,10 @@ const penaltyLogic = reactive({ const conversionLogic = reactive({ socialToCreditRate: 100 }) const levelRewards = reactive({ creditsPer10Levels: 50 }) +const inactivityDays = ref(180) +const originalInactivityDays = ref(180) +const lastUpdated = ref(null) + const calculator = reactive({ level: 5, activities: 3, socialPoints: 50 }) const calculatedDailyXp = computed(() => { @@ -468,6 +531,45 @@ function applyConfigToForm(cfg: Record) { } } +async function fetchInactivityThreshold() { + try { + const data = await $fetch<{ key: string; value: any }>('/api/v1/system/parameters/inactivity_threshold_days?scope_level=global', { + headers: getHeaders(), + }) + const val = data.value + if (typeof val === 'object' && val !== null) { + inactivityDays.value = Number(val.value || val.days || 180) + } else { + inactivityDays.value = Number(val) || 180 + } + originalInactivityDays.value = inactivityDays.value + lastUpdated.value = (data as any).updated_at ? new Date((data as any).updated_at).toLocaleDateString('hu-HU') : null + } catch (e) { + // Parameter not seeded yet — default stays 180 + inactivityDays.value = 180 + originalInactivityDays.value = 180 + } +} + +async function saveInactivityThreshold() { + try { + await $fetch('/api/v1/system/parameters/inactivity_threshold_days?scope_level=global', { + method: 'PUT', + headers: getHeaders(), + body: { + value: inactivityDays.value, + description: 'User inactivity threshold in days. If last activity is older, account is suspended and MLM commission bypasses them.', + }, + }) + originalInactivityDays.value = inactivityDays.value + showToast(t('gamification.config.inactivity_updated')) + } catch (e) { + console.error('Failed to save inactivity threshold:', e) + formError.value = t('gamification.config.inactivity_save_error') + setTimeout(() => { formError.value = '' }, 4000) + } +} + async function fetchConfig() { loading.value = true error.value = false @@ -525,6 +627,7 @@ function formatNumber(n: number): string { onMounted(() => { fetchConfig() + fetchInactivityThreshold() }) diff --git a/frontend_admin/pages/logs.vue b/frontend_admin/pages/logs.vue new file mode 100644 index 00000000..0e23e7fa --- /dev/null +++ b/frontend_admin/pages/logs.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend_admin/pages/system/settings.vue b/frontend_admin/pages/system/settings.vue new file mode 100644 index 00000000..94f71d0c --- /dev/null +++ b/frontend_admin/pages/system/settings.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend_app/src/components/cost/CostEntryWizard.vue b/frontend_app/src/components/cost/CostEntryWizard.vue index 989627b7..be92c573 100644 --- a/frontend_app/src/components/cost/CostEntryWizard.vue +++ b/frontend_app/src/components/cost/CostEntryWizard.vue @@ -969,22 +969,130 @@ function resetForm() { } // ── Edit Cost Hydration ── +/** + * P0 BUGFIX (2026-07-26): Complete rewrite of edit hydration to fix 5 bugs: + * + * BUG #1 (CRITICAL): Category hierarchy collapse — correctly splits category_id + * into mainCategory (parent) and subCategory (leaf) using parent_category_id + * from the enriched backend response. + * + * BUG #2 (HIGH): Vehicle not bound when not in store — explicitly sets + * selectedVehicleId from cost.asset_id when props.vehicle is null. + * + * BUG #3 (HIGH): Provider not hydrated — constructs ProviderSearchResult from + * cost.service_provider_id + cost.service_provider_name, or uses + * cost.external_vendor_name as fallback. + * + * BUG #4 (MEDIUM): liters null-check — defensive guard on cost.data?.liters. + * + * BUG #5 (MEDIUM): onMainCategoryChange() clears subCategory during hydration — + * sets mainCategory first (triggers @change → populates subcategories), then + * sets subCategory to the correct leaf ID. + */ watch(() => props.editCost, (cost) => { if (!cost) return - // Hydrate form from existing expense data + + // ── Basic fields (unchanged) ── form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString() - form.mainCategory = String(cost.category_id || '') - form.subCategory = String(cost.sub_category_id || '') - form.netAmount = cost.data?.net_amount || cost.amount_net || 0 - form.vatRate = cost.data?.vat_rate || 27 - form.grossAmount = cost.amount_gross || 0 + form.netAmount = cost.data?.net_amount ?? cost.amount_net ?? 0 + form.vatRate = cost.data?.vat_rate ?? 27 + form.grossAmount = cost.amount_gross ?? 0 form.currency = cost.currency || 'HUF' - form.invoiceNumber = cost.data?.invoice_number || '' - form.invoiceDate = cost.data?.invoice_date || '' - form.paymentDeadline = cost.data?.payment_deadline || '' - form.paymentMethod = cost.data?.payment_method || 'TRANSFER' - form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null - form.liters = cost.data?.liters || null + form.invoiceNumber = cost.data?.invoice_number ?? '' + form.invoiceDate = cost.data?.invoice_date ?? '' + form.paymentDeadline = cost.data?.payment_deadline ?? '' + form.paymentMethod = cost.data?.payment_method ?? 'TRANSFER' + + // ── BUG #4 FIX: Defensive null-check on data.liters ── + const costData = cost.data ?? {} + form.liters = (costData.liters != null && costData.liters !== '') ? costData.liters : null + + // ── Mileage: use enriched mileage_at_cost, fallback to data.mileage_at_cost ── + form.mileageAtCost = (cost.mileage_at_cost != null && cost.mileage_at_cost > 0) + ? cost.mileage_at_cost + : (costData.mileage_at_cost != null ? costData.mileage_at_cost : null) + + // ── BUG #1 FIX: Category hierarchy split ── + // The enriched GET /expenses/by-id/{id} response includes parent_category_id. + // If parent_category_id is set → it's a subcategory: parent goes to main, leaf to sub. + // If parent_category_id is null → category_id IS the main category already. + // Handle the 'data' response wrapper pattern (list endpoints) vs enriched format. + const categoryId = cost.category_id + const parentCategoryId = cost.parent_category_id + + if (parentCategoryId != null) { + // This is a genuine subcategory — set main from parent, sub from category_id + form.mainCategory = String(parentCategoryId) + // Setting mainCategory triggers @change → onMainCategoryChange() synchronously, + // which populates subCategories.value and clears form.subCategory. + // Now re-populate subcategories if they weren't populated (safety net) + if (subCategories.value.length === 0) { + onMainCategoryChange() + } + form.subCategory = String(categoryId) + } else if (categoryId != null) { + // No parent — category_id IS the main category + form.mainCategory = String(categoryId) + form.subCategory = '' + // Still call onMainCategoryChange to populate any subcategories if this + // parent has children (the backend might not have returned parent_category_id) + if (subCategories.value.length === 0) { + onMainCategoryChange() + } + } + + // ── BUG #2 FIX: Vehicle binding when not passed via prop ── + if (!props.vehicle && cost.asset_id) { + selectedVehicleId.value = cost.asset_id + } + + // ── BUG #3 FIX: Provider hydration from enriched response ── + // Priority: service_provider_id > vendor_organization_id > external_vendor_name + const spId = cost.service_provider_id + const spName = cost.service_provider_name + const vendorName = cost.vendor_name + const extVendorName = cost.external_vendor_name + + if (spId != null && spName) { + // Service provider (crowd_added / staged_data) — primary vendor ref + selectedProvider.value = { + id: spId, + name: spName, + category: cost.category_name || null, + specialization: [], + city: null, + address: null, + source: 'crowd_added', + is_verified: false, + rating: null, + } + } else if (cost.vendor_organization_id != null && vendorName) { + // B2B vendor organization + selectedProvider.value = { + id: cost.vendor_organization_id, + name: vendorName, + category: cost.category_name || null, + specialization: [], + city: null, + address: null, + source: 'verified_org', + is_verified: true, + rating: null, + } + } else if (extVendorName) { + // Free-text vendor name — set as a minimal provider object + selectedProvider.value = { + id: 0, + name: extVendorName, + category: cost.category_name || null, + specialization: [], + city: null, + address: null, + source: 'crowd_added', + is_verified: false, + rating: null, + } + } }) // ── File Methods ── diff --git a/frontend_app/src/components/dashboard/GamificationCard.vue b/frontend_app/src/components/dashboard/GamificationCard.vue index d81de825..9314c746 100644 --- a/frontend_app/src/components/dashboard/GamificationCard.vue +++ b/frontend_app/src/components/dashboard/GamificationCard.vue @@ -15,7 +15,7 @@
-

2,450

+

{{ userScore }}

{{ t('dashboard.monthlyScore') }}

@@ -43,7 +43,7 @@ diff --git a/frontend_app/src/components/dashboard/ProfileTrustCard.vue b/frontend_app/src/components/dashboard/ProfileTrustCard.vue index 988d1827..aa5f2b0e 100644 --- a/frontend_app/src/components/dashboard/ProfileTrustCard.vue +++ b/frontend_app/src/components/dashboard/ProfileTrustCard.vue @@ -1,63 +1,154 @@ diff --git a/frontend_app/src/components/dashboard/VehicleFormModal.vue b/frontend_app/src/components/dashboard/VehicleFormModal.vue index 1833e988..b3257617 100644 --- a/frontend_app/src/components/dashboard/VehicleFormModal.vue +++ b/frontend_app/src/components/dashboard/VehicleFormModal.vue @@ -1779,6 +1779,7 @@ const props = defineProps<{ const emit = defineEmits<{ (e: 'close'): void (e: 'saved'): void + (e: 'deleted'): void }>() // ── Tab Configuration ── diff --git a/frontend_app/src/components/finance/TransactionHistory.vue b/frontend_app/src/components/finance/TransactionHistory.vue new file mode 100644 index 00000000..0e16a191 --- /dev/null +++ b/frontend_app/src/components/finance/TransactionHistory.vue @@ -0,0 +1,302 @@ + + + diff --git a/frontend_app/src/components/finance/WalletBalanceCard.vue b/frontend_app/src/components/finance/WalletBalanceCard.vue new file mode 100644 index 00000000..736aecfd --- /dev/null +++ b/frontend_app/src/components/finance/WalletBalanceCard.vue @@ -0,0 +1,188 @@ + + + diff --git a/frontend_app/src/components/header/HeaderLogo.vue b/frontend_app/src/components/header/HeaderLogo.vue index fd3d1335..7c4ea5ba 100644 --- a/frontend_app/src/components/header/HeaderLogo.vue +++ b/frontend_app/src/components/header/HeaderLogo.vue @@ -18,22 +18,14 @@ diff --git a/frontend_app/src/components/vehicle/VehicleDetailModal.vue b/frontend_app/src/components/vehicle/VehicleDetailModal.vue index be805807..2cfc1e47 100644 --- a/frontend_app/src/components/vehicle/VehicleDetailModal.vue +++ b/frontend_app/src/components/vehicle/VehicleDetailModal.vue @@ -901,6 +901,7 @@ import VehiclePlateBadge from './VehiclePlateBadge.vue' import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue' import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue' import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue' +import TechDataTab from '../vehicles/tabs/TechDataTab.vue' const { t } = useI18n() diff --git a/frontend_app/src/i18n/en.ts b/frontend_app/src/i18n/en.ts index 5f29e6d0..b8a8df69 100644 --- a/frontend_app/src/i18n/en.ts +++ b/frontend_app/src/i18n/en.ts @@ -81,6 +81,10 @@ export default { garageLimit: 'Garage Limit', managePlan: 'Manage Plan', indefinite: 'Indefinite', + vehicles: 'vehicles', + unlimited: 'unlimited', + timeRemaining: 'Time Remaining', + days: 'days', }, landing: { openGarage: 'Open Garage', diff --git a/frontend_app/src/i18n/hu.ts b/frontend_app/src/i18n/hu.ts index 254908ca..5eb12118 100644 --- a/frontend_app/src/i18n/hu.ts +++ b/frontend_app/src/i18n/hu.ts @@ -81,6 +81,10 @@ export default { garageLimit: 'Garázs Limit', managePlan: 'Csomag Kezelése', indefinite: 'Határozatlan', + vehicles: 'jármű', + unlimited: 'korlátlan', + timeRemaining: 'Hátralévő idő', + days: 'nap', }, landing: { openGarage: 'Garázs Nyitása', diff --git a/frontend_app/src/layouts/FinanceLayout.vue b/frontend_app/src/layouts/FinanceLayout.vue new file mode 100644 index 00000000..ba64d8ea --- /dev/null +++ b/frontend_app/src/layouts/FinanceLayout.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend_app/src/router/index.ts b/frontend_app/src/router/index.ts index c5bc26f3..652bcaf6 100644 --- a/frontend_app/src/router/index.ts +++ b/frontend_app/src/router/index.ts @@ -84,6 +84,18 @@ const router = createRouter({ component: () => import('../views/ProfileView.vue'), meta: { requiresAuth: true } }, + { + path: '/profile/stats', + name: 'profile-stats', + component: () => import('../views/ProfileStatsView.vue'), + meta: { requiresAuth: true } + }, + { + path: '/profile/settings', + name: 'profile-settings', + component: () => import('../views/ProfileSettingsView.vue'), + meta: { requiresAuth: true } + }, { path: '/verify', name: 'verify', @@ -139,6 +151,51 @@ const router = createRouter({ component: () => import('../views/admin/AdminServicesView.vue') } ] + }, + { + // ── Finance routes ───────────────────────────────────────────────── + // Parent layout provides the standard TopBar (Logo, Profile, Lang) + // and the garage-bg.png background. Child routes render their + // content in 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 } } ] }) diff --git a/frontend_app/src/views/DashboardView.vue b/frontend_app/src/views/DashboardView.vue index 328bf8c6..6a64b0cc 100644 --- a/frontend_app/src/views/DashboardView.vue +++ b/frontend_app/src/views/DashboardView.vue @@ -68,10 +68,8 @@ @open-card="openCard" /> - - + +
diff --git a/frontend_app/src/views/FinanceMainView.vue b/frontend_app/src/views/FinanceMainView.vue new file mode 100644 index 00000000..4341cc1b --- /dev/null +++ b/frontend_app/src/views/FinanceMainView.vue @@ -0,0 +1,266 @@ + + + diff --git a/frontend_app/src/views/FinancePackagesView.vue b/frontend_app/src/views/FinancePackagesView.vue new file mode 100644 index 00000000..7f4bcc68 --- /dev/null +++ b/frontend_app/src/views/FinancePackagesView.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend_app/src/views/FinanceTransactionsView.vue b/frontend_app/src/views/FinanceTransactionsView.vue new file mode 100644 index 00000000..9f1f4375 --- /dev/null +++ b/frontend_app/src/views/FinanceTransactionsView.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend_app/src/views/FinanceWalletsView.vue b/frontend_app/src/views/FinanceWalletsView.vue new file mode 100644 index 00000000..7aa7a99c --- /dev/null +++ b/frontend_app/src/views/FinanceWalletsView.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend_app/src/views/ProfileSettingsView.vue b/frontend_app/src/views/ProfileSettingsView.vue new file mode 100644 index 00000000..6efa5df7 --- /dev/null +++ b/frontend_app/src/views/ProfileSettingsView.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend_app/src/views/ProfileStatsView.vue b/frontend_app/src/views/ProfileStatsView.vue new file mode 100644 index 00000000..f69e084a --- /dev/null +++ b/frontend_app/src/views/ProfileStatsView.vue @@ -0,0 +1,140 @@ + + + diff --git a/frontend_app/src/views/WalletView.vue b/frontend_app/src/views/WalletView.vue new file mode 100644 index 00000000..ece83353 --- /dev/null +++ b/frontend_app/src/views/WalletView.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend_app/src/views/costs/CostsView.vue b/frontend_app/src/views/costs/CostsView.vue index 774295ea..18ae33c2 100644 --- a/frontend_app/src/views/costs/CostsView.vue +++ b/frontend_app/src/views/costs/CostsView.vue @@ -524,12 +524,34 @@ function closeCostWizard() { // ── Cost Entry Wizard (Edit) ── -function openEditWizard(cost: any) { - // Find the vehicle for this cost - const vehicleId = cost.asset_id || cost.vehicle_id - editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null - editCostData.value = cost - showEditWizard.value = true +/** + * Open the edit modal for a specific expense. + * P0 BUGFIX (2026-07-26): Fetches the enriched expense from the new + * GET /expenses/by-id/{id} endpoint instead of passing the raw list object. + * This ensures category_name, parent_category_id, service_provider_name, + * vendor_name, description, and mileage_at_cost are all resolved. + */ +async function openEditWizard(cost: any) { + const expenseId = cost.id + if (!expenseId) return + + try { + const res = await api.get(`/expenses/by-id/${expenseId}`) + const enriched = res.data?.data || res.data + + // Find the vehicle for this cost + const vehicleId = enriched.asset_id || cost.asset_id + editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null + editCostData.value = enriched + showEditWizard.value = true + } catch (err: any) { + console.error('[CostsView] Failed to fetch enriched expense:', err) + // Fallback: use the raw list object if the endpoint fails + const vehicleId = cost.asset_id || cost.vehicle_id + editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null + editCostData.value = cost + showEditWizard.value = true + } } function closeEditWizard() { diff --git a/plans/admin_crash_and_user_repair_blueprint.md b/plans/admin_crash_and_user_repair_blueprint.md new file mode 100644 index 00000000..734e2a88 --- /dev/null +++ b/plans/admin_crash_and_user_repair_blueprint.md @@ -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. diff --git a/plans/bugfix_quad_blueprint_2026-07-26.md b/plans/bugfix_quad_blueprint_2026-07-26.md new file mode 100644 index 00000000..fd66c492 --- /dev/null +++ b/plans/bugfix_quad_blueprint_2026-07-26.md @@ -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.* diff --git a/plans/finance_layout_clone_and_enum_fix_2026-07-26.md b/plans/finance_layout_clone_and_enum_fix_2026-07-26.md new file mode 100644 index 00000000..137df311 --- /dev/null +++ b/plans/finance_layout_clone_and_enum_fix_2026-07-26.md @@ -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 +
+ +
+
+
+ +
+ + +
+ +
+
+

{{ title }}

+

{{ subtitle }}

+
+
+ +
+
+ + +
+ +
+
+
+``` + +### OrganizationLayout.vue Role + +[`OrganizationLayout.vue`](frontend_app/src/layouts/OrganizationLayout.vue) provides: +- `BaseHeader` with company name + hamburger menu +- `main class="relative z-10"` → `` +- **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"` → `` + +### 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 + +
+
+
...header...
+
...cards...
+
+
+``` + +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 + +``` + +**Key changes:** +1. Outer wrapper: `
` (replaces `flex flex-col justify-end min-h-[85vh] pb-8`) +2. Content container: `
` — 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 `