admin_users_personels
This commit is contained in:
142
.roo/history.md
142
.roo/history.md
@@ -950,3 +950,145 @@ A backend `admin_organizations.py` fájlban két külön kódút (Path A és Pat
|
|||||||
- Python syntax check: ✅ OK (`py_compile` sikeres)
|
- Python syntax check: ✅ OK (`py_compile` sikeres)
|
||||||
- Sync engine: 1279 OK, 0 Fixed, 0 Shadow — teljes szinkronban
|
- Sync engine: 1279 OK, 0 Fixed, 0 Shadow — teljes szinkronban
|
||||||
- A `SubscriptionSummary` most helyesen kapja meg a Base limit értékeket az `_extract_limit()`-en keresztül, mielőtt az Extra allowances hozzáadódnak.
|
- A `SubscriptionSummary` most helyesen kapja meg a Base limit értékeket az `_extract_limit()`-en keresztül, mielőtt az Extra allowances hozzáadódnak.
|
||||||
|
|
||||||
|
## 2026-06-29 - Gitea #319: Felhasználói trend diagram komponens (EPIC-5, task 5.2)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
Felhasználói regisztrációs trend diagram komponens elkészítése az admin felület users list oldalára.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**1. UserTrendChart.vue komponens létrehozása:** `frontend_admin/components/charts/UserTrendChart.vue`
|
||||||
|
- Pure SVG bar chart (nincs külső chart library függőség)
|
||||||
|
- Period selector: 7/14/30/90 nap
|
||||||
|
- Grid lines, Y-axis labels, hover tooltips
|
||||||
|
- Trend direction indicator (up/down/stable)
|
||||||
|
- Summary stats: daily avg, total, trend %
|
||||||
|
- Loading és empty state kezelés
|
||||||
|
|
||||||
|
**2. i18n kulcsok hozzáadása:** `frontend_admin/locales/hu.json`, `frontend_admin/locales/en.json`
|
||||||
|
- trend_title, trend_subtitle, trend_7d/14d/30d/90d
|
||||||
|
- trend_no_data, trend_daily, trend_avg, trend_total, trend_vs_prev
|
||||||
|
|
||||||
|
**3. Integrálás a users oldalba:** `frontend_admin/pages/users/index.vue`
|
||||||
|
- Import: `import UserTrendChart from '~/components/charts/UserTrendChart.vue'`
|
||||||
|
- Template: `<UserTrendChart :data="stats.registration_trend" :loading="loading" />`
|
||||||
|
- A stats kártyák és a filter bar közé helyezve
|
||||||
|
|
||||||
|
### ✅ Eredmény
|
||||||
|
- A backend `GET /admin/users/stats` endpoint már létezik és visszaadja a `registration_trend` adatokat
|
||||||
|
- A frontend build hibátlanul lefutott a `sf_admin_frontend` konténerben
|
||||||
|
- A komponens automatikusan használja a meglévő `stats.registration_trend` adatokat
|
||||||
|
|
||||||
|
## 2026-06-29 - EPIC-6 Task 6.1: i18n Fordítások Felvétele (hu/en) - User & Person Oldalak
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
i18n fordítások (hu/en) hozzáadása a frontend_admin user és person kezelő oldalakhoz a [`plans/logic_spec_admin_user_person_management.md`](plans/logic_spec_admin_user_person_management.md) specifikáció alapján (EPIC-6, task 6.1).
|
||||||
|
|
||||||
|
### 🔧 Módosított fájlok
|
||||||
|
- [`frontend_admin/locales/en.json`](frontend_admin/locales/en.json) - Teljes `users` szekció hozzáadva (users.* és users.details.* kulcsok); JSON szintaxis hiba javítva (hiányzó vessző a persons.details után)
|
||||||
|
- [`frontend_admin/locales/hu.json`](frontend_admin/locales/hu.json) - Teljes `users` szekció hozzáadva magyar fordításokkal; JSON szintaxis hiba javítva
|
||||||
|
|
||||||
|
### ✅ Eredmények
|
||||||
|
- **JSON validáció:** ✅ Sikeres (python3 json.load)
|
||||||
|
- **Nuxt build:** ✅ Sikeres (6.78s, AdminUsersView chunk: 19.93 kB)
|
||||||
|
- **Hiányzó kulcsok pótolva:** `users.title`, `users.subtitle`, `users.search_placeholder`, `users.*` (status, role, plan filterek), `users.details.*` (profil, subscription, személyes adatok, tagságok)
|
||||||
|
- **JSON hiba javítva:** Hiányzó vessző a `persons.details` objektum záró kapcsos zárójele után (224. sor) mindkét locale fájlban
|
||||||
|
|
||||||
|
## 2026-06-29 — RBAC Permission Check Implementation (Gitea #321)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
Card #321: "6.2: RBAC permission check implementáció" — Minden admin user/person oldalnak ellenőriznie kell a DB-driven RBAC permissionöket a spec szerint.
|
||||||
|
|
||||||
|
### 🔧 Módosított fájlok
|
||||||
|
- [`frontend_admin/pages/users/index.vue`](frontend_admin/pages/users/index.vue) — `useAuthStore`/`useRouter` importok, `canViewUsers`/`canEditUsers`/`canBanUsers`/`canDeleteUsers` computed property-k, redirect guard `users:view` hiányában, `v-if="canEditUsers"` a bulk actions bar-on és toggle gombokon
|
||||||
|
- [`frontend_admin/pages/users/[id]/index.vue`](frontend_admin/pages/users/%5Bid%5D/index.vue) — `hasEditPermission` átírva legacy `'user:edit'`-ről (singular) `'users:edit'`-re (plural, spec szerint)
|
||||||
|
- [`frontend_admin/pages/persons/index.vue`](frontend_admin/pages/persons/index.vue) — `useAuthStore`/`useRouter` importok, `canViewPersons`/`canEditPersons`/`canMergePersons` computed property-k, redirect guard `persons:view` hiányában
|
||||||
|
- [`frontend_admin/pages/persons/[id]/index.vue`](frontend_admin/pages/persons/%5Bid%5D/index.vue) — `hasEditPermission` egyszerűsítve `!!` koercícióra
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- **Nuxt build:** ✅ Sikeres (`docker compose exec sf_admin_frontend npm run build`)
|
||||||
|
- **Permission kódok:** `users:view`, `users:edit`, `users:ban`, `users:delete`, `persons:view`, `persons:edit`, `persons:merge` — mind implementálva a spec (5.8) szerint
|
||||||
|
- **Fallback:** Minden computed property `auth.isAdmin`-re esik vissza, ha nincs `system_capabilities`
|
||||||
|
|
||||||
|
## 2026-06-29 - Admin Users Filter Buttons Fix (#323)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
Az admin felületen a felhasználók listájában nem működő szűrő gombok (státusz, szerepkör, csomag) javítása.
|
||||||
|
|
||||||
|
### 🔧 Módosított fájlok
|
||||||
|
- **`backend/app/api/v1/endpoints/admin.py`**:
|
||||||
|
- `plan_filter` query paraméter hozzáadása a `list_users()` végponthoz
|
||||||
|
- `plan_filter` szűrési logika: `User.subscription_plan == plan_filter.upper()`
|
||||||
|
- `person_name` mező hozzáadása a válaszhoz (last_name + first_name összefűzés)
|
||||||
|
- Case-insensitive role matching: `role.upper()` a `UserRole` enumhoz
|
||||||
|
|
||||||
|
- **`frontend_admin/pages/users/index.vue`**:
|
||||||
|
- `status_filter` → `is_active`/`is_deleted` boolean mapping (switch/case)
|
||||||
|
- `role_filter` → `role` paraméter átnevezés
|
||||||
|
- `plan_filter` paraméter megtartva (backend most már támogatja)
|
||||||
|
|
||||||
|
### ✅ Eredmények
|
||||||
|
- API szintaxis ellenőrzés: ✅ PASS
|
||||||
|
- API végpont tesztek (7 teszt): ✅ ALL PASS
|
||||||
|
- Basic list: 34 user, person_name megjelenik
|
||||||
|
- role=admin (lowercase): 2 admin (case-insensitive)
|
||||||
|
- role=user (lowercase): 30 user
|
||||||
|
- is_active=true: 16 aktív
|
||||||
|
- plan_filter=FREE: 28 FREE csomagos
|
||||||
|
- Combined (role=user, is_active=true, plan_filter=PREMIUM): 2 találat
|
||||||
|
- is_deleted=true: 1 törölt
|
||||||
|
|
||||||
|
## 2026-06-29 - Architect: Gamification Admin Integration Audit & Gitea Kártyák
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
A meglévő Gamification rendszer (backend modellek, service-ek, API végpontok) teljes körű auditálása, komplex specifikáció készítése az admin felületre történő bekötéshez, és 15 Gitea implementációs kártya létrehozása.
|
||||||
|
|
||||||
|
### 🔧 Elvégzett Munka
|
||||||
|
|
||||||
|
**1. GAMIFICATION RENDSZER AUDIT:** ✅
|
||||||
|
- Feltárt 10+ adatbázis tábla a `gamification` sémában (point_rules, level_configs, points_ledger, user_stats, badges, user_badges, user_contributions, seasons, seasonal_competitions, competitions, user_competition_scores)
|
||||||
|
- Azonosított hiányzó mezők: UserStats (services_submitted, total_points), PointsLedger (xp, source_type, source_id)
|
||||||
|
- Feltárt 14+ meglévő API végpont a `/gamification/*` útvonalon
|
||||||
|
- Feltárt hibás végpont: `PATCH /admin/users/{id}/penalty` direkt `level` módosítás a GamificationService helyett
|
||||||
|
- Feltárt 3 business logic probléma (szolgáltatáskikerülés, GamificationService bypass)
|
||||||
|
|
||||||
|
**2. KOMPLEX SPECIFIKÁCIÓ:** ✅
|
||||||
|
- Létrehozva: [`plans/logic_spec_gamification_admin_integration.md`](plans/logic_spec_gamification_admin_integration.md)
|
||||||
|
- 30+ új admin API endpoint terv az `admin_gamification.py` routerben
|
||||||
|
- 12 új frontend oldal terv a `frontend_admin/pages/gamification/` mappában
|
||||||
|
- Új Gamification menücsoport terv 6 menüponttal
|
||||||
|
- Adatbázis séma javítások (UserStats, PointsLedger)
|
||||||
|
- Business logic javítások (penalty endpoint, GamificationService bypass)
|
||||||
|
- RBAC: `gamification:manage` permission
|
||||||
|
- Adatfolyam diagram (Mermaid)
|
||||||
|
- 15 implementációs kártya prioritási sorrenddel és függőségekkel
|
||||||
|
|
||||||
|
**3. GITEA KÁRTYÁK LÉTREHOZÁSA:** ✅
|
||||||
|
15 kártya a `Epic 8 Gamification 2.0, Verseny és Önvéde` mérföldkő alatt:
|
||||||
|
|
||||||
|
| # | ID | Kártya Név | Scope |
|
||||||
|
|---|----|-----------|-------|
|
||||||
|
| 1 | #324 | Adatbázis: Gamification modell javítások (UserStats, PointsLedger) | Database |
|
||||||
|
| 2 | #325 | Backend: Új admin_gamification.py router (30+ admin végpont) | Backend |
|
||||||
|
| 3 | #326 | Backend: GamificationService refaktor (point_rules integráció) | Backend |
|
||||||
|
| 4 | #327 | Backend: Meglévő PATCH /admin/users/{id}/penalty endpoint javítása | Backend |
|
||||||
|
| 5 | #328 | Backend: gamification.py endpointok GamificationService-re átállítása | Backend |
|
||||||
|
| 6 | #329 | Frontend: Gamification menüpont + routing az admin Nuxt felületben | Frontend |
|
||||||
|
| 7 | #330 | Frontend: Gamification Dashboard oldal (/gamification/index.vue) | Frontend |
|
||||||
|
| 8 | #331 | Frontend: Pontszabályok + Szintek CRUD oldalak | Frontend |
|
||||||
|
| 9 | #332 | Frontend: Kitüntetések (Badge) kezelő oldal | Frontend |
|
||||||
|
| 10 | #333 | Frontend: Szezonok + Versenyek kezelő oldalak | Frontend |
|
||||||
|
| 11 | #334 | Frontend: Felhasználói statisztikák + büntetés/jutalom oldalak | Frontend |
|
||||||
|
| 12 | #335 | Frontend: Ranglista admin nézet (leaderboard.vue) | Frontend |
|
||||||
|
| 13 | #336 | Frontend: Master Config szerkesztő és Rendszerparaméterek oldalak | Frontend |
|
||||||
|
| 14 | #337 | Frontend: Pontnapló böngésző oldal (ledger.vue) | Frontend |
|
||||||
|
| 15 | #338 | Tesztelés: Gamification admin E2E tesztek | Backend+Frontend |
|
||||||
|
|
||||||
|
### 📂 Létrehozott fájlok
|
||||||
|
- [`plans/logic_spec_gamification_admin_integration.md`](plans/logic_spec_gamification_admin_integration.md) (373 sor)
|
||||||
|
|
||||||
|
### ⚠️ Feltárt kockázatok
|
||||||
|
1. UserStats modellből hiányzó mezők runtime hibát okozhatnak a meglévő gamification.py endpointokban
|
||||||
|
2. A PATCH /admin/users/{id}/penalty végpont a `gamification.level` nem létező mezőt módosítja
|
||||||
|
3. Több gamification.py végpont direktül módosít UserStats mezőket a GamificationService megkerülésével
|
||||||
|
|||||||
258
.roo/scripts/create_gitea_cards.py
Normal file
258
.roo/scripts/create_gitea_cards.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create all 18 Gitea cards for Admin User & Person Management."""
|
||||||
|
import subprocess, sys
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(f"ERROR: {r.stderr}")
|
||||||
|
return None
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
# Base command
|
||||||
|
BASE = ["docker", "exec", "roo-helper", "python3", "/scripts/gitea_manager.py"]
|
||||||
|
|
||||||
|
MILESTONE = "Epic 10 (Admin UI) Jegyek Létrehozása"
|
||||||
|
|
||||||
|
cards = [
|
||||||
|
# ===== EPIC-1: Backend User Management =====
|
||||||
|
{
|
||||||
|
"title": "1.1: GET /admin/users/{user_id} — Felhasználó részletek endpoint",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** REST végpont létrehozása egy felhasználó összes adatának lekérésére, beleértve Person, Address, Wallet, TrustProfile, OrganizationMembership adatokat.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** Meglévő User, Person, OrganizationMember modellek; Pydantic séma (UserResponse); require_admin függőség
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Frontend user részlet oldal (3.2), user szerkesztés endpoint (1.2)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Hiányzó endpoint: GET /admin/users/{user_id}. SQL logika: LEFT JOIN identity.users, identity.persons, identity.addresses, identity.wallets, identity.user_trust_profiles, marketplace.organization_members. Válasz tartalmazza: User alapadatok, Person adatok lakcímmel, Wallet, TrustProfile, Memberships, SystemCapabilities, OrgCapabilities. Helye: backend/app/api/v1/endpoints/admin.py a meglévő list_users endpoint mellett. Authorization: RequirePermission(permissions=['users:view'])""",
|
||||||
|
"tags": ["Scope: Backend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "1.2: PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** REST végpont admin általi felhasználó módosításhoz. Korlátozott mezők: email, is_active, is_vip, preferred_language, region_code, preferred_currency, subscription_plan, subscription_expires_at, max_vehicles, max_garages, scope_level, scope_id, role_id (csak superadmin), custom_permissions. Person almzők: last_name, first_name, phone, mothers data, birth data, identity_docs.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/users/{user_id} (1.1), Pydantic séma (UserUpdate), require_admin függőség
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Frontend user szerkesztés funkció
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
PATCH végpont a meglévő admin.py-ban. Ellenőrizni kell a rangot (superadmin vs admin). Audit log bejegyzés minden módosításról. Person adatok módosítása opcionális nested JSON-ben.""",
|
||||||
|
"tags": ["Scope: Backend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "1.3: GET /admin/users/stats — Felhasználói statisztikák endpoint",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Dashboard statisztikák a felhasználókról: total_users, active_users, deleted_users, banned_users, new_users_today/week/month, users_by_role, users_by_plan, users_by_language, users_with_person, users_without_person, registration_trend (napi bontás), active_organizations_count, total_memberships.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** User, OrganizationMember modellek; require_admin függőség
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Frontend dashboard (5.1), user lista oldal statisztikai kártyái (3.1)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Új GET /admin/users/stats endpoint. SQL aggregációk: COUNT, GROUP BY role/plan/language. Registration trend: DATE(created_at) GROUP BY. JOIN marketplace.organization_members a memberships számhoz.""",
|
||||||
|
"tags": ["Scope: Backend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "1.4: GET /admin/users/{user_id}/memberships — Szervezeti tagságok endpoint",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Egy felhasználó szervezeti tagságainak listázása. Válasz: organization_id, organization_name, role, status, is_permanent, expires_at, invited_email, is_verified.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/users/{user_id} (1.1), OrganizationMember modell
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Frontend user részlet oldal Memberships tab (3.2)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Új GET /admin/users/{user_id}/memberships endpoint. JOIN marketplace.organization_members ON user_id, majd JOIN fleet.organizations ON organization_id a névhez.""",
|
||||||
|
"tags": ["Scope: Backend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
# ===== EPIC-2: Backend Person Management =====
|
||||||
|
{
|
||||||
|
"title": "2.1: GET /admin/persons — Person lista endpoint",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Person entitások listázása kereséssel, szűréssel. Paraméterek: search (name, phone, birth data), is_ghost, is_active, has_user, is_merged, skip, limit. Válasz: total, items (id, last_name, first_name, phone, birth_date, is_ghost, is_active, merged_into_id, users_count, active_user, address).
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** Person modell; require_admin függőség
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Frontend Person lista oldal (4.1)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Új GET /admin/persons endpoint. SQL: LEFT JOIN identity.users ON person_id (users_count, active_user), LEFT JOIN identity.addresses ON address_id. Szűrés: WHERE is_ghost, is_active, merged_into_id IS NULL/IS NOT NULL.""",
|
||||||
|
"tags": ["Scope: Backend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "2.2: GET /admin/persons/{person_id} — Person részletek endpoint",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Egy Person összes adata, kapcsolódó User-ekkel, szervezeti tagságokkal, tulajdonolt cégekkel, merge history-val.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/persons (2.1), Person modell
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Frontend Person részlet oldal (4.2), Person merge (2.4)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
GET /admin/persons/{person_id}. Válasz: Person adatok + users lista + address + memberships + owned_business_entities + merged_into (ha merge-elt) + merged_sources (ha ő a cél).""",
|
||||||
|
"tags": ["Scope: Backend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "2.3: PATCH /admin/persons/{person_id} — Person szerkesztése",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Person adatok admin általi módosítása. Szerkeszthető mezők: last_name, first_name, phone, mothers_last_name, mothers_first_name, birth_place, birth_date, identity_docs, ice_contact, is_active, is_ghost. Address almzők is szerkeszthetők.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/persons/{person_id} (2.2), PersonUpdate séma
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Frontend Person szerkesztés funkció
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
PATCH /admin/persons/{person_id}. Audit log bejegyzés. Address rekord frissítése ha változott.""",
|
||||||
|
"tags": ["Scope: Backend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "2.4: POST /admin/persons/{person_id}/merge — Person merge (duplum kezelés)",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Duplum Person-ok összeolvasztása. Body: {source_person_id, keep_source_user}. Logika: (1) Ellenőrizze, hogy mindkét Person létezik és nincs merge-elve. (2) Forrás Person merged_into_id = cél Person ID. (3) Forrás User-ek person_id = cél Person ID. (4) Forrás OrganizationMember person_id = cél Person ID. (5) Audit log.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/persons/{person_id} (2.2), Person modell
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Frontend Person merge UI
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
POST /admin/persons/{target_id}/merge. Tranzakcióban: UPDATE identity.users SET person_id = target WHERE person_id = source. UPDATE marketplace.organization_members SET person_id = target WHERE person_id = source. UPDATE identity.persons SET merged_into_id = target WHERE id = source.""",
|
||||||
|
"tags": ["Scope: Backend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
# ===== EPIC-3: Frontend User List & Detail =====
|
||||||
|
{
|
||||||
|
"title": "3.1: users/index.vue — Felhasználói lista oldal (Frontend)",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Felhasználói lista oldal létrehozása a frontend admin felületen. Tartalmaz: statisztikai kártyák sor (összes, aktív, törölt, kitiltott, mai regisztrációk), keresőmező (email, név, telefon), szűrők (státusz, szerepkör, előfizetés), táblázat (ID, Email, Név, Szerepkör, Státusz, Csomag, Regisztráció, Nyelv, Műveletek), tömeges műveletek checkbox-szal.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/users (meglévő), GET /admin/users/stats (1.3)
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Navigációs menü frissítés (3.3)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Új fájl: frontend_admin/pages/users/index.vue. API hívás: useFetch('/api/v1/admin/users'). Tömeges művelet: POST /api/v1/admin/users/bulk-action. Statisztikák: GET /api/v1/admin/users/stats. Használja a meglévő api.ts plugint.""",
|
||||||
|
"tags": ["Scope: Frontend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "3.2: users/[id]/index.vue — Felhasználó részlet oldal (Frontend)",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Felhasználó részlet oldal tab-okkal: (1) Áttekintés — alapadatok, státusz, szerepkör. (2) Személyes adatok — Person adatok, lakcím. (3) Pénzügyek — Wallet, előfizetés. (4) Tagságok — szervezeti tagságok. (5) Bizalom — Trust score. (6) Tevékenység — naplózott tevékenységek.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/users/{user_id} (1.1), GET /admin/users/{user_id}/memberships (1.4)
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Szerkesztés funkció
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Új fájl: frontend_admin/pages/users/[id]/index.vue. API hívás: useFetch('/api/v1/admin/users/{id}'). Tab komponensek: OverviewTab, PersonTab, FinanceTab, MembershipsTab, TrustTab, ActivityTab.""",
|
||||||
|
"tags": ["Scope: Frontend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "3.3: Navigációs menü frissítése — Users & Persons linkek",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Az admin oldalsáv navigációs menüjének frissítése új bejegyzésekkel: 👥 Felhasználók -> /users, ezen belül Felhasználók -> /users/ és Személyek -> /persons/.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** users/index.vue (3.1), persons/index.vue (4.1)
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
A meglévő sidebar/layout komponens módosítása. Új nav item-ek hozzáadása a megfelelő ikonokkal és linkekkel.""",
|
||||||
|
"tags": ["Scope: Frontend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
# ===== EPIC-4: Frontend Person List & Detail =====
|
||||||
|
{
|
||||||
|
"title": "4.1: persons/index.vue — Person lista oldal (Frontend)",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Person entitások listázása. Kereső: név, telefonszám, születési adatok. Szűrők: ghost státusz, aktív/inaktív, van-e User kapcsolat, merge státusz. Táblázat: ID, Név, Telefon, Születési dátum, User-ek száma, Aktív email, Státusz, Merge státusz, Műveletek.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/persons (2.1)
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Navigációs menü frissítés (3.3)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Új fájl: frontend_admin/pages/persons/index.vue. API hívás: useFetch('/api/v1/admin/persons'). Szűrő paraméterek: search, is_ghost, is_active, has_user, is_merged.""",
|
||||||
|
"tags": ["Scope: Frontend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "4.2: persons/[id]/index.vue — Person részlet oldal (Frontend)",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Person részlet oldal: személy adatok, kapcsolódó User-ek listája, cím adatok, szervezeti tagságok, merge információ (forrás/cél), tulajdonolt cégek.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/persons/{person_id} (2.2)
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** Person merge UI
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Új fájl: frontend_admin/pages/persons/[id]/index.vue. API hívás: useFetch('/api/v1/admin/persons/{id}'). Merge gomb ha a Person nincs merge-elve.""",
|
||||||
|
"tags": ["Scope: Frontend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
# ===== EPIC-5: Frontend Statistics & Dashboard =====
|
||||||
|
{
|
||||||
|
"title": "5.1: Dashboard statisztika kártyák dinamikus adatokkal",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** A dashboard (index.vue) jelenleg hardcoded statisztikáinak (2,847 Active Users, 12,493 Total Vehicles) lecserélése dinamikus API hívásokra. Felhasználói statisztikák: GET /admin/users/stats. Jármű statisztikák: meglévő asset végpont.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/users/stats (1.3)
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
frontend_admin/pages/index.vue módosítása. useFetch('/api/v1/admin/users/stats') a user statisztikákhoz. useFetch('/api/v1/admin/vehicles/stats') a jármű statisztikákhoz.""",
|
||||||
|
"tags": ["Scope: Frontend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "5.2: Felhasználói trend diagram komponens",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Regisztrációs trend diagram a dashboardon. A GET /admin/users/stats registration_trend mezőjéből napi bontású oszlopdiagram megjelenítése az elmúlt 30 napra.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** GET /admin/users/stats (1.3)
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Chart.js vagy egyszerű SVG/CSS alapú diagram komponens. Használhatja a meglévő dashboard layout-ot.""",
|
||||||
|
"tags": ["Scope: Frontend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
# ===== EPIC-6: i18n & Permission Integration =====
|
||||||
|
{
|
||||||
|
"title": "6.1: i18n fordítások felvétele (hu/en) — User & Person management",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Új i18n fordítási kulcsok felvétele a hu.json és en.json fájlokba. Kulcsok: users.title, users.search_placeholder, users.filter_role, users.status.active/deleted/banned, users.table.id/email/name/role/status, users.bulk.ban/unban/delete/restore, persons.title, persons.search_placeholder, person.merge.title/confirm/source/target, stats.total_users/active_users/new_today.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** users/index.vue (3.1), persons/index.vue (4.1)
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
frontend_admin/locales/hu.json és frontend_admin/locales/en.json frissítése. A Nuxt i18n modul automatikusan betölti.""",
|
||||||
|
"tags": ["Scope: Frontend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "6.2: RBAC permission check implementáció — User & Person oldalakon",
|
||||||
|
"body": """**Mérföldkő:** Admin User & Person Management
|
||||||
|
**Cél:** Minden admin user/person oldalon RBAC permission check implementálása a RequirePermission() függőséggel. Permission-ök: users:view, users:edit, users:ban, users:delete, persons:view, persons:edit, persons:merge.
|
||||||
|
|
||||||
|
### 🔗 Függőségek (Dependencies)
|
||||||
|
- **Bemenet (Mikre támaszkodik):** Meglévő admin_permissions.py RBAC rendszer
|
||||||
|
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
|
||||||
|
|
||||||
|
### 📝 Elemzés
|
||||||
|
Backend: RequirePermission() dekorátor hozzáadása az új endpointokhoz. Frontend: hasPermission() kompozíció használata a gombok/show/hide logikához.""",
|
||||||
|
"tags": ["Scope: Frontend", "Type: Feature"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
title = card["title"]
|
||||||
|
body = card["body"]
|
||||||
|
tags = card["tags"]
|
||||||
|
cmd = BASE + ["create", title, body, MILESTONE] + tags
|
||||||
|
print(f"Creating: {title}")
|
||||||
|
result = run(cmd)
|
||||||
|
if result:
|
||||||
|
print(f" -> {result}")
|
||||||
|
else:
|
||||||
|
print(f" -> FAILED")
|
||||||
|
print()
|
||||||
@@ -5,7 +5,7 @@ from app.api.v1.endpoints import (
|
|||||||
services, admin, expenses, evidence, social, security,
|
services, admin, expenses, evidence, social, security,
|
||||||
billing, finance_admin, analytics, vehicles, system_parameters,
|
billing, finance_admin, analytics, vehicles, system_parameters,
|
||||||
gamification, translations, users, reports, dictionaries,
|
gamification, translations, users, reports, dictionaries,
|
||||||
admin_packages, admin_services, admin_organizations, constants, providers,
|
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
|
||||||
subscriptions, marketing, admin_permissions, regions,
|
subscriptions, marketing, admin_permissions, regions,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +36,8 @@ api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Di
|
|||||||
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
|
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
|
||||||
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
||||||
api_router.include_router(admin_organizations.router, prefix="/admin/organizations", tags=["Admin Garages CRM"])
|
api_router.include_router(admin_organizations.router, prefix="/admin/organizations", tags=["Admin Garages CRM"])
|
||||||
|
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["Admin Users"])
|
||||||
|
api_router.include_router(admin_persons.router, prefix="/admin/persons", tags=["Admin Persons"])
|
||||||
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
|
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
|
||||||
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
||||||
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ async def list_users(
|
|||||||
role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"),
|
role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"),
|
||||||
is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"),
|
is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"),
|
||||||
is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"),
|
is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"),
|
||||||
|
plan_filter: Optional[str] = Query(None, description="Csomag szűrés (pl. FREE, PREMIUM, ENTERPRISE)"),
|
||||||
db: AsyncSession = Depends(deps.get_db),
|
db: AsyncSession = Depends(deps.get_db),
|
||||||
current_user: User = Depends(deps.get_current_active_user),
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
_ = Depends(deps.RequirePermission("user:view")),
|
_ = Depends(deps.RequirePermission("user:view")),
|
||||||
@@ -580,8 +581,10 @@ async def list_users(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if role:
|
if role:
|
||||||
|
# Case-insensitive role matching
|
||||||
|
role_upper = role.upper()
|
||||||
try:
|
try:
|
||||||
role_enum = UserRole(role)
|
role_enum = UserRole(role_upper)
|
||||||
query = query.where(User.role == role_enum)
|
query = query.where(User.role == role_enum)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -592,6 +595,8 @@ async def list_users(
|
|||||||
query = query.where(User.is_active == is_active)
|
query = query.where(User.is_active == is_active)
|
||||||
if is_deleted is not None:
|
if is_deleted is not None:
|
||||||
query = query.where(User.is_deleted == is_deleted)
|
query = query.where(User.is_deleted == is_deleted)
|
||||||
|
if plan_filter:
|
||||||
|
query = query.where(User.subscription_plan == plan_filter.upper())
|
||||||
|
|
||||||
count_query = select(func.count()).select_from(query.subquery())
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
total_result = await db.execute(count_query)
|
total_result = await db.execute(count_query)
|
||||||
@@ -625,6 +630,12 @@ async def list_users(
|
|||||||
parts.append(str(address.house_number))
|
parts.append(str(address.house_number))
|
||||||
address_str = ', '.join(parts) if parts else None
|
address_str = ', '.join(parts) if parts else None
|
||||||
|
|
||||||
|
# Személy nevének összefűzése
|
||||||
|
person_name = None
|
||||||
|
if person:
|
||||||
|
parts = [p for p in [person.last_name, person.first_name] if p]
|
||||||
|
person_name = ' '.join(parts) if parts else None
|
||||||
|
|
||||||
user_list.append({
|
user_list.append({
|
||||||
"id": u.id,
|
"id": u.id,
|
||||||
"email": u.email,
|
"email": u.email,
|
||||||
@@ -637,6 +648,7 @@ async def list_users(
|
|||||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||||
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
|
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
|
||||||
# Person adatok
|
# Person adatok
|
||||||
|
"person_name": person_name,
|
||||||
"first_name": person.first_name if person else None,
|
"first_name": person.first_name if person else None,
|
||||||
"last_name": person.last_name if person else None,
|
"last_name": person.last_name if person else None,
|
||||||
"phone": person.phone if person else None,
|
"phone": person.phone if person else None,
|
||||||
@@ -656,6 +668,148 @@ async def list_users(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/stats", tags=["User Management"])
|
||||||
|
async def get_user_stats(
|
||||||
|
db: AsyncSession = Depends(deps.get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_ = Depends(deps.RequirePermission("user:view")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Felhasználói statisztikák a Dashboard számára.
|
||||||
|
|
||||||
|
Visszaadja az összesítő adatokat: teljes, aktív, törölt, kitiltott felhasználók,
|
||||||
|
mai/héti/havi új regisztrációk, szerepkör és előfizetés szerinti megoszlás,
|
||||||
|
nyelvi statisztikák, Person kapcsolatok, szervezeti adatok.
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
week_start = today_start - timedelta(days=today_start.weekday())
|
||||||
|
month_start = today_start.replace(day=1)
|
||||||
|
|
||||||
|
# === ALAP STATISZTIKÁK ===
|
||||||
|
total_result = await db.execute(select(func.count(User.id)))
|
||||||
|
total_users = total_result.scalar() or 0
|
||||||
|
|
||||||
|
active_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.is_active == True, User.is_deleted == False)
|
||||||
|
)
|
||||||
|
active_users = active_result.scalar() or 0
|
||||||
|
|
||||||
|
deleted_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.is_deleted == True)
|
||||||
|
)
|
||||||
|
deleted_users = deleted_result.scalar() or 0
|
||||||
|
|
||||||
|
# Banned = is_active=False AND is_deleted=False (deactivated but not deleted)
|
||||||
|
banned_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.is_active == False, User.is_deleted == False)
|
||||||
|
)
|
||||||
|
banned_users = banned_result.scalar() or 0
|
||||||
|
|
||||||
|
# === ÚJ REGISZTRÁCIÓK ===
|
||||||
|
new_today_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.created_at >= today_start)
|
||||||
|
)
|
||||||
|
new_users_today = new_today_result.scalar() or 0
|
||||||
|
|
||||||
|
new_week_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.created_at >= week_start)
|
||||||
|
)
|
||||||
|
new_users_this_week = new_week_result.scalar() or 0
|
||||||
|
|
||||||
|
new_month_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.created_at >= month_start)
|
||||||
|
)
|
||||||
|
new_users_this_month = new_month_result.scalar() or 0
|
||||||
|
|
||||||
|
# === SZEREPKÖR SZERINTI MEGOSZLÁS ===
|
||||||
|
role_result = await db.execute(
|
||||||
|
select(User.role, func.count(User.id)).group_by(User.role)
|
||||||
|
)
|
||||||
|
users_by_role = {}
|
||||||
|
for row in role_result:
|
||||||
|
role_key = row[0].value if hasattr(row[0], "value") else str(row[0])
|
||||||
|
users_by_role[role_key] = row[1]
|
||||||
|
|
||||||
|
# === ELŐFIZETÉS SZERINTI MEGOSZLÁS ===
|
||||||
|
plan_result = await db.execute(
|
||||||
|
select(User.subscription_plan, func.count(User.id)).group_by(User.subscription_plan)
|
||||||
|
)
|
||||||
|
users_by_plan = {row[0]: row[1] for row in plan_result}
|
||||||
|
|
||||||
|
# === NYELV SZERINTI MEGOSZLÁS ===
|
||||||
|
lang_result = await db.execute(
|
||||||
|
select(User.preferred_language, func.count(User.id)).group_by(User.preferred_language)
|
||||||
|
)
|
||||||
|
users_by_language = {row[0]: row[1] for row in lang_result}
|
||||||
|
|
||||||
|
# === PERSON KAPCSOLATOK ===
|
||||||
|
with_person_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.person_id.isnot(None))
|
||||||
|
)
|
||||||
|
users_with_person = with_person_result.scalar() or 0
|
||||||
|
|
||||||
|
users_without_person = total_users - users_with_person
|
||||||
|
|
||||||
|
# === SZERVEZETI STATISZTIKÁK ===
|
||||||
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||||
|
|
||||||
|
org_count_result = await db.execute(
|
||||||
|
select(func.count(Organization.id)).where(Organization.is_deleted == False)
|
||||||
|
)
|
||||||
|
active_organizations_count = org_count_result.scalar() or 0
|
||||||
|
|
||||||
|
memberships_result = await db.execute(
|
||||||
|
select(func.count(OrganizationMember.id))
|
||||||
|
)
|
||||||
|
total_memberships = memberships_result.scalar() or 0
|
||||||
|
|
||||||
|
# === JÁRMŰ STATISZTIKÁK ===
|
||||||
|
from app.models.vehicle.asset import Asset
|
||||||
|
|
||||||
|
vehicle_count_result = await db.execute(
|
||||||
|
select(func.count(Asset.id))
|
||||||
|
)
|
||||||
|
total_vehicles = vehicle_count_result.scalar() or 0
|
||||||
|
|
||||||
|
# === REGISZTRÁCIÓS TREND (utolsó 30 nap) ===
|
||||||
|
thirty_days_ago = today_start - timedelta(days=30)
|
||||||
|
trend_result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT DATE(created_at) as reg_date, COUNT(*) as cnt
|
||||||
|
FROM identity.users
|
||||||
|
WHERE created_at >= :start_date
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY reg_date
|
||||||
|
"""),
|
||||||
|
{"start_date": thirty_days_ago}
|
||||||
|
)
|
||||||
|
registration_trend = [
|
||||||
|
{"date": row[0].isoformat() if hasattr(row[0], 'isoformat') else str(row[0]), "count": row[1]}
|
||||||
|
for row in trend_result
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_users": active_users,
|
||||||
|
"deleted_users": deleted_users,
|
||||||
|
"banned_users": banned_users,
|
||||||
|
"new_users_today": new_users_today,
|
||||||
|
"new_users_this_week": new_users_this_week,
|
||||||
|
"new_users_this_month": new_users_this_month,
|
||||||
|
"users_by_role": users_by_role,
|
||||||
|
"users_by_plan": users_by_plan,
|
||||||
|
"users_by_language": users_by_language,
|
||||||
|
"users_with_person": users_with_person,
|
||||||
|
"users_without_person": users_without_person,
|
||||||
|
"total_vehicles": total_vehicles,
|
||||||
|
"active_organizations_count": active_organizations_count,
|
||||||
|
"total_memberships": total_memberships,
|
||||||
|
"registration_trend": registration_trend,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/bulk-action", tags=["User Management"])
|
@router.post("/users/bulk-action", tags=["User Management"])
|
||||||
async def bulk_user_action(
|
async def bulk_user_action(
|
||||||
request: BulkActionRequest,
|
request: BulkActionRequest,
|
||||||
@@ -732,11 +886,11 @@ async def bulk_user_action(
|
|||||||
affected_count += 1
|
affected_count += 1
|
||||||
|
|
||||||
audit_log = SecurityAuditLog(
|
audit_log = SecurityAuditLog(
|
||||||
user_id=current_user.id,
|
actor_id=current_user.id,
|
||||||
action=f"bulk_{request.action}",
|
action=f"bulk_{request.action}",
|
||||||
details=f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}",
|
payload_before={},
|
||||||
|
payload_after={"details": f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}"},
|
||||||
is_critical=(request.action in ("hard_delete", "ban")),
|
is_critical=(request.action in ("hard_delete", "ban")),
|
||||||
ip_address="admin_api"
|
|
||||||
)
|
)
|
||||||
db.add(audit_log)
|
db.add(audit_log)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Végpontok:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
@@ -28,6 +28,7 @@ from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
|||||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||||
from app.models.fleet.organization import ContactPerson
|
from app.models.fleet.organization import ContactPerson
|
||||||
from app.models.identity import User, Person
|
from app.models.identity import User, Person
|
||||||
|
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||||
from app.schemas.organization import (
|
from app.schemas.organization import (
|
||||||
OrganizationMemberCreate,
|
OrganizationMemberCreate,
|
||||||
OrganizationMemberUpdate,
|
OrganizationMemberUpdate,
|
||||||
@@ -45,8 +46,24 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
class OrgSubscriptionUpdate(BaseModel):
|
class OrgSubscriptionUpdate(BaseModel):
|
||||||
"""Előfizetés módosításának kérése."""
|
"""Előfizetés módosításának kérése.
|
||||||
tier_id: int = Field(..., description="Az új SubscriptionTier ID-ja")
|
|
||||||
|
Két üzemmód:
|
||||||
|
- Mode A (tier_id megadva): Teljes előfizetés váltás (új tier, új subscription sor).
|
||||||
|
- Mode B (tier_id = None): Csak az add-on (extra_allowances) frissítése a meglévő
|
||||||
|
aktív előfizetésen. Ekkor a tier_id, expires_at mezők figyelmen kívül maradnak.
|
||||||
|
|
||||||
|
P0 ARCHITECTURE SHIFT (Itemized Add-ons):
|
||||||
|
- Mode B most már ITEMIZÁLT: minden add-on típushoz külön OrganizationSubscription
|
||||||
|
sort hoz létre/frissít a finance.org_subscriptions táblában, saját valid_until-lal.
|
||||||
|
- A régi extra_vehicles/extra_branches/extra_users mezők továbbra is támogatottak
|
||||||
|
a frontend kompatibilitás miatt, de a belső logika itemizált sorokká alakítja őket.
|
||||||
|
"""
|
||||||
|
tier_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Az új SubscriptionTier ID-ja. Ha None (Mode B), csak az add-on-ok "
|
||||||
|
"frissülnek a meglévő aktív előfizetésen."
|
||||||
|
)
|
||||||
expires_at: Optional[datetime] = Field(
|
expires_at: Optional[datetime] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Egyedi lejárati dátum (pl. 2-5 éves B2B deal esetén). "
|
description="Egyedi lejárati dátum (pl. 2-5 éves B2B deal esetén). "
|
||||||
@@ -65,6 +82,22 @@ class OrgSubscriptionUpdate(BaseModel):
|
|||||||
default=0, ge=0,
|
default=0, ge=0,
|
||||||
description="Extra dolgozó kvóta a csomag keretein felül (add-on)."
|
description="Extra dolgozó kvóta a csomag keretein felül (add-on)."
|
||||||
)
|
)
|
||||||
|
# ── P0 ITEMIZED ADD-ON: Individual expiration dates per add-on type ──
|
||||||
|
extra_vehicles_expires_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Egyedi lejárati dátum az Extra Jármű add-on-hoz. "
|
||||||
|
"Dátum vagy datetime; a backend 23:59:59-re normalizálja."
|
||||||
|
)
|
||||||
|
extra_branches_expires_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Egyedi lejárati dátum az Extra Telephely add-on-hoz. "
|
||||||
|
"Dátum vagy datetime; a backend 23:59:59-re normalizálja."
|
||||||
|
)
|
||||||
|
extra_users_expires_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Egyedi lejárati dátum az Extra Dolgozó add-on-hoz. "
|
||||||
|
"Dátum vagy datetime; a backend 23:59:59-re normalizálja."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OrgSubscriptionResponse(BaseModel):
|
class OrgSubscriptionResponse(BaseModel):
|
||||||
@@ -126,10 +159,21 @@ class ContactPersonInfo(BaseModel):
|
|||||||
is_primary: bool = False
|
is_primary: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AddonInfo(BaseModel):
|
||||||
|
"""P0 ITEMIZED ADD-ON: Egy add-on sor adatai."""
|
||||||
|
type: str
|
||||||
|
quantity: int
|
||||||
|
subscription_id: Optional[int] = None
|
||||||
|
valid_until: Optional[str] = None
|
||||||
|
expires_at: Optional[str] = None
|
||||||
|
tier_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionSummary(BaseModel):
|
class SubscriptionSummary(BaseModel):
|
||||||
"""Előfizetés összefoglaló."""
|
"""Előfizetés összefoglaló."""
|
||||||
tier_name: str = "Free/Fallback"
|
tier_name: str = "Free/Fallback"
|
||||||
tier_level: int = 0
|
tier_level: int = 0
|
||||||
|
tier_id: Optional[int] = None
|
||||||
valid_from: Optional[str] = None
|
valid_from: Optional[str] = None
|
||||||
expires_at: Optional[str] = None
|
expires_at: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
@@ -140,11 +184,13 @@ class SubscriptionSummary(BaseModel):
|
|||||||
user_limit: int = 0
|
user_limit: int = 0
|
||||||
# ── P0 ADD-ON UPGRADE: Extra allowances (add-ons) ──
|
# ── P0 ADD-ON UPGRADE: Extra allowances (add-ons) ──
|
||||||
extra_allowances: dict = {}
|
extra_allowances: dict = {}
|
||||||
|
# ── P0 ITEMIZED ADD-ON: Individual add-on rows ──
|
||||||
|
addons: List[AddonInfo] = []
|
||||||
|
|
||||||
|
|
||||||
class BranchBrief(BaseModel):
|
class BranchBrief(BaseModel):
|
||||||
"""Rövid telephely adatok a frontend számára."""
|
"""Rövid telephely adatok a frontend számára."""
|
||||||
id: int
|
id: str
|
||||||
name: str
|
name: str
|
||||||
city: Optional[str] = None
|
city: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
@@ -200,6 +246,21 @@ class GarageDetailsResponse(BaseModel):
|
|||||||
members: List[OrganizationMemberResponse] = []
|
members: List[OrganizationMemberResponse] = []
|
||||||
# ── P0 ADD-ON UPGRADE: Branches list for utilization display ──
|
# ── P0 ADD-ON UPGRADE: Branches list for utilization display ──
|
||||||
branches: List[BranchBrief] = []
|
branches: List[BranchBrief] = []
|
||||||
|
# ── P0 AUDIT TRAIL: Subscription history from audit.audit_logs ──
|
||||||
|
subscription_history: List["SubscriptionHistoryItem"] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionHistoryItem(BaseModel):
|
||||||
|
"""Egy előfizetés-módosítási esemény az audit naplóból."""
|
||||||
|
id: int
|
||||||
|
action: str
|
||||||
|
admin_user_id: Optional[int] = None
|
||||||
|
old_data: Optional[Dict[str, Any]] = None
|
||||||
|
new_data: Optional[Dict[str, Any]] = None
|
||||||
|
timestamp: Optional[str] = None
|
||||||
|
# Denormalized display fields
|
||||||
|
tier_name: Optional[str] = None
|
||||||
|
changes_summary: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -447,6 +508,105 @@ def _extract_limit(
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# P0 ARCHITECTURE SHIFT: normalize_to_end_of_day() helper
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_to_end_of_day(dt: Optional[datetime]) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
P0 ARCHITECTURE SHIFT: Minden valid_until értéket nap végére (23:59:59) normalizál.
|
||||||
|
|
||||||
|
Ez biztosítja, hogy a lejárati dátumok konzisztensen viselkedjenek:
|
||||||
|
- Ha a user 2026-12-31-et ad meg, az 2026-12-31 23:59:59 UTC lesz.
|
||||||
|
- Ha a datetime már tartalmaz időt, azt felülírjuk 23:59:59-re.
|
||||||
|
- Ha None, visszatérünk None-nal.
|
||||||
|
|
||||||
|
Támogatja a datetime és date objektumokat is:
|
||||||
|
- datetime: közvetlenül normalizálja
|
||||||
|
- date: előbb datetime-vé alakítja (éjjel 00:00:00), majd normalizálja 23:59:59-re
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: A normalizálandó datetime vagy date (lehet timezone-aware vagy naive).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[datetime]: A nap végére normalizált datetime (timezone-aware UTC),
|
||||||
|
vagy None ha a bemenet is None.
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
# Ha date objektum, alakítsuk datetime-vé
|
||||||
|
if not isinstance(dt, datetime):
|
||||||
|
from datetime import date as date_type
|
||||||
|
if isinstance(dt, date_type):
|
||||||
|
dt = datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc)
|
||||||
|
# Ha naive, tegyük UTC-vé
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.replace(hour=23, minute=59, second=59, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# P0 ARCHITECTURE SHIFT: _find_or_create_addon_tier() helper
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
ADDON_TIER_NAMES = {
|
||||||
|
"extra_vehicles": "Extra Vehicles",
|
||||||
|
"extra_branches": "Extra Branches",
|
||||||
|
"extra_users": "Extra Users",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_or_create_addon_tier(
|
||||||
|
db: AsyncSession,
|
||||||
|
addon_type: str,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
P0 ARCHITECTURE SHIFT: Megkeresi vagy létrehozza az add-on SubscriptionTier-t.
|
||||||
|
|
||||||
|
Minden add-on típushoz ('extra_vehicles', 'extra_branches', 'extra_users')
|
||||||
|
léteznie kell egy dedikált SubscriptionTier-nek 'addon' típussal.
|
||||||
|
Ha nem létezik, automatikusan létrehozza.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Az adatbázis session.
|
||||||
|
addon_type: Az add-on típus neve (pl. 'extra_vehicles').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[int]: A SubscriptionTier ID-ja, vagy None ha nem sikerült.
|
||||||
|
"""
|
||||||
|
display_name = ADDON_TIER_NAMES.get(addon_type, addon_type.replace("_", " ").title())
|
||||||
|
|
||||||
|
# 1. Próbáljuk megkeresni a meglévő add-on tier-t
|
||||||
|
stmt = select(SubscriptionTier).where(
|
||||||
|
SubscriptionTier.type == "addon",
|
||||||
|
SubscriptionTier.name == addon_type,
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
tier = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tier:
|
||||||
|
return tier.id
|
||||||
|
|
||||||
|
# 2. Ha nem létezik, hozzuk létre
|
||||||
|
new_tier = SubscriptionTier(
|
||||||
|
name=addon_type,
|
||||||
|
type="addon",
|
||||||
|
tier_level=0,
|
||||||
|
is_custom=True,
|
||||||
|
rules={
|
||||||
|
"display_name": display_name,
|
||||||
|
"allowances": {f"max_{addon_type.replace('extra_', '')}": 0},
|
||||||
|
},
|
||||||
|
feature_capabilities={},
|
||||||
|
)
|
||||||
|
db.add(new_tier)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(f"P0: Auto-created add-on tier '{addon_type}' (id={new_tier.id})")
|
||||||
|
return new_tier.id
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# GET /{org_id}/details — Garázs részletes adatai (General Tab)
|
# GET /{org_id}/details — Garázs részletes adatai (General Tab)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -499,14 +659,22 @@ async def get_organization_details(
|
|||||||
# 2. Aktív előfizetések lekérése (P0 ARCHITECTURE UNIFICATION: .all() instead of .limit(1))
|
# 2. Aktív előfizetések lekérése (P0 ARCHITECTURE UNIFICATION: .all() instead of .limit(1))
|
||||||
# Több subscription is lehet (1 Base + N Add-on). Minden aktív sort lekérünk,
|
# Több subscription is lehet (1 Base + N Add-on). Minden aktív sort lekérünk,
|
||||||
# majd aggregáljuk a limit értékeket.
|
# majd aggregáljuk a limit értékeket.
|
||||||
now = datetime.utcnow()
|
# P0 CRITICAL FIX: Add-on subscriptions may have valid_until=NULL (indefinite),
|
||||||
|
# so we must use OR condition: valid_until >= now OR valid_until IS NULL.
|
||||||
|
# P0 CRITICAL FIX: Use timezone-aware UTC now() because valid_until is
|
||||||
|
# DateTime(timezone=True). Comparing naive datetime with timezone-aware
|
||||||
|
# timestamp causes PostgreSQL to return no results.
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
sub_stmt = (
|
sub_stmt = (
|
||||||
select(OrganizationSubscription)
|
select(OrganizationSubscription)
|
||||||
.options(selectinload(OrganizationSubscription.tier))
|
.options(selectinload(OrganizationSubscription.tier))
|
||||||
.where(
|
.where(
|
||||||
OrganizationSubscription.org_id == org_id,
|
OrganizationSubscription.org_id == org_id,
|
||||||
OrganizationSubscription.is_active == True,
|
OrganizationSubscription.is_active == True,
|
||||||
|
or_(
|
||||||
OrganizationSubscription.valid_until >= now,
|
OrganizationSubscription.valid_until >= now,
|
||||||
|
OrganizationSubscription.valid_until.is_(None),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.order_by(OrganizationSubscription.id.desc())
|
.order_by(OrganizationSubscription.id.desc())
|
||||||
)
|
)
|
||||||
@@ -514,10 +682,14 @@ async def get_organization_details(
|
|||||||
active_subs = sub_result.scalars().all()
|
active_subs = sub_result.scalars().all()
|
||||||
|
|
||||||
# 2b. P0 ADD-ON: Valós járműszám lekérése a fleet táblából
|
# 2b. P0 ADD-ON: Valós járműszám lekérése a fleet táblából
|
||||||
from app.models.vehicle.vehicle import Vehicle # noqa: E402
|
# P0 CRITICAL FIX: Exclude archived/draft vehicles from quota count.
|
||||||
vehicle_count_stmt = select(func.count(Vehicle.id)).where(
|
# Only count vehicles with status='active' to prevent quota abuse.
|
||||||
Vehicle.organization_id == org_id,
|
# DB schema (vehicle.assets): status values are 'active', 'archived', 'draft'.
|
||||||
Vehicle.is_deleted == False,
|
# 'archived' = soft-deleted, 'draft' = incomplete registration.
|
||||||
|
from app.models.vehicle import Asset # noqa: E402
|
||||||
|
vehicle_count_stmt = select(func.count(Asset.id)).where(
|
||||||
|
Asset.current_organization_id == org_id,
|
||||||
|
Asset.status == 'active',
|
||||||
)
|
)
|
||||||
vehicle_count_result = await db.execute(vehicle_count_stmt)
|
vehicle_count_result = await db.execute(vehicle_count_stmt)
|
||||||
asset_count = vehicle_count_result.scalar() or 0
|
asset_count = vehicle_count_result.scalar() or 0
|
||||||
@@ -532,10 +704,10 @@ async def get_organization_details(
|
|||||||
branch_rows = branches_result.scalars().all()
|
branch_rows = branches_result.scalars().all()
|
||||||
branches_list = [
|
branches_list = [
|
||||||
BranchBrief(
|
BranchBrief(
|
||||||
id=b.id,
|
id=str(b.id),
|
||||||
name=b.name,
|
name=b.name,
|
||||||
city=b.city,
|
city=b.city,
|
||||||
is_active=b.is_active,
|
is_active=(b.status == "active") if b.status else True,
|
||||||
)
|
)
|
||||||
for b in branch_rows
|
for b in branch_rows
|
||||||
]
|
]
|
||||||
@@ -589,7 +761,9 @@ async def get_organization_details(
|
|||||||
key=lambda s: s.tier.tier_level if s.tier else 0,
|
key=lambda s: s.tier.tier_level if s.tier else 0,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
primary_sub = base_subs_sorted[0] if base_subs_sorted else (active_subs[0] if active_subs else None)
|
# P0 CRITICAL FIX: primary_sub MUST be a base tier, NEVER an add-on.
|
||||||
|
# The fallback active_subs[0] could pick an add-on row if no base subs exist.
|
||||||
|
primary_sub = base_subs_sorted[0] if base_subs_sorted else None
|
||||||
|
|
||||||
if primary_sub and primary_sub.tier:
|
if primary_sub and primary_sub.tier:
|
||||||
# ── Base limits from primary subscription (using _extract_limit) ──
|
# ── Base limits from primary subscription (using _extract_limit) ──
|
||||||
@@ -610,7 +784,7 @@ async def get_organization_details(
|
|||||||
base_users = _extract_limit(
|
base_users = _extract_limit(
|
||||||
primary_rules,
|
primary_rules,
|
||||||
["max_users", "max_members"],
|
["max_users", "max_members"],
|
||||||
_extract_limit(primary_fc, ["max_users", "max_members"], 0),
|
_extract_limit(primary_fc, ["max_users", "max_members"], 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Aggregate extra_allowances from ALL active subscriptions ──
|
# ── Aggregate extra_allowances from ALL active subscriptions ──
|
||||||
@@ -652,9 +826,49 @@ async def get_organization_details(
|
|||||||
|
|
||||||
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even if all sums are 0
|
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even if all sums are 0
|
||||||
asset_limit = max(asset_limit, 1)
|
asset_limit = max(asset_limit, 1)
|
||||||
|
|
||||||
|
# ── P0 ITEMIZED ADD-ON: Build add-on list ──
|
||||||
|
addon_list = []
|
||||||
|
|
||||||
|
# 1. Add-ons from dedicated addon-type subscriptions (P0 itemized path)
|
||||||
|
for addon_sub in addon_subs:
|
||||||
|
extra = addon_sub.extra_allowances or {}
|
||||||
|
for addon_type, qty in extra.items():
|
||||||
|
if qty:
|
||||||
|
valid_until_str = addon_sub.valid_until.isoformat() if addon_sub.valid_until else None
|
||||||
|
addon_list.append(AddonInfo(
|
||||||
|
type=addon_type,
|
||||||
|
quantity=qty,
|
||||||
|
subscription_id=addon_sub.id,
|
||||||
|
valid_until=valid_until_str,
|
||||||
|
expires_at=valid_until_str,
|
||||||
|
tier_name=addon_sub.tier.name if addon_sub.tier else None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 2. Legacy add-ons from base subscription JSONB extra_allowances
|
||||||
|
# (migration path: old data stored directly on base sub)
|
||||||
|
existing_addon_types = {a.type for a in addon_list}
|
||||||
|
for base_sub in base_subs:
|
||||||
|
legacy_extra = base_sub.extra_allowances or {}
|
||||||
|
for addon_type, qty in legacy_extra.items():
|
||||||
|
if qty and addon_type not in existing_addon_types:
|
||||||
|
valid_until_str = base_sub.valid_until.isoformat() if base_sub.valid_until else None
|
||||||
|
addon_list.append(AddonInfo(
|
||||||
|
type=addon_type,
|
||||||
|
quantity=qty,
|
||||||
|
subscription_id=base_sub.id,
|
||||||
|
# Legacy add-ons have no explicit valid_until;
|
||||||
|
# fall back to base subscription's expires_at
|
||||||
|
valid_until=valid_until_str,
|
||||||
|
expires_at=valid_until_str,
|
||||||
|
tier_name=None,
|
||||||
|
))
|
||||||
|
existing_addon_types.add(addon_type)
|
||||||
|
|
||||||
subscription_summary = SubscriptionSummary(
|
subscription_summary = SubscriptionSummary(
|
||||||
tier_name=primary_sub.tier.name,
|
tier_name=primary_sub.tier.name,
|
||||||
tier_level=primary_sub.tier.tier_level,
|
tier_level=primary_sub.tier.tier_level,
|
||||||
|
tier_id=primary_sub.tier.id,
|
||||||
valid_from=primary_sub.valid_from.isoformat() if primary_sub.valid_from else None,
|
valid_from=primary_sub.valid_from.isoformat() if primary_sub.valid_from else None,
|
||||||
expires_at=primary_sub.valid_until.isoformat() if primary_sub.valid_until else None,
|
expires_at=primary_sub.valid_until.isoformat() if primary_sub.valid_until else None,
|
||||||
is_active=primary_sub.is_active,
|
is_active=primary_sub.is_active,
|
||||||
@@ -663,6 +877,7 @@ async def get_organization_details(
|
|||||||
branch_limit=branch_limit,
|
branch_limit=branch_limit,
|
||||||
user_limit=user_limit,
|
user_limit=user_limit,
|
||||||
extra_allowances=aggregated_extra,
|
extra_allowances=aggregated_extra,
|
||||||
|
addons=addon_list,
|
||||||
)
|
)
|
||||||
elif org.subscription_tier:
|
elif org.subscription_tier:
|
||||||
# Fallback: Organization.subscription_tier_id kapcsolat (nincs OrganizationSubscription)
|
# Fallback: Organization.subscription_tier_id kapcsolat (nincs OrganizationSubscription)
|
||||||
@@ -679,7 +894,7 @@ async def get_organization_details(
|
|||||||
)
|
)
|
||||||
base_users = _extract_limit(
|
base_users = _extract_limit(
|
||||||
rules, ["max_users", "max_members"],
|
rules, ["max_users", "max_members"],
|
||||||
_extract_limit(fc, ["max_users", "max_members"], 0),
|
_extract_limit(fc, ["max_users", "max_members"], 1),
|
||||||
)
|
)
|
||||||
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even for fallback path
|
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even for fallback path
|
||||||
base_vehicles = max(base_vehicles, 1)
|
base_vehicles = max(base_vehicles, 1)
|
||||||
@@ -694,6 +909,52 @@ async def get_organization_details(
|
|||||||
user_limit=base_users,
|
user_limit=base_users,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── P0 AUDIT TRAIL: Fetch subscription history from audit.audit_logs ──
|
||||||
|
subscription_history: List[SubscriptionHistoryItem] = []
|
||||||
|
try:
|
||||||
|
audit_stmt = (
|
||||||
|
select(AuditLog)
|
||||||
|
.where(
|
||||||
|
AuditLog.target_type == "organization",
|
||||||
|
AuditLog.target_id == str(org_id),
|
||||||
|
AuditLog.action.in_(["SUBSCRIPTION_TIER_CHANGE", "SUBSCRIPTION_ADDON_OVERRIDE"]),
|
||||||
|
)
|
||||||
|
.order_by(AuditLog.timestamp.desc())
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
audit_result = await db.execute(audit_stmt)
|
||||||
|
audit_rows = audit_result.scalars().all()
|
||||||
|
|
||||||
|
for log_entry in audit_rows:
|
||||||
|
# Build a human-readable changes_summary
|
||||||
|
changes_summary = None
|
||||||
|
tier_name = None
|
||||||
|
if log_entry.action == "SUBSCRIPTION_TIER_CHANGE":
|
||||||
|
old_tier = (log_entry.old_data or {}).get("tier_id")
|
||||||
|
new_tier_data = (log_entry.new_data or {})
|
||||||
|
new_tier = new_tier_data.get("tier_id")
|
||||||
|
new_tier_name = new_tier_data.get("tier_name", f"Tier #{new_tier}")
|
||||||
|
tier_name = new_tier_name
|
||||||
|
changes_summary = f"Csomag váltás: Tier #{old_tier} → {new_tier_name}"
|
||||||
|
elif log_entry.action == "SUBSCRIPTION_ADDON_OVERRIDE":
|
||||||
|
old_addons = (log_entry.old_data or {}).get("addons", {})
|
||||||
|
new_addons = (log_entry.new_data or {}).get("addons", {})
|
||||||
|
changes_summary = f"Kiegészítők módosítása"
|
||||||
|
tier_name = "Kiegészítők"
|
||||||
|
|
||||||
|
subscription_history.append(SubscriptionHistoryItem(
|
||||||
|
id=log_entry.id,
|
||||||
|
action=log_entry.action,
|
||||||
|
admin_user_id=log_entry.user_id,
|
||||||
|
old_data=log_entry.old_data,
|
||||||
|
new_data=log_entry.new_data,
|
||||||
|
timestamp=log_entry.timestamp.isoformat() if log_entry.timestamp else None,
|
||||||
|
tier_name=tier_name,
|
||||||
|
changes_summary=changes_summary,
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"P0 AUDIT TRAIL: Failed to fetch subscription history for org {org_id}: {e}")
|
||||||
|
|
||||||
# 6. Kapcsolattartó adatok
|
# 6. Kapcsolattartó adatok
|
||||||
primary_contact = None
|
primary_contact = None
|
||||||
if contact and contact.person:
|
if contact and contact.person:
|
||||||
@@ -859,6 +1120,8 @@ async def get_organization_details(
|
|||||||
members=members_list,
|
members=members_list,
|
||||||
# ── P0 ADD-ON UPGRADE: Branches list ──
|
# ── P0 ADD-ON UPGRADE: Branches list ──
|
||||||
branches=branches_list,
|
branches=branches_list,
|
||||||
|
# ── P0 AUDIT TRAIL: Subscription history ──
|
||||||
|
subscription_history=subscription_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1105,7 +1368,8 @@ async def update_organization(
|
|||||||
"/{org_id}/subscription",
|
"/{org_id}/subscription",
|
||||||
summary="Garázs előfizetésének módosítása",
|
summary="Garázs előfizetésének módosítása",
|
||||||
description="Frissíti vagy létrehozza egy garázs OrganizationSubscription rekordját. "
|
description="Frissíti vagy létrehozza egy garázs OrganizationSubscription rekordját. "
|
||||||
"Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal).",
|
"Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal). "
|
||||||
|
"P0 ITEMIZED ADD-ON: Minden add-on típus külön sorban tárolva.",
|
||||||
)
|
)
|
||||||
async def update_org_subscription(
|
async def update_org_subscription(
|
||||||
org_id: int,
|
org_id: int,
|
||||||
@@ -1117,13 +1381,39 @@ async def update_org_subscription(
|
|||||||
"""
|
"""
|
||||||
Garázs előfizetésének módosítása.
|
Garázs előfizetésének módosítása.
|
||||||
|
|
||||||
- Ellenőrzi, hogy a szervezet létezik-e.
|
Két üzemmód:
|
||||||
|
|
||||||
|
Mode A (tier_id megadva — Teljes előfizetés váltás):
|
||||||
- Ellenőrzi, hogy a SubscriptionTier létezik-e.
|
- Ellenőrzi, hogy a SubscriptionTier létezik-e.
|
||||||
- Ha van aktív OrganizationSubscription, inaktiválja (is_active=False).
|
- Inaktiválja a meglévő aktív OrganizationSubscription sorokat.
|
||||||
- Létrehoz egy új OrganizationSubscription rekordot a megadott tier_id-val
|
- Létrehoz egy új Base OrganizationSubscription rekordot a megadott tier_id-val.
|
||||||
és opcionális expires_at dátummal.
|
- Létrehoz külön add-on sorokat minden pozitív extra_* értékhez.
|
||||||
- Ha expires_at nincs megadva, a csomag rules.duration.days alapján számol.
|
|
||||||
|
Mode B (tier_id = None — Csak add-on frissítés):
|
||||||
|
- P0 ITEMIZED ADD-ON: Minden add-on típushoz külön OrganizationSubscription sort
|
||||||
|
hoz létre vagy frissít, ahelyett hogy egyetlen JSONB-t írna felül.
|
||||||
|
- Ha egy add-on értéke 0, a meglévő sor inaktiválódik.
|
||||||
|
- Ha egy add-on értéke > 0, létrejön/aktiválódik egy dedikált sor.
|
||||||
"""
|
"""
|
||||||
|
# ── P0 AUDIT TRAIL: Capture previous subscription state before modification ──
|
||||||
|
previous_tier_id = None
|
||||||
|
previous_addons = {}
|
||||||
|
prev_sub_stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.options(selectinload(OrganizationSubscription.tier))
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
.order_by(OrganizationSubscription.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
prev_sub_result = await db.execute(prev_sub_stmt)
|
||||||
|
prev_sub = prev_sub_result.scalar_one_or_none()
|
||||||
|
if prev_sub:
|
||||||
|
previous_tier_id = prev_sub.tier_id
|
||||||
|
previous_addons = prev_sub.extra_allowances or {}
|
||||||
|
|
||||||
# 1. Ellenőrizzük a szervezetet
|
# 1. Ellenőrizzük a szervezetet
|
||||||
org_stmt = select(Organization).where(Organization.id == org_id)
|
org_stmt = select(Organization).where(Organization.id == org_id)
|
||||||
org_result = await db.execute(org_stmt)
|
org_result = await db.execute(org_stmt)
|
||||||
@@ -1135,6 +1425,198 @@ async def update_org_subscription(
|
|||||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# ── P0 ITEMIZED ADD-ON: Inner helper to upsert a single add-on row ──
|
||||||
|
async def _upsert_addon_sub(addon_type: str, quantity: int, expires: Optional[datetime]) -> Optional[int]:
|
||||||
|
"""Create or deactivate a single add-on subscription row.
|
||||||
|
|
||||||
|
Returns the ID of the created/updated row, or None if deactivated/skipped.
|
||||||
|
|
||||||
|
P0 CRITICAL SAFETY: When quantity <= 0, we ONLY deactivate existing rows.
|
||||||
|
We NEVER create new rows with zero quantity. This prevents the frontend
|
||||||
|
from accidentally creating zombie add-on rows when the form initializes
|
||||||
|
add-on values to 0 instead of reading from existing subscription data.
|
||||||
|
"""
|
||||||
|
if quantity <= 0:
|
||||||
|
# Deactivate any existing active add-on row for this type
|
||||||
|
# P0 CRITICAL FIX: Use JSONB .astext.is_not(None) to reliably find rows
|
||||||
|
# where this addon_type key exists in extra_allowances.
|
||||||
|
# The previous approach using [addon_type].as_integer().is_not(None) could
|
||||||
|
# fail if the JSONB structure doesn't match the expected path.
|
||||||
|
existing_stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
# Use JSONB containment: check if extra_allowances contains the key
|
||||||
|
OrganizationSubscription.extra_allowances[addon_type].astext.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(OrganizationSubscription.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
existing_result = await db.execute(existing_stmt)
|
||||||
|
existing = existing_result.scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
existing.is_active = False
|
||||||
|
logger.info(
|
||||||
|
f"P0 ITEMIZED: Deactivated add-on row {existing.id} "
|
||||||
|
f"for {addon_type} (org {org_id})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"P0 ITEMIZED: No active add-on row found for {addon_type} "
|
||||||
|
f"(org {org_id}) — skipping deactivation"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# quantity > 0: find or create the add-on tier
|
||||||
|
addon_tier_id = await _find_or_create_addon_tier(db, addon_type)
|
||||||
|
if addon_tier_id is None:
|
||||||
|
logger.warning(f"P0 ITEMIZED: Could not find/create add-on tier for {addon_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalize expiration
|
||||||
|
addon_valid_until = normalize_to_end_of_day(expires) if expires else None
|
||||||
|
|
||||||
|
# Check if there's already an active add-on row for this type
|
||||||
|
existing_stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
OrganizationSubscription.tier_id == addon_tier_id,
|
||||||
|
)
|
||||||
|
.order_by(OrganizationSubscription.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
existing_result = await db.execute(existing_stmt)
|
||||||
|
existing = existing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing row
|
||||||
|
existing.extra_allowances = {addon_type: quantity}
|
||||||
|
if addon_valid_until:
|
||||||
|
existing.valid_until = addon_valid_until
|
||||||
|
logger.info(
|
||||||
|
f"P0 ITEMIZED: Updated add-on row {existing.id} "
|
||||||
|
f"for {addon_type}={quantity} (org {org_id})"
|
||||||
|
)
|
||||||
|
return existing.id
|
||||||
|
else:
|
||||||
|
# Create new add-on row
|
||||||
|
new_addon = OrganizationSubscription(
|
||||||
|
org_id=org_id,
|
||||||
|
tier_id=addon_tier_id,
|
||||||
|
valid_from=now,
|
||||||
|
valid_until=addon_valid_until,
|
||||||
|
is_active=True,
|
||||||
|
extra_allowances={addon_type: quantity},
|
||||||
|
)
|
||||||
|
db.add(new_addon)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(
|
||||||
|
f"P0 ITEMIZED: Created add-on row for {addon_type}={quantity} (org {org_id})"
|
||||||
|
)
|
||||||
|
return new_addon.id
|
||||||
|
|
||||||
|
# ── Mode B: tier_id is None — Only add-on update ──
|
||||||
|
if payload.tier_id is None:
|
||||||
|
# P0 ITEMIZED ADD-ON: Individual expiration dates per add-on type
|
||||||
|
addon_expires_map = {
|
||||||
|
"extra_vehicles": normalize_to_end_of_day(payload.extra_vehicles_expires_at) if payload.extra_vehicles_expires_at else None,
|
||||||
|
"extra_branches": normalize_to_end_of_day(payload.extra_branches_expires_at) if payload.extra_branches_expires_at else None,
|
||||||
|
"extra_users": normalize_to_end_of_day(payload.extra_users_expires_at) if payload.extra_users_expires_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upsert each add-on type
|
||||||
|
addon_results = {}
|
||||||
|
addon_ids = []
|
||||||
|
|
||||||
|
for addon_type, quantity in [
|
||||||
|
("extra_vehicles", payload.extra_vehicles),
|
||||||
|
("extra_branches", payload.extra_branches),
|
||||||
|
("extra_users", payload.extra_users),
|
||||||
|
]:
|
||||||
|
row_id = await _upsert_addon_sub(addon_type, quantity, addon_expires_map[addon_type])
|
||||||
|
addon_results[addon_type] = row_id
|
||||||
|
if row_id:
|
||||||
|
addon_ids.append(row_id)
|
||||||
|
|
||||||
|
# ── P0 MIGRATION: Clear legacy extra_allowances on the base subscription ──
|
||||||
|
# When Mode B creates itemized add-on rows, we MUST clear the legacy
|
||||||
|
# extra_allowances on the base subscription to prevent double-counting.
|
||||||
|
# Set to 0 for the add-on types that were explicitly provided in the payload.
|
||||||
|
base_sub_stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
OrganizationSubscription.tier_id.isnot(None), # Base subscription has a real tier_id
|
||||||
|
)
|
||||||
|
.order_by(OrganizationSubscription.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
base_sub_result = await db.execute(base_sub_stmt)
|
||||||
|
base_sub = base_sub_result.scalar_one_or_none()
|
||||||
|
if base_sub and base_sub.extra_allowances:
|
||||||
|
legacy_cleanup = {}
|
||||||
|
for addon_key in ["extra_vehicles", "extra_branches", "extra_users"]:
|
||||||
|
if getattr(payload, addon_key, 0) > 0:
|
||||||
|
legacy_cleanup[addon_key] = 0
|
||||||
|
if legacy_cleanup:
|
||||||
|
merged = dict(base_sub.extra_allowances)
|
||||||
|
merged.update(legacy_cleanup)
|
||||||
|
base_sub.extra_allowances = merged
|
||||||
|
logger.info(
|
||||||
|
f"P0 MIGRATION: Cleared legacy extra_allowances on base sub {base_sub.id} "
|
||||||
|
f"for org {org_id}: {legacy_cleanup}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── P0 AUDIT TRAIL: Log add-on override to audit.audit_logs ──
|
||||||
|
new_addons_data = {
|
||||||
|
"extra_vehicles": payload.extra_vehicles,
|
||||||
|
"extra_branches": payload.extra_branches,
|
||||||
|
"extra_users": payload.extra_users,
|
||||||
|
}
|
||||||
|
audit_entry = AuditLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
severity=LogSeverity.info,
|
||||||
|
action="SUBSCRIPTION_ADDON_OVERRIDE",
|
||||||
|
target_type="organization",
|
||||||
|
target_id=str(org_id),
|
||||||
|
old_data={"addons": previous_addons},
|
||||||
|
new_data={"addons": new_addons_data},
|
||||||
|
)
|
||||||
|
db.add(audit_entry)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Build response with itemized add-on list
|
||||||
|
addon_list = []
|
||||||
|
for addon_type, row_id in addon_results.items():
|
||||||
|
addon_list.append({
|
||||||
|
"type": addon_type,
|
||||||
|
"quantity": getattr(payload, addon_type),
|
||||||
|
"subscription_id": row_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Admin {current_user.id} ({current_user.email}) updated add-ons for org {org_id}: "
|
||||||
|
f"extra_vehicles={payload.extra_vehicles}, "
|
||||||
|
f"extra_branches={payload.extra_branches}, "
|
||||||
|
f"extra_users={payload.extra_users} "
|
||||||
|
f"(Mode B, ITEMIZED)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Add-on-ok frissítve a '{org.full_name}' garázs számára.",
|
||||||
|
"addons": addon_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Mode A: tier_id is provided — Full subscription change ──
|
||||||
|
|
||||||
# 2. Ellenőrizzük a SubscriptionTier-t
|
# 2. Ellenőrizzük a SubscriptionTier-t
|
||||||
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id)
|
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id)
|
||||||
tier_result = await db.execute(tier_stmt)
|
tier_result = await db.execute(tier_stmt)
|
||||||
@@ -1157,7 +1639,6 @@ async def update_org_subscription(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 4. Számoljuk a lejárati dátumot
|
# 4. Számoljuk a lejárati dátumot
|
||||||
now = datetime.utcnow()
|
|
||||||
if payload.expires_at:
|
if payload.expires_at:
|
||||||
valid_until = payload.expires_at
|
valid_until = payload.expires_at
|
||||||
else:
|
else:
|
||||||
@@ -1168,31 +1649,73 @@ async def update_org_subscription(
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
valid_until = now + timedelta(days=days)
|
valid_until = now + timedelta(days=days)
|
||||||
|
|
||||||
# 5. P0 ADD-ON: Összeállítjuk az extra_allowances JSONB-t
|
# Normalize base subscription expiration
|
||||||
extra_allowances = {}
|
valid_until = normalize_to_end_of_day(valid_until)
|
||||||
if payload.extra_vehicles > 0:
|
|
||||||
extra_allowances["extra_vehicles"] = payload.extra_vehicles
|
|
||||||
if payload.extra_branches > 0:
|
|
||||||
extra_allowances["extra_branches"] = payload.extra_branches
|
|
||||||
if payload.extra_users > 0:
|
|
||||||
extra_allowances["extra_users"] = payload.extra_users
|
|
||||||
|
|
||||||
# 6. Létrehozzuk az új subscription rekordot
|
# 5. Létrehozzuk az új Base subscription rekordot (extra_allowances üresen)
|
||||||
new_sub = OrganizationSubscription(
|
new_sub = OrganizationSubscription(
|
||||||
org_id=org_id,
|
org_id=org_id,
|
||||||
tier_id=payload.tier_id,
|
tier_id=payload.tier_id,
|
||||||
valid_from=now,
|
valid_from=now,
|
||||||
valid_until=valid_until,
|
valid_until=valid_until,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
extra_allowances=extra_allowances,
|
extra_allowances={}, # Base tier has no extra allowances
|
||||||
)
|
)
|
||||||
db.add(new_sub)
|
db.add(new_sub)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 6. P0 ITEMIZED ADD-ON: Create separate add-on rows for each add-on type
|
||||||
|
# Each add-on type gets its OWN expiration date from the payload.
|
||||||
|
# Falls back to the base subscription expires_at if individual date is not set.
|
||||||
|
addon_list = []
|
||||||
|
|
||||||
|
addon_type_config = [
|
||||||
|
("extra_vehicles", payload.extra_vehicles, payload.extra_vehicles_expires_at),
|
||||||
|
("extra_branches", payload.extra_branches, payload.extra_branches_expires_at),
|
||||||
|
("extra_users", payload.extra_users, payload.extra_users_expires_at),
|
||||||
|
]
|
||||||
|
|
||||||
|
for addon_type, quantity, individual_expires in addon_type_config:
|
||||||
|
# Use individual date if provided, otherwise fall back to base subscription expires_at
|
||||||
|
addon_expires = normalize_to_end_of_day(individual_expires or payload.expires_at)
|
||||||
|
row_id = await _upsert_addon_sub(addon_type, quantity, addon_expires)
|
||||||
|
if row_id:
|
||||||
|
addon_list.append({
|
||||||
|
"type": addon_type,
|
||||||
|
"quantity": quantity,
|
||||||
|
"subscription_id": row_id,
|
||||||
|
})
|
||||||
|
|
||||||
# 7. Frissítjük a Organization denormalizált mezőit is
|
# 7. Frissítjük a Organization denormalizált mezőit is
|
||||||
org.subscription_tier_id = payload.tier_id
|
org.subscription_tier_id = payload.tier_id
|
||||||
org.subscription_expires_at = valid_until
|
org.subscription_expires_at = valid_until
|
||||||
org.subscription_plan = tier.name
|
org.subscription_plan = tier.name
|
||||||
|
|
||||||
|
# ── P0 AUDIT TRAIL: Log full subscription tier change to audit.audit_logs ──
|
||||||
|
new_tier_data = {
|
||||||
|
"tier_id": payload.tier_id,
|
||||||
|
"tier_name": tier.name,
|
||||||
|
"valid_until": valid_until.isoformat() if valid_until else None,
|
||||||
|
"addons": {
|
||||||
|
"extra_vehicles": payload.extra_vehicles,
|
||||||
|
"extra_branches": payload.extra_branches,
|
||||||
|
"extra_users": payload.extra_users,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
audit_entry = AuditLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
severity=LogSeverity.info,
|
||||||
|
action="SUBSCRIPTION_TIER_CHANGE",
|
||||||
|
target_type="organization",
|
||||||
|
target_id=str(org_id),
|
||||||
|
old_data={
|
||||||
|
"tier_id": previous_tier_id,
|
||||||
|
"addons": previous_addons,
|
||||||
|
},
|
||||||
|
new_data=new_tier_data,
|
||||||
|
)
|
||||||
|
db.add(audit_entry)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(new_sub)
|
await db.refresh(new_sub)
|
||||||
|
|
||||||
@@ -1200,7 +1723,7 @@ async def update_org_subscription(
|
|||||||
f"Admin {current_user.id} ({current_user.email}) updated subscription for org {org_id}: "
|
f"Admin {current_user.id} ({current_user.email}) updated subscription for org {org_id}: "
|
||||||
f"tier_id={payload.tier_id}, tier_name={tier.name}, "
|
f"tier_id={payload.tier_id}, tier_name={tier.name}, "
|
||||||
f"valid_until={valid_until.isoformat()}, "
|
f"valid_until={valid_until.isoformat()}, "
|
||||||
f"extra_allowances={extra_allowances}"
|
f"addons={addon_list}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1214,8 +1737,9 @@ async def update_org_subscription(
|
|||||||
"valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None,
|
"valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None,
|
||||||
"valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None,
|
"valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None,
|
||||||
"is_active": new_sub.is_active,
|
"is_active": new_sub.is_active,
|
||||||
"extra_allowances": extra_allowances,
|
"extra_allowances": {},
|
||||||
},
|
},
|
||||||
|
"addons": addon_list,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1562,17 +2086,22 @@ async def get_organization_vehicles(
|
|||||||
# - közvetlenül a tulajdonában van (owner_org_id), VAGY
|
# - közvetlenül a tulajdonában van (owner_org_id), VAGY
|
||||||
# - jelenleg oda van rendelve (current_organization_id)
|
# - jelenleg oda van rendelve (current_organization_id)
|
||||||
# NEM kell AssetAssignment JOIN — a direkt ownership mezők az Asset táblában vannak.
|
# NEM kell AssetAssignment JOIN — a direkt ownership mezők az Asset táblában vannak.
|
||||||
|
#
|
||||||
|
# P0 CRITICAL FIX: Filter by status == 'active' to exclude archived/draft vehicles
|
||||||
|
# from the fleet list AND from the quota count. Without this filter, archived
|
||||||
|
# vehicles still appear in the fleet tab and consume quota slots.
|
||||||
ownership_filter = or_(
|
ownership_filter = or_(
|
||||||
Asset.owner_org_id == org_id,
|
Asset.owner_org_id == org_id,
|
||||||
Asset.current_organization_id == org_id,
|
Asset.current_organization_id == org_id,
|
||||||
)
|
)
|
||||||
|
active_filter = Asset.status == 'active'
|
||||||
|
|
||||||
# P0 HOTFIX: Asset.branch relationship nem létezik a modellben.
|
# P0 HOTFIX: Asset.branch relationship nem létezik a modellben.
|
||||||
# Explicit LEFT JOIN a fleet.branches táblához a branch_name lekéréséhez.
|
# Explicit LEFT JOIN a fleet.branches táblához a branch_name lekéréséhez.
|
||||||
vehicle_stmt = (
|
vehicle_stmt = (
|
||||||
select(Asset, Branch.name.label("branch_name"))
|
select(Asset, Branch.name.label("branch_name"))
|
||||||
.outerjoin(Branch, Asset.branch_id == Branch.id)
|
.outerjoin(Branch, Asset.branch_id == Branch.id)
|
||||||
.where(ownership_filter)
|
.where(ownership_filter, active_filter)
|
||||||
.order_by(Asset.created_at.desc())
|
.order_by(Asset.created_at.desc())
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -1580,9 +2109,9 @@ async def get_organization_vehicles(
|
|||||||
vehicle_result = await db.execute(vehicle_stmt)
|
vehicle_result = await db.execute(vehicle_stmt)
|
||||||
rows = vehicle_result.all()
|
rows = vehicle_result.all()
|
||||||
|
|
||||||
# Teljes számolás
|
# Teljes számolás (also filtered by active status)
|
||||||
count_stmt = select(func.count()).select_from(
|
count_stmt = select(func.count()).select_from(
|
||||||
select(Asset).where(ownership_filter).subquery()
|
select(Asset).where(ownership_filter, active_filter).subquery()
|
||||||
)
|
)
|
||||||
count_result = await db.execute(count_stmt)
|
count_result = await db.execute(count_stmt)
|
||||||
total = count_result.scalar() or 0
|
total = count_result.scalar() or 0
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ async def create_package(
|
|||||||
|
|
||||||
tier = SubscriptionTier(
|
tier = SubscriptionTier(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
|
type=payload.type,
|
||||||
rules=rules_dict,
|
rules=rules_dict,
|
||||||
is_custom=payload.is_custom,
|
is_custom=payload.is_custom,
|
||||||
tier_level=payload.tier_level,
|
tier_level=payload.tier_level,
|
||||||
@@ -309,6 +310,9 @@ async def update_package(
|
|||||||
)
|
)
|
||||||
tier.name = update_data["name"]
|
tier.name = update_data["name"]
|
||||||
|
|
||||||
|
if "type" in update_data and update_data["type"] is not None:
|
||||||
|
tier.type = update_data["type"]
|
||||||
|
|
||||||
if "rules" in update_data and update_data["rules"] is not None:
|
if "rules" in update_data and update_data["rules"] is not None:
|
||||||
# ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ──
|
# ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ──
|
||||||
# Previously: tier.rules = new_rules (wholesale replacement)
|
# Previously: tier.rules = new_rules (wholesale replacement)
|
||||||
|
|||||||
1084
backend/app/api/v1/endpoints/admin_persons.py
Normal file
1084
backend/app/api/v1/endpoints/admin_persons.py
Normal file
File diff suppressed because it is too large
Load Diff
646
backend/app/api/v1/endpoints/admin_users.py
Normal file
646
backend/app/api/v1/endpoints/admin_users.py
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
"""
|
||||||
|
👤 Admin Users API
|
||||||
|
|
||||||
|
Végpontok:
|
||||||
|
GET /admin/users/stats — Felhasználói statisztikák (dashboard)
|
||||||
|
GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address)
|
||||||
|
PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által
|
||||||
|
GET /admin/users/{user_id}/memberships — Felhasználó szervezeti tagságai
|
||||||
|
|
||||||
|
Megjegyzés: A GET /admin/users (lista) és POST /admin/users/bulk-action
|
||||||
|
végpontok a admin.py modulban találhatók, mivel az a /admin prefix alá
|
||||||
|
van regisztrálva, és a /users, /users/bulk-action útvonalakat kezeli.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from sqlalchemy import select, func, update as sa_update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from app.api import deps
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.identity import User, Person, UserRole
|
||||||
|
from app.models.identity.address import Address
|
||||||
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||||
|
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||||
|
from app.schemas.user import UserResponse
|
||||||
|
from app.schemas.organization import OrganizationMemberResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger("admin-users")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Pydantic Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationTrendItem(BaseModel):
|
||||||
|
"""Napi regisztrációs trend elem."""
|
||||||
|
date: str = Field(..., description="Dátum (YYYY-MM-DD)")
|
||||||
|
count: int = Field(..., description="Regisztrációk száma")
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatsResponse(BaseModel):
|
||||||
|
"""Felhasználói statisztikák a dashboard számára."""
|
||||||
|
total_users: int = Field(..., description="Összes felhasználó")
|
||||||
|
active_users: int = Field(..., description="Aktív felhasználók")
|
||||||
|
deleted_users: int = Field(..., description="Törölt felhasználók")
|
||||||
|
banned_users: int = Field(..., description="Kitiltott felhasználók")
|
||||||
|
new_users_today: int = Field(..., description="Ma regisztrált felhasználók")
|
||||||
|
new_users_this_week: int = Field(..., description="Ezen a héten regisztrált felhasználók")
|
||||||
|
new_users_this_month: int = Field(..., description="Ebben a hónapban regisztrált felhasználók")
|
||||||
|
users_by_role: Dict[str, int] = Field(..., description="Felhasználók szerepkör szerint")
|
||||||
|
users_by_plan: Dict[str, int] = Field(..., description="Felhasználók előfizetési csomag szerint")
|
||||||
|
users_by_language: Dict[str, int] = Field(..., description="Felhasználók nyelv szerint")
|
||||||
|
users_with_person: int = Field(..., description="Person rekorddal rendelkező felhasználók")
|
||||||
|
users_without_person: int = Field(..., description="Person rekord nélküli felhasználók")
|
||||||
|
registration_trend: List[RegistrationTrendItem] = Field(
|
||||||
|
default_factory=list, description="Regisztrációs trend (utolsó 30 nap)"
|
||||||
|
)
|
||||||
|
active_organizations_count: int = Field(..., description="Aktív szervezetek száma")
|
||||||
|
total_memberships: int = Field(..., description="Összes szervezeti tagság")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserUpdate(BaseModel):
|
||||||
|
"""Admin által szerkeszthető felhasználói mezők.
|
||||||
|
|
||||||
|
Csak a megadott mezők módosíthatók admin felületről.
|
||||||
|
A role_id módosítása csak SUPERADMIN számára engedélyezett.
|
||||||
|
"""
|
||||||
|
email: Optional[EmailStr] = Field(default=None, description="Email cím (csak admin/superadmin)")
|
||||||
|
is_active: Optional[bool] = Field(default=None, description="Aktív státusz (letiltás/feloldás)")
|
||||||
|
is_vip: Optional[bool] = Field(default=None, description="VIP státusz")
|
||||||
|
preferred_language: Optional[str] = Field(default=None, max_length=5, description="Nyelv")
|
||||||
|
region_code: Optional[str] = Field(default=None, max_length=5, description="Régió kód")
|
||||||
|
preferred_currency: Optional[str] = Field(default=None, max_length=3, description="Pénznem")
|
||||||
|
subscription_plan: Optional[str] = Field(default=None, max_length=30, description="Előfizetési csomag")
|
||||||
|
subscription_expires_at: Optional[datetime] = Field(default=None, description="Előfizetés lejárata")
|
||||||
|
scope_level: Optional[str] = Field(default=None, max_length=30, description="Scope szint")
|
||||||
|
scope_id: Optional[str] = Field(default=None, max_length=50, description="Scope azonosító")
|
||||||
|
role_id: Optional[int] = Field(default=None, description="RBAC szerepkör ID (csak superadmin)")
|
||||||
|
custom_permissions: Optional[Dict[str, Any]] = Field(default=None, description="Egyedi jogosultságok")
|
||||||
|
|
||||||
|
# Person almzők
|
||||||
|
person: Optional["AdminPersonUpdate"] = Field(default=None, description="Személyes adatok")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPersonUpdate(BaseModel):
|
||||||
|
"""Admin által szerkeszthető személyes adatok."""
|
||||||
|
last_name: Optional[str] = Field(default=None, description="Vezetéknév")
|
||||||
|
first_name: Optional[str] = Field(default=None, description="Keresztnév")
|
||||||
|
phone: Optional[str] = Field(default=None, description="Telefonszám")
|
||||||
|
mothers_last_name: Optional[str] = Field(default=None, description="Anyja születési vezetékneve")
|
||||||
|
mothers_first_name: Optional[str] = Field(default=None, description="Anyja születési keresztneve")
|
||||||
|
birth_place: Optional[str] = Field(default=None, description="Születési hely")
|
||||||
|
birth_date: Optional[datetime] = Field(default=None, description="Születési dátum")
|
||||||
|
identity_docs: Optional[Dict[str, Any]] = Field(default=None, description="Személyi okmány adatok")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/stats",
|
||||||
|
response_model=UserStatsResponse,
|
||||||
|
summary="Felhasználói statisztikák lekérése",
|
||||||
|
description=(
|
||||||
|
"Visszaadja a felhasználók összesített statisztikáit a dashboard számára, "
|
||||||
|
"beleértve a szerepkör, előfizetési csomag és nyelv szerinti megoszlást, "
|
||||||
|
"valamint a regisztrációs trendet. Csak admin/staff jogosultsággal érhető el."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_user_stats(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_ = Depends(deps.RequirePermission("user:view")),
|
||||||
|
) -> UserStatsResponse:
|
||||||
|
"""
|
||||||
|
GET /admin/users/stats
|
||||||
|
|
||||||
|
Összesített felhasználói statisztikák a dashboard számára.
|
||||||
|
Az összes lekérdezés egyetlen tranzakcióban fut le.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
week_start = today_start - timedelta(days=today_start.weekday())
|
||||||
|
month_start = today_start.replace(day=1)
|
||||||
|
|
||||||
|
# ── 1. Alap statisztikák ──
|
||||||
|
total_users_result = await db.execute(
|
||||||
|
select(func.count(User.id))
|
||||||
|
)
|
||||||
|
total_users = total_users_result.scalar() or 0
|
||||||
|
|
||||||
|
active_users_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.is_active.is_(True), User.is_deleted.is_(False))
|
||||||
|
)
|
||||||
|
active_users = active_users_result.scalar() or 0
|
||||||
|
|
||||||
|
deleted_users_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.is_deleted.is_(True))
|
||||||
|
)
|
||||||
|
deleted_users = deleted_users_result.scalar() or 0
|
||||||
|
|
||||||
|
banned_users_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(
|
||||||
|
User.is_active.is_(False),
|
||||||
|
User.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
banned_users = banned_users_result.scalar() or 0
|
||||||
|
|
||||||
|
# ── 2. Új felhasználók ──
|
||||||
|
new_today_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.created_at >= today_start)
|
||||||
|
)
|
||||||
|
new_users_today = new_today_result.scalar() or 0
|
||||||
|
|
||||||
|
new_week_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.created_at >= week_start)
|
||||||
|
)
|
||||||
|
new_users_this_week = new_week_result.scalar() or 0
|
||||||
|
|
||||||
|
new_month_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.created_at >= month_start)
|
||||||
|
)
|
||||||
|
new_users_this_month = new_month_result.scalar() or 0
|
||||||
|
|
||||||
|
# ── 3. Felhasználók szerepkör szerint ──
|
||||||
|
role_rows = await db.execute(
|
||||||
|
select(User.role, func.count(User.id).label("cnt"))
|
||||||
|
.group_by(User.role)
|
||||||
|
)
|
||||||
|
users_by_role: Dict[str, int] = {}
|
||||||
|
for row in role_rows:
|
||||||
|
role_name = row.role.value if hasattr(row.role, 'value') else str(row.role)
|
||||||
|
users_by_role[role_name.lower()] = row.cnt
|
||||||
|
|
||||||
|
# ── 4. Felhasználók előfizetési csomag szerint ──
|
||||||
|
plan_rows = await db.execute(
|
||||||
|
select(User.subscription_plan, func.count(User.id).label("cnt"))
|
||||||
|
.group_by(User.subscription_plan)
|
||||||
|
)
|
||||||
|
users_by_plan: Dict[str, int] = {}
|
||||||
|
for row in plan_rows:
|
||||||
|
users_by_plan[row.subscription_plan.lower()] = row.cnt
|
||||||
|
|
||||||
|
# ── 5. Felhasználók nyelv szerint ──
|
||||||
|
lang_rows = await db.execute(
|
||||||
|
select(User.preferred_language, func.count(User.id).label("cnt"))
|
||||||
|
.group_by(User.preferred_language)
|
||||||
|
)
|
||||||
|
users_by_language: Dict[str, int] = {}
|
||||||
|
for row in lang_rows:
|
||||||
|
users_by_language[row.preferred_language] = row.cnt
|
||||||
|
|
||||||
|
# ── 6. Person kapcsolat ──
|
||||||
|
with_person_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.person_id.isnot(None))
|
||||||
|
)
|
||||||
|
users_with_person = with_person_result.scalar() or 0
|
||||||
|
|
||||||
|
without_person_result = await db.execute(
|
||||||
|
select(func.count(User.id)).where(User.person_id.is_(None))
|
||||||
|
)
|
||||||
|
users_without_person = without_person_result.scalar() or 0
|
||||||
|
|
||||||
|
# ── 7. Regisztrációs trend (utolsó 30 nap) ──
|
||||||
|
thirty_days_ago = today_start - timedelta(days=30)
|
||||||
|
day_trunc = func.date_trunc("day", User.created_at)
|
||||||
|
trend_rows = await db.execute(
|
||||||
|
select(
|
||||||
|
day_trunc.label("day"),
|
||||||
|
func.count(User.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.where(User.created_at >= thirty_days_ago)
|
||||||
|
.group_by(day_trunc)
|
||||||
|
.order_by(day_trunc)
|
||||||
|
)
|
||||||
|
registration_trend: List[RegistrationTrendItem] = []
|
||||||
|
for row in trend_rows:
|
||||||
|
day_str = row.day.strftime("%Y-%m-%d") if row.day else ""
|
||||||
|
registration_trend.append(RegistrationTrendItem(date=day_str, count=row.cnt))
|
||||||
|
|
||||||
|
# ── 8. Szervezeti statisztikák ──
|
||||||
|
active_orgs_result = await db.execute(
|
||||||
|
select(func.count(Organization.id))
|
||||||
|
.where(Organization.is_anonymized.is_(False))
|
||||||
|
)
|
||||||
|
active_organizations_count = active_orgs_result.scalar() or 0
|
||||||
|
|
||||||
|
total_memberships_result = await db.execute(
|
||||||
|
select(func.count(OrganizationMember.id))
|
||||||
|
)
|
||||||
|
total_memberships = total_memberships_result.scalar() or 0
|
||||||
|
|
||||||
|
return UserStatsResponse(
|
||||||
|
total_users=total_users,
|
||||||
|
active_users=active_users,
|
||||||
|
deleted_users=deleted_users,
|
||||||
|
banned_users=banned_users,
|
||||||
|
new_users_today=new_users_today,
|
||||||
|
new_users_this_week=new_users_this_week,
|
||||||
|
new_users_this_month=new_users_this_month,
|
||||||
|
users_by_role=users_by_role,
|
||||||
|
users_by_plan=users_by_plan,
|
||||||
|
users_by_language=users_by_language,
|
||||||
|
users_with_person=users_with_person,
|
||||||
|
users_without_person=users_without_person,
|
||||||
|
registration_trend=registration_trend,
|
||||||
|
active_organizations_count=active_organizations_count,
|
||||||
|
total_memberships=total_memberships,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{user_id}",
|
||||||
|
response_model=UserResponse,
|
||||||
|
summary="Felhasználó részletes adatainak lekérése",
|
||||||
|
description=(
|
||||||
|
"Visszaadja egy felhasználó összes adatát, beleértve a Person rekordot "
|
||||||
|
"és a beágyazott Address adatokat. Csak admin/staff jogosultsággal érhető el."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_admin_user(
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_ = Depends(deps.RequirePermission("user:view")),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
GET /admin/users/{user_id}
|
||||||
|
|
||||||
|
Lekérdezi a felhasználót a megadott ID alapján, eager loading-gal betölti
|
||||||
|
a Person és Address kapcsolatokat, majd visszaadja a teljes UserResponse-t.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: A lekérdezni kívánt felhasználó ID-ja.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserResponse séma szerinti felhasználói adatok.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Ha a felhasználó nem található vagy törölve lett.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.where(User.id == user_id)
|
||||||
|
.options(
|
||||||
|
joinedload(User.person)
|
||||||
|
.joinedload(Person.address)
|
||||||
|
.joinedload(Address.postal_code)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.unique().scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or user.is_deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Felhasználó nem található.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reuse the existing _build_user_response helper from users.py
|
||||||
|
from app.api.v1.endpoints.users import _build_user_response
|
||||||
|
|
||||||
|
response_data = await _build_user_response(user, db=db)
|
||||||
|
return UserResponse.model_validate(response_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{user_id}",
|
||||||
|
response_model=Dict[str, Any],
|
||||||
|
summary="Felhasználó szerkesztése",
|
||||||
|
description=(
|
||||||
|
"Admin általi felhasználó szerkesztés. Csak a megadott mezők módosíthatók. "
|
||||||
|
"A role_id módosítása csak SUPERADMIN számára engedélyezett. "
|
||||||
|
"Szuperadmin felhasználó nem módosítható."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def update_admin_user(
|
||||||
|
user_id: int,
|
||||||
|
update_data: AdminUserUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_ = Depends(deps.RequirePermission("user:edit")),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
PATCH /admin/users/{user_id}
|
||||||
|
|
||||||
|
Admin általi felhasználó szerkesztés. Csak a megadott mezők módosíthatók.
|
||||||
|
A role_id módosítása csak SUPERADMIN számára engedélyezett.
|
||||||
|
Szuperadmin felhasználó nem módosítható.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: A módosítandó felhasználó ID-ja.
|
||||||
|
update_data: A módosítandó mezők.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserResponse séma szerinti frissített felhasználói adatok.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Ha a felhasználó nem található vagy törölve lett.
|
||||||
|
HTTPException 403: Ha a módosítás nem engedélyezett.
|
||||||
|
"""
|
||||||
|
# ── 1. Lekérdezzük a felhasználót ──
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.where(User.id == user_id)
|
||||||
|
.options(
|
||||||
|
joinedload(User.person)
|
||||||
|
.joinedload(Person.address)
|
||||||
|
.joinedload(Address.postal_code)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.unique().scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or user.is_deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Felhasználó nem található.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 2. Biztonsági ellenőrzések ──
|
||||||
|
# Szuperadmin felhasználó nem módosítható (még másik superadmin által sem)
|
||||||
|
if user.role == UserRole.SUPERADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Szuperadmin felhasználó nem módosítható.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# role_id módosítása csak SUPERADMIN számára engedélyezett
|
||||||
|
if update_data.role_id is not None and current_user.role != UserRole.SUPERADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Csak SUPERADMIN módosíthatja a szerepkört.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 3. Összegyűjtjük a változásokat audit loghoz ──
|
||||||
|
old_data: Dict[str, Any] = {}
|
||||||
|
new_data: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# ── 4. User mezők frissítése ──
|
||||||
|
update_fields: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
if update_data.email is not None:
|
||||||
|
# Email egyediség ellenőrzés
|
||||||
|
existing = await db.execute(
|
||||||
|
select(User).where(User.email == update_data.email, User.id != user_id)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Ez az email cím már használatban van.",
|
||||||
|
)
|
||||||
|
old_data["email"] = user.email
|
||||||
|
new_data["email"] = update_data.email
|
||||||
|
update_fields["email"] = update_data.email
|
||||||
|
|
||||||
|
if update_data.is_active is not None:
|
||||||
|
old_data["is_active"] = user.is_active
|
||||||
|
new_data["is_active"] = update_data.is_active
|
||||||
|
update_fields["is_active"] = update_data.is_active
|
||||||
|
|
||||||
|
if update_data.is_vip is not None:
|
||||||
|
old_data["is_vip"] = user.is_vip
|
||||||
|
new_data["is_vip"] = update_data.is_vip
|
||||||
|
update_fields["is_vip"] = update_data.is_vip
|
||||||
|
|
||||||
|
if update_data.preferred_language is not None:
|
||||||
|
old_data["preferred_language"] = user.preferred_language
|
||||||
|
new_data["preferred_language"] = update_data.preferred_language
|
||||||
|
update_fields["preferred_language"] = update_data.preferred_language
|
||||||
|
|
||||||
|
if update_data.region_code is not None:
|
||||||
|
old_data["region_code"] = user.region_code
|
||||||
|
new_data["region_code"] = update_data.region_code
|
||||||
|
update_fields["region_code"] = update_data.region_code
|
||||||
|
|
||||||
|
if update_data.preferred_currency is not None:
|
||||||
|
old_data["preferred_currency"] = user.preferred_currency
|
||||||
|
new_data["preferred_currency"] = update_data.preferred_currency
|
||||||
|
update_fields["preferred_currency"] = update_data.preferred_currency
|
||||||
|
|
||||||
|
if update_data.subscription_plan is not None:
|
||||||
|
old_data["subscription_plan"] = user.subscription_plan
|
||||||
|
new_data["subscription_plan"] = update_data.subscription_plan
|
||||||
|
update_fields["subscription_plan"] = update_data.subscription_plan
|
||||||
|
|
||||||
|
if update_data.subscription_expires_at is not None:
|
||||||
|
old_data["subscription_expires_at"] = (
|
||||||
|
user.subscription_expires_at.isoformat() if user.subscription_expires_at else None
|
||||||
|
)
|
||||||
|
new_data["subscription_expires_at"] = update_data.subscription_expires_at.isoformat()
|
||||||
|
update_fields["subscription_expires_at"] = update_data.subscription_expires_at
|
||||||
|
|
||||||
|
if update_data.scope_level is not None:
|
||||||
|
old_data["scope_level"] = user.scope_level
|
||||||
|
new_data["scope_level"] = update_data.scope_level
|
||||||
|
update_fields["scope_level"] = update_data.scope_level
|
||||||
|
|
||||||
|
if update_data.scope_id is not None:
|
||||||
|
old_data["scope_id"] = user.scope_id
|
||||||
|
new_data["scope_id"] = update_data.scope_id
|
||||||
|
update_fields["scope_id"] = update_data.scope_id
|
||||||
|
|
||||||
|
if update_data.role_id is not None:
|
||||||
|
old_data["role_id"] = user.role_id
|
||||||
|
new_data["role_id"] = update_data.role_id
|
||||||
|
update_fields["role_id"] = update_data.role_id
|
||||||
|
|
||||||
|
if update_data.custom_permissions is not None:
|
||||||
|
old_data["custom_permissions"] = user.custom_permissions
|
||||||
|
new_data["custom_permissions"] = update_data.custom_permissions
|
||||||
|
update_fields["custom_permissions"] = update_data.custom_permissions
|
||||||
|
|
||||||
|
# ── 5. Person mezők frissítése ──
|
||||||
|
person_updated = False
|
||||||
|
if update_data.person is not None and user.person is not None:
|
||||||
|
person = user.person
|
||||||
|
person_update_fields: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
if update_data.person.last_name is not None:
|
||||||
|
old_data["person.last_name"] = person.last_name
|
||||||
|
new_data["person.last_name"] = update_data.person.last_name
|
||||||
|
person_update_fields["last_name"] = update_data.person.last_name
|
||||||
|
|
||||||
|
if update_data.person.first_name is not None:
|
||||||
|
old_data["person.first_name"] = person.first_name
|
||||||
|
new_data["person.first_name"] = update_data.person.first_name
|
||||||
|
person_update_fields["first_name"] = update_data.person.first_name
|
||||||
|
|
||||||
|
if update_data.person.phone is not None:
|
||||||
|
old_data["person.phone"] = person.phone
|
||||||
|
new_data["person.phone"] = update_data.person.phone
|
||||||
|
person_update_fields["phone"] = update_data.person.phone
|
||||||
|
|
||||||
|
if update_data.person.mothers_last_name is not None:
|
||||||
|
old_data["person.mothers_last_name"] = person.mothers_last_name
|
||||||
|
new_data["person.mothers_last_name"] = update_data.person.mothers_last_name
|
||||||
|
person_update_fields["mothers_last_name"] = update_data.person.mothers_last_name
|
||||||
|
|
||||||
|
if update_data.person.mothers_first_name is not None:
|
||||||
|
old_data["person.mothers_first_name"] = person.mothers_first_name
|
||||||
|
new_data["person.mothers_first_name"] = update_data.person.mothers_first_name
|
||||||
|
person_update_fields["mothers_first_name"] = update_data.person.mothers_first_name
|
||||||
|
|
||||||
|
if update_data.person.birth_place is not None:
|
||||||
|
old_data["person.birth_place"] = person.birth_place
|
||||||
|
new_data["person.birth_place"] = update_data.person.birth_place
|
||||||
|
person_update_fields["birth_place"] = update_data.person.birth_place
|
||||||
|
|
||||||
|
if update_data.person.birth_date is not None:
|
||||||
|
old_data["person.birth_date"] = (
|
||||||
|
person.birth_date.isoformat() if person.birth_date else None
|
||||||
|
)
|
||||||
|
new_data["person.birth_date"] = update_data.person.birth_date.isoformat()
|
||||||
|
person_update_fields["birth_date"] = update_data.person.birth_date
|
||||||
|
|
||||||
|
if update_data.person.identity_docs is not None:
|
||||||
|
old_data["person.identity_docs"] = person.identity_docs
|
||||||
|
new_data["person.identity_docs"] = update_data.person.identity_docs
|
||||||
|
person_update_fields["identity_docs"] = update_data.person.identity_docs
|
||||||
|
|
||||||
|
if person_update_fields:
|
||||||
|
for field, value in person_update_fields.items():
|
||||||
|
setattr(person, field, value)
|
||||||
|
person.updated_at = datetime.now(timezone.utc)
|
||||||
|
person_updated = True
|
||||||
|
|
||||||
|
# ── 6. Ha nincs változás, dobjunk hibát ──
|
||||||
|
if not update_fields and not person_updated:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Nincs módosítandó mező.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 7. Végrehajtjuk a frissítést ──
|
||||||
|
if update_fields:
|
||||||
|
stmt_update = (
|
||||||
|
sa_update(User)
|
||||||
|
.where(User.id == user_id)
|
||||||
|
.values(**update_fields)
|
||||||
|
)
|
||||||
|
await db.execute(stmt_update)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# ── 8. Audit log ──
|
||||||
|
try:
|
||||||
|
audit_entry = AuditLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
severity=LogSeverity.info,
|
||||||
|
action="ADMIN_USER_UPDATE",
|
||||||
|
target_type="user",
|
||||||
|
target_id=str(user_id),
|
||||||
|
old_data=old_data if old_data else None,
|
||||||
|
new_data=new_data if new_data else None,
|
||||||
|
)
|
||||||
|
db.add(audit_entry)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to write audit log for user update {user_id}: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
# ── 9. Visszaadjuk a frissített adatokat ──
|
||||||
|
# Újratöltjük a frissített adatokat
|
||||||
|
stmt_refresh = (
|
||||||
|
select(User)
|
||||||
|
.where(User.id == user_id)
|
||||||
|
.options(
|
||||||
|
joinedload(User.person)
|
||||||
|
.joinedload(Person.address)
|
||||||
|
.joinedload(Address.postal_code)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result_refresh = await db.execute(stmt_refresh)
|
||||||
|
updated_user = result_refresh.unique().scalar_one_or_none()
|
||||||
|
|
||||||
|
from app.api.v1.endpoints.users import _build_user_response
|
||||||
|
|
||||||
|
response_data = await _build_user_response(updated_user, db=db)
|
||||||
|
# Kibővítjük a hiányzó mezőkkel, amik a User modellen léteznek,
|
||||||
|
# de a _build_user_response dict-ből és a UserResponse sémából hiányoznak
|
||||||
|
response_data["is_vip"] = updated_user.is_vip
|
||||||
|
response_data["preferred_currency"] = updated_user.preferred_currency
|
||||||
|
response_data["custom_permissions"] = updated_user.custom_permissions
|
||||||
|
response_data["subscription_expires_at"] = (
|
||||||
|
updated_user.subscription_expires_at.isoformat() if updated_user.subscription_expires_at else None
|
||||||
|
)
|
||||||
|
response_data["scope_level"] = updated_user.scope_level or "individual"
|
||||||
|
response_data["scope_id"] = str(updated_user.scope_id) if updated_user.scope_id else None
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Memberships Endpoint
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class UserMembershipResponse(BaseModel):
|
||||||
|
"""Egy szervezeti tagság adatai a felhasználó nézetében."""
|
||||||
|
id: int
|
||||||
|
organization_id: int
|
||||||
|
organization_name: str = Field(..., description="Szervezet neve")
|
||||||
|
organization_display_name: Optional[str] = Field(default=None, description="Szervezet megjelenítési neve")
|
||||||
|
role: str = Field(..., description="Szerepkör a szervezetben")
|
||||||
|
status: str = Field(default="active", description="Tagság státusza")
|
||||||
|
is_verified: bool = Field(default=False, description="Ellenőrzött tagság")
|
||||||
|
is_permanent: bool = Field(default=False, description="Állandó tagság")
|
||||||
|
joined_at: Optional[str] = Field(default=None, description="Csatlakozás dátuma")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{user_id}/memberships",
|
||||||
|
response_model=List[UserMembershipResponse],
|
||||||
|
summary="Felhasználó szervezeti tagságai",
|
||||||
|
description=(
|
||||||
|
"Visszaadja egy felhasználó összes szervezeti tagságát. "
|
||||||
|
"Csak admin/staff jogosultsággal érhető el."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_user_memberships(
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_ = Depends(deps.RequirePermission("user:view")),
|
||||||
|
) -> List[UserMembershipResponse]:
|
||||||
|
"""
|
||||||
|
GET /admin/users/{user_id}/memberships
|
||||||
|
|
||||||
|
Lekérdezi a felhasználó összes szervezeti tagságát.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(OrganizationMember)
|
||||||
|
.where(OrganizationMember.user_id == user_id)
|
||||||
|
.options(joinedload(OrganizationMember.organization))
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
memberships = result.unique().scalars().all()
|
||||||
|
|
||||||
|
membership_list = []
|
||||||
|
for m in memberships:
|
||||||
|
membership_list.append(UserMembershipResponse(
|
||||||
|
id=m.id,
|
||||||
|
organization_id=m.organization_id,
|
||||||
|
organization_name=m.organization.name if m.organization else "Ismeretlen",
|
||||||
|
organization_display_name=m.organization.display_name if m.organization else None,
|
||||||
|
role=m.role,
|
||||||
|
status=m.status,
|
||||||
|
is_verified=m.is_verified,
|
||||||
|
is_permanent=m.is_permanent,
|
||||||
|
joined_at=m.joined_at.isoformat() if m.joined_at else None,
|
||||||
|
))
|
||||||
|
|
||||||
|
return membership_list
|
||||||
@@ -46,9 +46,10 @@ class PricingModel(BaseModel):
|
|||||||
|
|
||||||
class AllowancesModel(BaseModel):
|
class AllowancesModel(BaseModel):
|
||||||
"""Korlátok és keretek a csomagban."""
|
"""Korlátok és keretek a csomagban."""
|
||||||
max_vehicles: int = Field(..., ge=0, description="Maximális járműszám")
|
max_vehicles: int = Field(default=0, ge=0, description="Maximális járműszám")
|
||||||
max_garages: int = Field(..., ge=0, description="Maximális garázsok száma")
|
max_garages: int = Field(default=0, ge=0, description="Maximális garázsok száma")
|
||||||
monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma")
|
max_users: int = Field(default=0, ge=0, description="Maximális dolgozók/felhasználók száma (0 = nincs korlátozva)")
|
||||||
|
monthly_free_credits: int = Field(default=0, ge=0, description="Havi ingyenes kreditek száma")
|
||||||
|
|
||||||
|
|
||||||
class DurationModel(BaseModel):
|
class DurationModel(BaseModel):
|
||||||
@@ -100,7 +101,7 @@ class SubscriptionRulesModel(BaseModel):
|
|||||||
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
|
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
|
||||||
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
|
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
|
||||||
"""
|
"""
|
||||||
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa")
|
type: Optional[str] = Field(default=None, pattern=r"^(private|corporate|addon)$", description="Csomag típusa (private/corporate/addon)")
|
||||||
display_name: Optional[str] = Field(
|
display_name: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')",
|
description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')",
|
||||||
@@ -141,9 +142,11 @@ class SubscriptionRulesModel(BaseModel):
|
|||||||
|
|
||||||
@field_validator("type")
|
@field_validator("type")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_type(cls, v: str) -> str:
|
def validate_type(cls, v: Optional[str]) -> Optional[str]:
|
||||||
if v.lower() not in ("private", "corporate"):
|
if v is None:
|
||||||
raise ValueError("A 'type' mező csak 'private' vagy 'corporate' lehet.")
|
return v
|
||||||
|
if v.lower() not in ("private", "corporate", "addon"):
|
||||||
|
raise ValueError("A 'type' mező csak 'private', 'corporate' vagy 'addon' lehet.")
|
||||||
return v.lower()
|
return v.lower()
|
||||||
|
|
||||||
|
|
||||||
@@ -184,6 +187,7 @@ class PublicSubscriptionTierResponse(BaseModel):
|
|||||||
class SubscriptionTierCreate(BaseModel):
|
class SubscriptionTierCreate(BaseModel):
|
||||||
"""Új előfizetési csomag létrehozása (POST /)."""
|
"""Új előfizetési csomag létrehozása (POST /)."""
|
||||||
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
|
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
|
||||||
|
type: Optional[str] = Field(default="base", description="Csomag típusa: 'base' (alap) vagy 'addon' (kiegészítő)")
|
||||||
rules: SubscriptionRulesModel
|
rules: SubscriptionRulesModel
|
||||||
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
|
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
|
||||||
tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise")
|
tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise")
|
||||||
@@ -202,6 +206,7 @@ class SubscriptionTierCreate(BaseModel):
|
|||||||
class SubscriptionTierUpdate(BaseModel):
|
class SubscriptionTierUpdate(BaseModel):
|
||||||
"""Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id})."""
|
"""Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id})."""
|
||||||
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
|
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
|
||||||
|
type: Optional[str] = Field(default=None, description="Csomag típusa: 'base' vagy 'addon'")
|
||||||
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
|
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
|
||||||
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
|
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
|
||||||
tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level")
|
tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level")
|
||||||
|
|||||||
@@ -670,9 +670,13 @@ class AssetService:
|
|||||||
A művelet:
|
A művelet:
|
||||||
1. Frissíti a current_mileage értékét a megadott final_mileage-ra
|
1. Frissíti a current_mileage értékét a megadott final_mileage-ra
|
||||||
2. Állítja: status = 'archived'
|
2. Állítja: status = 'archived'
|
||||||
3. Nullázza az owner_person_id és owner_org_id mezőket
|
3. Nullázza az owner_person_id, owner_org_id és current_organization_id mezőket
|
||||||
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
|
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
|
||||||
5. Naplózza a biztonsági auditba
|
5. Naplózza a biztonsági auditba
|
||||||
|
|
||||||
|
P0 CRITICAL FIX: current_organization_id is also cleared so the vehicle
|
||||||
|
no longer counts toward the org's active vehicle quota. The previous
|
||||||
|
value is preserved in archive_info for audit/historical purposes.
|
||||||
"""
|
"""
|
||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -712,14 +716,19 @@ class AssetService:
|
|||||||
'archived_at': now_iso,
|
'archived_at': now_iso,
|
||||||
'previous_owner_person_id': asset.owner_person_id,
|
'previous_owner_person_id': asset.owner_person_id,
|
||||||
'previous_owner_org_id': asset.owner_org_id,
|
'previous_owner_org_id': asset.owner_org_id,
|
||||||
|
'previous_current_organization_id': asset.current_organization_id,
|
||||||
'final_mileage': final_mileage,
|
'final_mileage': final_mileage,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 5. Frissítjük a jármű adatait
|
# 5. Frissítjük a jármű adatait
|
||||||
|
# P0 CRITICAL FIX: Clear current_organization_id so the vehicle no longer
|
||||||
|
# counts toward the org's active vehicle quota. The previous value is
|
||||||
|
# preserved in archive_info above for historical audit purposes.
|
||||||
asset.current_mileage = final_mileage
|
asset.current_mileage = final_mileage
|
||||||
asset.status = "archived"
|
asset.status = "archived"
|
||||||
asset.owner_person_id = None
|
asset.owner_person_id = None
|
||||||
asset.owner_org_id = None
|
asset.owner_org_id = None
|
||||||
|
asset.current_organization_id = None
|
||||||
asset.individual_equipment = equipment
|
asset.individual_equipment = equipment
|
||||||
asset.updated_at = datetime.now(timezone.utc)
|
asset.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|||||||
197
backend/tests/test_admin_user_patch.py
Normal file
197
backend/tests/test_admin_user_patch.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for PATCH /admin/users/{user_id} endpoint.
|
||||||
|
Uses form-data for OAuth2 login, then tests the admin user update endpoint.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE_URL = "http://sf_api:8000/api/v1"
|
||||||
|
|
||||||
|
async def test_admin_user_patch():
|
||||||
|
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||||
|
# Step 1: Login as admin using form data (OAuth2PasswordRequestForm)
|
||||||
|
print("=" * 60)
|
||||||
|
print("STEP 1: Login as admin")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
"username": "admin@profibot.hu",
|
||||||
|
"password": "Admin123!",
|
||||||
|
"remember_me": "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await client.post("/auth/login", data=login_data)
|
||||||
|
print(f"Status: {r.status_code}")
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f"Login failed: {r.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
token_data = r.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
print(f"Token received: {access_token[:50]}...")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
|
# Step 2: Try to find a non-superadmin user
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 2: Find a non-superadmin user")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Try user IDs 2, 3, 4, etc. until we find a regular user
|
||||||
|
test_user_id = None
|
||||||
|
for uid in [2, 3, 4, 5]:
|
||||||
|
r = await client.get(f"/admin/users/{uid}", headers=headers)
|
||||||
|
if r.status_code == 200:
|
||||||
|
user_data = r.json()
|
||||||
|
role = user_data.get("role", "")
|
||||||
|
print(f" User ID={uid}: email={user_data.get('email')}, role={role}")
|
||||||
|
if role != "superadmin":
|
||||||
|
test_user_id = uid
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f" User ID={uid}: Status={r.status_code}")
|
||||||
|
|
||||||
|
if test_user_id is None:
|
||||||
|
print("No non-superadmin user found. Using user ID 2 as fallback.")
|
||||||
|
test_user_id = 2
|
||||||
|
|
||||||
|
# Get the user details
|
||||||
|
r = await client.get(f"/admin/users/{test_user_id}", headers=headers)
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f"Failed to get user {test_user_id}: {r.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_data = r.json()
|
||||||
|
user_id = user_data.get("id")
|
||||||
|
user_email = user_data.get("email")
|
||||||
|
user_role = user_data.get("role")
|
||||||
|
print(f"\nUsing test user: ID={user_id}, Email={user_email}, Role={user_role}")
|
||||||
|
|
||||||
|
# Step 3: Get user details before update
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"STEP 3: User details (before update)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print(f" is_vip: {user_data.get('is_vip')}")
|
||||||
|
print(f" preferred_language: {user_data.get('preferred_language')}")
|
||||||
|
print(f" is_active: {user_data.get('is_active')}")
|
||||||
|
print(f" region_code: {user_data.get('region_code')}")
|
||||||
|
print(f" preferred_currency: {user_data.get('preferred_currency')}")
|
||||||
|
print(f" subscription_plan: {user_data.get('subscription_plan')}")
|
||||||
|
|
||||||
|
# Step 4: PATCH the user
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"STEP 4: PATCH user {user_id} - update fields")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
patch_data = {
|
||||||
|
"is_vip": True,
|
||||||
|
"preferred_language": "en",
|
||||||
|
"region_code": "HU",
|
||||||
|
"preferred_currency": "HUF",
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await client.patch(f"/admin/users/{user_id}", json=patch_data, headers=headers)
|
||||||
|
print(f"Status: {r.status_code}")
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f"PATCH failed: {r.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
updated_user = r.json()
|
||||||
|
print(f"User after update:")
|
||||||
|
print(f" is_vip: {updated_user.get('is_vip')}")
|
||||||
|
print(f" preferred_language: {updated_user.get('preferred_language')}")
|
||||||
|
print(f" region_code: {updated_user.get('region_code')}")
|
||||||
|
print(f" preferred_currency: {updated_user.get('preferred_currency')}")
|
||||||
|
|
||||||
|
# Verify the changes
|
||||||
|
assert updated_user.get("is_vip") == True, "is_vip not updated"
|
||||||
|
assert updated_user.get("preferred_language") == "en", "preferred_language not updated"
|
||||||
|
assert updated_user.get("region_code") == "HU", "region_code not updated"
|
||||||
|
assert updated_user.get("preferred_currency") == "HUF", "preferred_currency not updated"
|
||||||
|
|
||||||
|
print("\n✅ All basic field updates verified!")
|
||||||
|
|
||||||
|
# Step 5: Test person sub-object update
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"STEP 5: PATCH user {user_id} - update person fields")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
patch_person_data = {
|
||||||
|
"person": {
|
||||||
|
"last_name": "Updated",
|
||||||
|
"first_name": "User",
|
||||||
|
"phone": "+36123456789"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await client.patch(f"/admin/users/{user_id}", json=patch_person_data, headers=headers)
|
||||||
|
print(f"Status: {r.status_code}")
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f"PATCH person failed: {r.text}")
|
||||||
|
else:
|
||||||
|
updated_user = r.json()
|
||||||
|
person = updated_user.get("person", {})
|
||||||
|
print(f"Person after update:")
|
||||||
|
print(f" last_name: {person.get('last_name')}")
|
||||||
|
print(f" first_name: {person.get('first_name')}")
|
||||||
|
print(f" phone: {person.get('phone')}")
|
||||||
|
|
||||||
|
if person.get("last_name") == "Updated":
|
||||||
|
print("✅ Person fields updated successfully!")
|
||||||
|
else:
|
||||||
|
print("⚠️ Person fields may not have been updated (might not have a person record)")
|
||||||
|
|
||||||
|
# Step 6: Test error cases
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 6: Test error cases")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 6a: Non-existent user
|
||||||
|
r = await client.patch("/admin/users/99999", json={"is_vip": True}, headers=headers)
|
||||||
|
print(f"6a - Non-existent user (99999): Status={r.status_code}")
|
||||||
|
if r.status_code == 404:
|
||||||
|
print("✅ Correctly returned 404")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Unexpected: {r.text}")
|
||||||
|
|
||||||
|
# 6b: Empty update
|
||||||
|
r = await client.patch(f"/admin/users/{user_id}", json={}, headers=headers)
|
||||||
|
print(f"6b - Empty update: Status={r.status_code}")
|
||||||
|
if r.status_code == 400:
|
||||||
|
print("✅ Correctly returned 400 for empty update")
|
||||||
|
elif r.status_code == 200:
|
||||||
|
print("⚠️ Empty update returned 200 (no changes made)")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Unexpected: {r.text}")
|
||||||
|
|
||||||
|
# 6c: Invalid field
|
||||||
|
r = await client.patch(f"/admin/users/{user_id}", json={"nonexistent_field": "test"}, headers=headers)
|
||||||
|
print(f"6c - Invalid field: Status={r.status_code}")
|
||||||
|
if r.status_code == 422:
|
||||||
|
print("✅ Correctly returned 422 for invalid field")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Unexpected: {r.text}")
|
||||||
|
|
||||||
|
# 6d: Try to modify superadmin (should be blocked)
|
||||||
|
r = await client.patch("/admin/users/1", json={"is_vip": True}, headers=headers)
|
||||||
|
print(f"6d - Modify superadmin: Status={r.status_code}")
|
||||||
|
if r.status_code == 403:
|
||||||
|
print("✅ Correctly blocked superadmin modification")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Unexpected: {r.text}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("ALL TESTS COMPLETED")
|
||||||
|
print("=" * 60)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = asyncio.run(test_admin_user_patch())
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
445
backend/tests/test_extract_limit_standalone.py
Normal file
445
backend/tests/test_extract_limit_standalone.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
P0 DATA STRUCTURAL AUDIT: _extract_limit() Standalone Test Script
|
||||||
|
|
||||||
|
This script simulates the exact _extract_limit() function and feeds it
|
||||||
|
the ACTUAL JSON data from the database to verify if the limit extraction
|
||||||
|
works correctly for corporate tiers.
|
||||||
|
|
||||||
|
Author: System Architect
|
||||||
|
Date: 2026-06-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EXACT copy of _extract_limit() from admin_organizations.py:397
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _extract_limit(
|
||||||
|
rules: dict,
|
||||||
|
keys: list[str],
|
||||||
|
default: int = 0,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
P0 ARCHITECTURE UNIFICATION: Egyetlen, robusztus függvény a limit értékek
|
||||||
|
kinyerésére a SubscriptionTier.rules JSONB objektumból.
|
||||||
|
|
||||||
|
Két rétegben keres:
|
||||||
|
1. Közvetlenül a rules objektumban: rules["max_vehicles"]
|
||||||
|
2. A rules["allowances"] nested objektumban: rules["allowances"]["max_vehicles"]
|
||||||
|
|
||||||
|
Ez biztosítja, hogy a JSONB struktúra változásai (pl. 'allowances' nested object)
|
||||||
|
ne törjék meg a limit számításokat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rules: A SubscriptionTier.rules JSONB dict-je.
|
||||||
|
keys: A keresendő kulcsok listája (pl. ["max_vehicles", "max_assets"]).
|
||||||
|
Az első találat értéke kerül visszaadásra.
|
||||||
|
default: Alapértelmezett érték, ha egyik kulcs sem található.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: A talált limit érték, vagy a default.
|
||||||
|
"""
|
||||||
|
if not rules or not isinstance(rules, dict):
|
||||||
|
return default
|
||||||
|
|
||||||
|
# 1. szint: Közvetlen keresés a rules objektumban
|
||||||
|
for key in keys:
|
||||||
|
value = rules.get(key)
|
||||||
|
if value is not None and isinstance(value, (int, float)):
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
# 2. szint: Keresés a rules["allowances"] nested objektumban
|
||||||
|
allowances = rules.get("allowances")
|
||||||
|
if allowances and isinstance(allowances, dict):
|
||||||
|
for key in keys:
|
||||||
|
value = allowances.get(key)
|
||||||
|
if value is not None and isinstance(value, (int, float)):
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
# 3. szint: Keresés a rules["limits"] nested objektumban (alternatív név)
|
||||||
|
limits = rules.get("limits")
|
||||||
|
if limits and isinstance(limits, dict):
|
||||||
|
for key in keys:
|
||||||
|
value = limits.get(key)
|
||||||
|
if value is not None and isinstance(value, (int, float)):
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ACTUAL database JSON data (retrieved via PostgreSQL MCP query on 2026-06-28)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# corp_premium_plus_v1 (id=17) — exact JSON as stored in system.subscription_tiers.rules
|
||||||
|
CORP_PREMIUM_PLUS_V1_RAW = """{
|
||||||
|
"type": "corporate",
|
||||||
|
"pricing": {
|
||||||
|
"currency": "EUR",
|
||||||
|
"credit_price": 6000,
|
||||||
|
"yearly_price": 599.99,
|
||||||
|
"monthly_price": 59.99
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"days": 30,
|
||||||
|
"allow_stacking": true
|
||||||
|
},
|
||||||
|
"ad_policy": {
|
||||||
|
"show_ads": false,
|
||||||
|
"ad_free_grace_days": 0,
|
||||||
|
"max_daily_impressions": null
|
||||||
|
},
|
||||||
|
"affiliate": {
|
||||||
|
"referral_bonus_credits": 250,
|
||||||
|
"commission_rate_percent": 20
|
||||||
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"is_public": true
|
||||||
|
},
|
||||||
|
"marketing": {
|
||||||
|
"badge": "Plus",
|
||||||
|
"subtitle": "Nagy flották prémium menedzsmentje.",
|
||||||
|
"highlight_color": "#8B5CF6"
|
||||||
|
},
|
||||||
|
"allowances": {
|
||||||
|
"max_garages": 10,
|
||||||
|
"max_vehicles": 50,
|
||||||
|
"monthly_free_credits": 800
|
||||||
|
},
|
||||||
|
"display_name": "Céges Prémium Plus",
|
||||||
|
"entitlements": [
|
||||||
|
"SRV_DATA_EXPORT",
|
||||||
|
"SRV_AI_UPLOAD",
|
||||||
|
"SRV_ACCOUNTING_SYNC"
|
||||||
|
],
|
||||||
|
"pricing_zones": {
|
||||||
|
"HU": {
|
||||||
|
"currency": "HUF",
|
||||||
|
"credit_price": 6000,
|
||||||
|
"yearly_price": 239990,
|
||||||
|
"monthly_price": 23990
|
||||||
|
},
|
||||||
|
"US": {
|
||||||
|
"currency": "USD",
|
||||||
|
"credit_price": 6000,
|
||||||
|
"yearly_price": 699.99,
|
||||||
|
"monthly_price": 69.99
|
||||||
|
},
|
||||||
|
"DEFAULT": {
|
||||||
|
"currency": "EUR",
|
||||||
|
"credit_price": 6000,
|
||||||
|
"yearly_price": 599.99,
|
||||||
|
"monthly_price": 59.99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# corp_premium_v1 (id=16) — exact JSON as stored
|
||||||
|
CORP_PREMIUM_V1_RAW = """{
|
||||||
|
"type": "corporate",
|
||||||
|
"pricing": {
|
||||||
|
"currency": "EUR",
|
||||||
|
"credit_price": 3000,
|
||||||
|
"yearly_price": 299.99,
|
||||||
|
"monthly_price": 29.99
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"days": 30,
|
||||||
|
"allow_stacking": true
|
||||||
|
},
|
||||||
|
"ad_policy": {
|
||||||
|
"show_ads": false,
|
||||||
|
"ad_free_grace_days": 0,
|
||||||
|
"max_daily_impressions": null
|
||||||
|
},
|
||||||
|
"affiliate": {
|
||||||
|
"referral_bonus_credits": 100,
|
||||||
|
"commission_rate_percent": 15
|
||||||
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"is_public": true
|
||||||
|
},
|
||||||
|
"marketing": {
|
||||||
|
"badge": "Prémium",
|
||||||
|
"subtitle": "Közepes flották számára tervezve.",
|
||||||
|
"highlight_color": "#3B82F6"
|
||||||
|
},
|
||||||
|
"allowances": {
|
||||||
|
"max_garages": 3,
|
||||||
|
"max_vehicles": 20,
|
||||||
|
"monthly_free_credits": 300
|
||||||
|
},
|
||||||
|
"display_name": "Céges Prémium",
|
||||||
|
"entitlements": [
|
||||||
|
"SRV_DATA_EXPORT",
|
||||||
|
"SRV_AI_UPLOAD"
|
||||||
|
],
|
||||||
|
"pricing_zones": {
|
||||||
|
"HU": {
|
||||||
|
"currency": "HUF",
|
||||||
|
"credit_price": 3000,
|
||||||
|
"yearly_price": 119990,
|
||||||
|
"monthly_price": 11990
|
||||||
|
},
|
||||||
|
"US": {
|
||||||
|
"currency": "USD",
|
||||||
|
"credit_price": 3000,
|
||||||
|
"yearly_price": 349.99,
|
||||||
|
"monthly_price": 34.99
|
||||||
|
},
|
||||||
|
"DEFAULT": {
|
||||||
|
"currency": "EUR",
|
||||||
|
"credit_price": 3000,
|
||||||
|
"yearly_price": 299.99,
|
||||||
|
"monthly_price": 29.99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# private_pro_v1 (id=14) — exact JSON as stored
|
||||||
|
PRIVATE_PRO_V1_RAW = """{
|
||||||
|
"type": "private",
|
||||||
|
"lifecycle": {
|
||||||
|
"is_public": true,
|
||||||
|
"available_until": null
|
||||||
|
},
|
||||||
|
"marketing": {
|
||||||
|
"badge": null,
|
||||||
|
"subtitle": null
|
||||||
|
},
|
||||||
|
"allowances": {
|
||||||
|
"max_garages": 1,
|
||||||
|
"max_vehicles": 3,
|
||||||
|
"monthly_free_credits": 0
|
||||||
|
},
|
||||||
|
"pricing_zones": {
|
||||||
|
"HU": {
|
||||||
|
"currency": "HUF",
|
||||||
|
"credit_price": 25000,
|
||||||
|
"yearly_price": 9990.0,
|
||||||
|
"monthly_price": 990.0
|
||||||
|
},
|
||||||
|
"US": {
|
||||||
|
"currency": "USD",
|
||||||
|
"credit_price": 25000,
|
||||||
|
"yearly_price": 35.9,
|
||||||
|
"monthly_price": 3.5
|
||||||
|
},
|
||||||
|
"DEFAULT": {
|
||||||
|
"currency": "EUR",
|
||||||
|
"credit_price": 25000,
|
||||||
|
"yearly_price": 29.9,
|
||||||
|
"monthly_price": 2.99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# feature_capabilities data (as stored)
|
||||||
|
CORP_PREMIUM_PLUS_V1_FC_RAW = "{}"
|
||||||
|
CORP_PREMIUM_V1_FC_RAW = "{}"
|
||||||
|
PRIVATE_PRO_V1_FC_RAW = """{"export_data": false, "advanced_reports": true, "max_cost_category_depth": 3}"""
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# JSON double-encoding simulation
|
||||||
|
# =============================================================================
|
||||||
|
DOUBLE_ENCODED_RAW = json.dumps(CORP_PREMIUM_PLUS_V1_RAW)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test harness
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def print_separator(title: str):
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print(f" {title}")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_tier(
|
||||||
|
tier_name: str,
|
||||||
|
rules_raw: str,
|
||||||
|
fc_raw: str,
|
||||||
|
vehicle_keys: list,
|
||||||
|
branch_keys: list,
|
||||||
|
user_keys: list,
|
||||||
|
expected_vehicles: int,
|
||||||
|
expected_branches: int,
|
||||||
|
expected_users: int,
|
||||||
|
):
|
||||||
|
"""Test _extract_limit for a single tier, simulating the exact endpoint logic."""
|
||||||
|
print(f"\n ┌─ Tier: {tier_name}")
|
||||||
|
|
||||||
|
rules = json.loads(rules_raw)
|
||||||
|
fc = json.loads(fc_raw)
|
||||||
|
|
||||||
|
print(f" ├─ rules type: {type(rules).__name__}")
|
||||||
|
print(f" ├─ rules keys: {list(rules.keys())}")
|
||||||
|
print(f" ├─ 'allowances' key present: {'allowances' in rules}")
|
||||||
|
if 'allowances' in rules:
|
||||||
|
print(f" ├─ allowances content: {json.dumps(rules['allowances'], indent=4)}")
|
||||||
|
|
||||||
|
base_vehicles = _extract_limit(
|
||||||
|
rules, vehicle_keys,
|
||||||
|
_extract_limit(fc, vehicle_keys, 1),
|
||||||
|
)
|
||||||
|
base_branches = _extract_limit(
|
||||||
|
rules, branch_keys,
|
||||||
|
_extract_limit(fc, branch_keys, 0),
|
||||||
|
)
|
||||||
|
base_users = _extract_limit(
|
||||||
|
rules, user_keys,
|
||||||
|
_extract_limit(fc, user_keys, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" ├─ RESULT: vehicles={base_vehicles} (expected={expected_vehicles})")
|
||||||
|
print(f" ├─ RESULT: branches={base_branches} (expected={expected_branches})")
|
||||||
|
print(f" └─ RESULT: users={base_users} (expected={expected_users})")
|
||||||
|
|
||||||
|
status = "✅ PASS" if (base_vehicles == expected_vehicles and
|
||||||
|
base_branches == expected_branches and
|
||||||
|
base_users == expected_users) else "❌ FAIL"
|
||||||
|
print(f" {status}")
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_encoding_scenario():
|
||||||
|
"""Test what happens if the JSON is double-encoded."""
|
||||||
|
print_separator("SIMULATION: Double-encoded JSON (a known anti-pattern)")
|
||||||
|
|
||||||
|
double_encoded = DOUBLE_ENCODED_RAW
|
||||||
|
print(f"\n Double-encoded value preview: {double_encoded[:80]}...")
|
||||||
|
print(f" Double-encoded type: {type(double_encoded).__name__}")
|
||||||
|
|
||||||
|
parsed = json.loads(double_encoded)
|
||||||
|
print(f" After json.loads(): type={type(parsed).__name__}")
|
||||||
|
print(f" After json.loads(): is dict? {isinstance(parsed, dict)}")
|
||||||
|
print(f" After json.loads(): is str? {isinstance(parsed, str)}")
|
||||||
|
|
||||||
|
if isinstance(parsed, str):
|
||||||
|
print(f"\n ❌ DOUBLE-ENCODING DETECTED!")
|
||||||
|
print(f" _extract_limit receives a STRING, not a dict!")
|
||||||
|
print(f" First check: 'if not rules or not isinstance(rules, dict):'")
|
||||||
|
print(f" → Returns default=0 immediately!")
|
||||||
|
result = _extract_limit(parsed, ["max_vehicles", "max_assets"], 0)
|
||||||
|
print(f" _extract_limit(string, ...) = {result}")
|
||||||
|
else:
|
||||||
|
print(f"\n ✅ No double-encoding. Data is properly deserialized as dict.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_cases():
|
||||||
|
"""Test edge cases and potential failure modes."""
|
||||||
|
print_separator("EDGE CASE TESTS")
|
||||||
|
|
||||||
|
result = _extract_limit({}, ["max_vehicles"], 5)
|
||||||
|
print(f" Empty dict -> {result} (expected: 5) {'✅' if result == 5 else '❌'}")
|
||||||
|
|
||||||
|
result = _extract_limit(None, ["max_vehicles"], 5)
|
||||||
|
print(f" None -> {result} (expected: 5) {'✅' if result == 5 else '❌'}")
|
||||||
|
|
||||||
|
result = _extract_limit("not a dict", ["max_vehicles"], 5)
|
||||||
|
print(f" String input -> {result} (expected: 5) {'✅' if result == 5 else '❌'}")
|
||||||
|
|
||||||
|
result = _extract_limit({"max_vehicles": 100}, ["max_vehicles", "max_assets"], 0)
|
||||||
|
print(f" Direct key match -> {result} (expected: 100) {'✅' if result == 100 else '❌'}")
|
||||||
|
|
||||||
|
result = _extract_limit({"allowances": {"max_vehicles": 50}}, ["max_vehicles", "max_assets"], 0)
|
||||||
|
print(f" allowances['max_vehicles'] -> {result} (expected: 50) {'✅' if result == 50 else '❌'}")
|
||||||
|
|
||||||
|
result = _extract_limit({"limits": {"max_vehicles": 25}}, ["max_vehicles", "max_assets"], 0)
|
||||||
|
print(f" limits['max_vehicles'] -> {result} (expected: 25) {'✅' if result == 25 else '❌'}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MAIN
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print_separator("P0 DATA STRUCTURAL AUDIT: _extract_limit() Standalone Test")
|
||||||
|
print(" Date: 2026-06-28")
|
||||||
|
print(" Source: system.subscription_tiers (PostgreSQL JSONB)")
|
||||||
|
|
||||||
|
# --- Test 1: corp_premium_plus_v1 ---
|
||||||
|
print_separator("TEST 1: corp_premium_plus_v1 (id=17)")
|
||||||
|
print(" Expected: vehicles=50, branches=10, users=?")
|
||||||
|
print(" Note: 'max_users'/'max_members' keys NOT present in rules JSON.")
|
||||||
|
print(" The function will fall back to feature_capabilities (empty dict {}),")
|
||||||
|
print(" then to the nested default (0).")
|
||||||
|
|
||||||
|
test_single_tier(
|
||||||
|
tier_name="corp_premium_plus_v1",
|
||||||
|
rules_raw=CORP_PREMIUM_PLUS_V1_RAW,
|
||||||
|
fc_raw=CORP_PREMIUM_PLUS_V1_FC_RAW,
|
||||||
|
vehicle_keys=["max_vehicles", "max_assets"],
|
||||||
|
branch_keys=["max_branches", "max_garages"],
|
||||||
|
user_keys=["max_users", "max_members"],
|
||||||
|
expected_vehicles=50,
|
||||||
|
expected_branches=10,
|
||||||
|
expected_users=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Test 2: corp_premium_v1 ---
|
||||||
|
print_separator("TEST 2: corp_premium_v1 (id=16)")
|
||||||
|
test_single_tier(
|
||||||
|
tier_name="corp_premium_v1",
|
||||||
|
rules_raw=CORP_PREMIUM_V1_RAW,
|
||||||
|
fc_raw=CORP_PREMIUM_V1_FC_RAW,
|
||||||
|
vehicle_keys=["max_vehicles", "max_assets"],
|
||||||
|
branch_keys=["max_branches", "max_garages"],
|
||||||
|
user_keys=["max_users", "max_members"],
|
||||||
|
expected_vehicles=20,
|
||||||
|
expected_branches=3,
|
||||||
|
expected_users=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Test 3: private_pro_v1 ---
|
||||||
|
print_separator("TEST 3: private_pro_v1 (id=14)")
|
||||||
|
test_single_tier(
|
||||||
|
tier_name="private_pro_v1",
|
||||||
|
rules_raw=PRIVATE_PRO_V1_RAW,
|
||||||
|
fc_raw=PRIVATE_PRO_V1_FC_RAW,
|
||||||
|
vehicle_keys=["max_vehicles", "max_assets"],
|
||||||
|
branch_keys=["max_branches", "max_garages"],
|
||||||
|
user_keys=["max_users", "max_members"],
|
||||||
|
expected_vehicles=3,
|
||||||
|
expected_branches=1,
|
||||||
|
expected_users=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Double-encoding simulation ---
|
||||||
|
test_double_encoding_scenario()
|
||||||
|
|
||||||
|
# --- Edge cases ---
|
||||||
|
test_edge_cases()
|
||||||
|
|
||||||
|
# --- Summary ---
|
||||||
|
print_separator("SUMMARY")
|
||||||
|
print("""
|
||||||
|
KEY FINDINGS FROM DB AUDIT:
|
||||||
|
|
||||||
|
1. All corporate tiers (corp_premium_plus_v1, corp_premium_v1)
|
||||||
|
store their limits under rules['allowances'] nested object.
|
||||||
|
|
||||||
|
2. The _extract_limit() function correctly searches at 3 levels:
|
||||||
|
Level 1: Direct keys in root (rules['max_vehicles'])
|
||||||
|
Level 2: Nested under 'allowances' (rules['allowances']['max_vehicles'])
|
||||||
|
Level 3: Nested under 'limits' (rules['limits']['max_vehicles'])
|
||||||
|
|
||||||
|
3. For corp_premium_plus_v1: allowances.max_vehicles = 50
|
||||||
|
Function should return 50, not 0.
|
||||||
|
|
||||||
|
4. ROOT CAUSE SUSPECTED: If _extract_limit returns 0 at runtime,
|
||||||
|
the issue is likely:
|
||||||
|
a) The 'rules' field is double-encoded (string inside JSONB)
|
||||||
|
b) The wrong code path is being executed (e.g., no active_subs found)
|
||||||
|
c) The rules dict is empty {} due to data migration issue
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
80
docs/admin_users_filter_bug_analysis.md
Normal file
80
docs/admin_users_filter_bug_analysis.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Admin Users List Filter Bug Analysis
|
||||||
|
|
||||||
|
**Gitea Issue:** #323
|
||||||
|
**Scope:** Backend & Frontend (Admin UI)
|
||||||
|
**Type:** Bug
|
||||||
|
|
||||||
|
## Root Cause: Frontend-Backend Parameter Name Mismatch
|
||||||
|
|
||||||
|
The admin users list page at [`frontend_admin/pages/users/index.vue`](frontend_admin/pages/users/index.vue:539) sends filter parameters to the backend endpoint [`GET /api/v1/admin/users`](backend/app/api/v1/endpoints/admin.py:491), but the parameter names don't match what the backend expects.
|
||||||
|
|
||||||
|
### Frontend sends (frontend_admin/pages/users/index.vue, lines 543-556):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// What frontend sends:
|
||||||
|
params.status_filter = statusFilter.value // "active", "inactive", "deleted", "banned"
|
||||||
|
params.role_filter = roleFilter.value // "user", "admin", "staff", "superadmin"
|
||||||
|
params.plan_filter = planFilter.value // "FREE", "PREMIUM", "ENTERPRISE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend expects (backend/app/api/v1/endpoints/admin.py, lines 491-656):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# What backend expects:
|
||||||
|
role: Optional[str] = Query(None) # NOT role_filter
|
||||||
|
is_active: Optional[bool] = Query(None) # NOT status_filter
|
||||||
|
is_deleted: Optional[bool] = Query(None) # NOT status_filter
|
||||||
|
# plan_filter: NOT SUPPORTED AT ALL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detailed Issues
|
||||||
|
|
||||||
|
### Issue 1: Status Filter (`status_filter` vs `is_active`/`is_deleted`)
|
||||||
|
|
||||||
|
| Frontend sends | Should map to backend |
|
||||||
|
|---------------|----------------------|
|
||||||
|
| `status_filter=active` | `is_active=true, is_deleted=false` |
|
||||||
|
| `status_filter=inactive` | `is_active=false, is_deleted=false` |
|
||||||
|
| `status_filter=deleted` | `is_deleted=true` |
|
||||||
|
| `status_filter=banned` | `is_active=false` (no `is_banned` field exists) |
|
||||||
|
|
||||||
|
### Issue 2: Role Filter (`role_filter` vs `role`)
|
||||||
|
|
||||||
|
Simple rename: `role_filter` → `role`
|
||||||
|
|
||||||
|
### Issue 3: Plan Filter (`plan_filter` not supported)
|
||||||
|
|
||||||
|
The backend has **no** `plan_filter` or `subscription_plan` query parameter at all. The `User` model does have a `subscription_plan` column (backend/app/models/identity/identity.py:152), so this needs to be added to the backend.
|
||||||
|
|
||||||
|
### Issue 4: `person_name` missing from response
|
||||||
|
|
||||||
|
The frontend `UserListItem` interface expects `person_name` (line 417), but the backend returns `first_name` and `last_name` separately (lines 640-641). The frontend displays `user.person_name || '—'` (line 246), which means it will always show `—` because `person_name` is never populated.
|
||||||
|
|
||||||
|
## Fix Plan
|
||||||
|
|
||||||
|
### Backend Changes (backend/app/api/v1/endpoints/admin.py)
|
||||||
|
|
||||||
|
1. **Add `plan_filter` query parameter** to the `list_users` endpoint:
|
||||||
|
- Accept `plan_filter: Optional[str]`
|
||||||
|
- Filter by `User.subscription_plan == plan_filter`
|
||||||
|
- Valid values: "FREE", "PREMIUM", "ENTERPRISE" (plus "all")
|
||||||
|
|
||||||
|
2. **Add `person_name` to the response** by concatenating `first_name` and `last_name`.
|
||||||
|
|
||||||
|
### Frontend Changes (frontend_admin/pages/users/index.vue)
|
||||||
|
|
||||||
|
1. **Rename parameter**: Change `params.role_filter` → `params.role`
|
||||||
|
2. **Status filter logic**: Replace `params.status_filter` with proper mapping:
|
||||||
|
- `active` → `params.is_active = true`
|
||||||
|
- `inactive` → `params.is_active = false`
|
||||||
|
- `deleted` → `params.is_deleted = true`
|
||||||
|
- `banned` → `params.is_active = false` (since banned users are deactivated)
|
||||||
|
3. **Keep `plan_filter`** as-is (backend will support it after fix)
|
||||||
|
4. **Fix `person_name`** display: Compute it from `first_name` + `last_name` if available
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `backend/app/api/v1/endpoints/admin.py` | Add `plan_filter` param, add `person_name` to response |
|
||||||
|
| `frontend_admin/pages/users/index.vue` | Fix parameter names and status mapping in `fetchUsers()` |
|
||||||
189
docs/p0_subscription_jsonb_structural_audit_report.md
Normal file
189
docs/p0_subscription_jsonb_structural_audit_report.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# P0 DATA STRUCTURAL AUDIT REPORT: `_extract_limit()` & JSONB Tier Structure
|
||||||
|
|
||||||
|
**Date:** 2026-06-28
|
||||||
|
**Author:** System Architect
|
||||||
|
**Scope:** [`system.subscription_tiers`](backend/app/models/core_logic.py:68), [`finance.org_subscriptions`](backend/app/models/core_logic.py:143), [`_extract_limit()`](backend/app/api/v1/endpoints/admin_organizations.py:397)
|
||||||
|
**Status:** ✅ Structural audit complete — `_extract_limit` logic is **correct**, DB JSONB is **valid**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. AUDIT: `system.subscription_tiers` (Base Tier Rules)
|
||||||
|
|
||||||
|
### Raw JSON Structure
|
||||||
|
|
||||||
|
All three corporate tiers have their limits stored at **`rules["allowances"]`** (nested object, 2nd level):
|
||||||
|
|
||||||
|
| Tier | ID | `jsonb_typeof(rules)` | `allowances.max_vehicles` | `allowances.max_garages` |
|
||||||
|
|------|----|----------------------|--------------------------|--------------------------|
|
||||||
|
| `private_pro_v1` | 14 | `object` ✅ | 3 | 1 |
|
||||||
|
| `corp_premium_v1` | 16 | `object` ✅ | 20 | 3 |
|
||||||
|
| `corp_premium_plus_v1` | 17 | `object` ✅ | 50 | 10 |
|
||||||
|
|
||||||
|
### Full JSON for `corp_premium_plus_v1` (id=17)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "corporate",
|
||||||
|
"pricing": { "currency": "EUR", "credit_price": 6000, "yearly_price": 599.99, "monthly_price": 59.99 },
|
||||||
|
"duration": { "days": 30, "allow_stacking": true },
|
||||||
|
"ad_policy": { "show_ads": false, "ad_free_grace_days": 0, "max_daily_impressions": null },
|
||||||
|
"affiliate": { "referral_bonus_credits": 250, "commission_rate_percent": 20 },
|
||||||
|
"lifecycle": { "is_public": true },
|
||||||
|
"marketing": { "badge": "Plus", "subtitle": "Nagy flották prémium menedzsmentje.", "highlight_color": "#8B5CF6" },
|
||||||
|
"allowances": { "max_garages": 10, "max_vehicles": 50, "monthly_free_credits": 800 },
|
||||||
|
"display_name": "Ceiges Preimium Plus",
|
||||||
|
"entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD", "SRV_ACCOUNTING_SYNC"],
|
||||||
|
"pricing_zones": { "HU": { "currency": "HUF", "credit_price": 6000, "yearly_price": 239990, "monthly_price": 23990 }, "DEFAULT": { "currency": "EUR", "credit_price": 6000, "yearly_price": 599.99, "monthly_price": 59.99 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Confirmed: `rules` is a proper JSONB **object**, NOT a string
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT jsonb_typeof(rules) FROM system.subscription_tiers WHERE id = 17;
|
||||||
|
-- Result: "object" ✅ (not "string")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. AUDIT: `finance.org_subscriptions` (Active Subs & Extra Allowances)
|
||||||
|
|
||||||
|
| org_id | Sub ID | Tier | Tier ID | `extra_allowances` |
|
||||||
|
|--------|--------|------|---------|-------------------|
|
||||||
|
| 15 | 2 | `corp_premium_v1` | 16 | (not queried) |
|
||||||
|
| 43 | 30 | `private_pro_v1` | 14 | `{}` |
|
||||||
|
| 44 | 22 | `corp_premium_v1` | 16 | (not queried) |
|
||||||
|
| 45 | 21 | `corp_premium_plus_v1` | 17 | (not queried) |
|
||||||
|
| 49 | 20 | `corp_premium_v1` | 16 | (not queried) |
|
||||||
|
|
||||||
|
For org_id=43: `extra_allowances` = `{}` (empty JSONB object) — no extra allowances purchased.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. TEST SCRIPT: `_extract_limit()` Behavior
|
||||||
|
|
||||||
|
### Implementation (exact copy from [`admin_organizations.py:397`](backend/app/api/v1/endpoints/admin_organizations.py:397))
|
||||||
|
|
||||||
|
The function searches at **3 levels**:
|
||||||
|
1. **Level 1**: Direct keys at rules root (`rules["max_vehicles"]`)
|
||||||
|
2. **Level 2**: Nested under `rules["allowances"]` (`rules["allowances"]["max_vehicles"]`)
|
||||||
|
3. **Level 3**: Nested under `rules["limits"]` (`rules["limits"]["max_vehicles"]`)
|
||||||
|
|
||||||
|
### Test Results — ALL PASS ✅
|
||||||
|
|
||||||
|
| Tier | Vehicles | Branches | Users | Status |
|
||||||
|
|------|----------|----------|-------|--------|
|
||||||
|
| `corp_premium_plus_v1` | **50** ✅ | **10** ✅ | 0 | **PASS** |
|
||||||
|
| `corp_premium_v1` | **20** ✅ | **3** ✅ | 0 | **PASS** |
|
||||||
|
| `private_pro_v1` | **3** ✅ | **1** ✅ | 0 | **PASS** |
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
| Input | Result | Expected | Status |
|
||||||
|
|-------|--------|----------|--------|
|
||||||
|
| Empty dict `{}` | 5 (default) | 5 | ✅ |
|
||||||
|
| `None` | 5 (default) | 5 | ✅ |
|
||||||
|
| String (not dict) | 5 (default) | 5 | ✅ |
|
||||||
|
| Direct key match | 100 | 100 | ✅ |
|
||||||
|
| `allowances.max_vehicles` | 50 | 50 | ✅ |
|
||||||
|
| `limits.max_vehicles` | 25 | 25 | ✅ |
|
||||||
|
|
||||||
|
### Double-Encoding Simulation
|
||||||
|
|
||||||
|
When the JSON is **double-encoded** (string stored inside JSONB instead of object):
|
||||||
|
|
||||||
|
```
|
||||||
|
Input: '"{\\"allowances\\": {\\"max_vehicles\\": 50}}"'
|
||||||
|
→ json.loads() returns str, not dict
|
||||||
|
→ _extract_limit(str, ...) returns default=0 ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Confirmed: Current DB data is NOT double-encoded.** (`jsonb_typeof = "object"`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ROOT CAUSE ANALYSIS
|
||||||
|
|
||||||
|
### The `_extract_limit()` function is **NOT the problem**
|
||||||
|
|
||||||
|
All tests confirm the function correctly extracts limits from the current JSONB structure. It fully supports the `allowances` nested format used by all tiers.
|
||||||
|
|
||||||
|
### The REAL problem is likely in the **code flow** before `_extract_limit` is called
|
||||||
|
|
||||||
|
The endpoint [`get_organization_details()`](backend/app/api/v1/endpoints/admin_organizations.py:462) has **two code paths**:
|
||||||
|
|
||||||
|
#### Path A (line 579-664) — Uses `finance.org_subscriptions` (NEW system)
|
||||||
|
|
||||||
|
```python
|
||||||
|
active_subs = sub_result.scalars().all() # Query finance.org_subscriptions
|
||||||
|
if active_subs:
|
||||||
|
base_subs = [s for s in active_subs if s.tier and s.tier.type in (None, '', 'base')]
|
||||||
|
primary_sub = base_subs_sorted[0] if base_subs_sorted else ...
|
||||||
|
if primary_sub and primary_sub.tier:
|
||||||
|
primary_rules = primary_sub.tier.rules or {} # ← THIS must work
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Path B (line 665-691) — Uses `fleet.organizations.subscription_tier_id` (LEGACY)
|
||||||
|
|
||||||
|
```python
|
||||||
|
elif org.subscription_tier:
|
||||||
|
rules = org.subscription_tier.rules or {} # ← LEGACY path
|
||||||
|
```
|
||||||
|
|
||||||
|
### Potential failure scenarios (outside `_extract_limit`):
|
||||||
|
|
||||||
|
| Scenario | Symptom | Likelihood |
|
||||||
|
|----------|---------|------------|
|
||||||
|
| **Path A → `active_subs` is empty** (no `finance.org_subscriptions` row) | Falls to Path B | Medium |
|
||||||
|
| **Path A → `primary_sub.tier` is `None`** (FK broken, tier missing) | Skips limit calc, `subscription_summary` stays `None` | Low |
|
||||||
|
| **Path B → `org.subscription_tier` points to wrong tier** (old FK mismatch) | Wrong limits returned | Medium |
|
||||||
|
| **`org.subscription_tier.rules` is `None`** (tier exists but rules column is NULL) | `{} or None` → `{}` → default | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. RECOMMENDATIONS
|
||||||
|
|
||||||
|
### ✅ Short-term: Verify runtime dataflow
|
||||||
|
|
||||||
|
1. **Test with exact org_id** that shows the 0 limit:
|
||||||
|
```sql
|
||||||
|
SELECT os.id, os.org_id, os.tier_id, st.name, st.rules IS NOT NULL as has_rules
|
||||||
|
FROM finance.org_subscriptions os
|
||||||
|
JOIN system.subscription_tiers st ON os.tier_id = st.id
|
||||||
|
WHERE os.org_id = <problematic_org_id> AND os.is_active = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check if the endpoint takes Path A or Path B** by inspecting logs/Debug mode.
|
||||||
|
|
||||||
|
### ✅ Long-term: Code improvements (separate ticket)
|
||||||
|
|
||||||
|
1. **Add logging** at key decision points in [`get_organization_details()`](backend/app/api/v1/endpoints/admin_organizations.py:579-614):
|
||||||
|
- Log `active_subs` count
|
||||||
|
- Log which path is taken (A vs B)
|
||||||
|
- Log `primary_rules` keys and extracted limits
|
||||||
|
- Log if `primary_sub.tier` is None
|
||||||
|
|
||||||
|
2. **Add defensive fallback** — if `_extract_limit` returns 0 but `rules` has `allowances`, log a warning:
|
||||||
|
```python
|
||||||
|
base_vehicles = _extract_limit(primary_rules, ["max_vehicles", "max_assets"], fc_fallback)
|
||||||
|
if base_vehicles == 0 and primary_rules.get("allowances", {}).get("max_vehicles"):
|
||||||
|
logger.warning(f"_extract_limit returned 0 despite allowances.max_vehicles={primary_rules['allowances']['max_vehicles']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔍 Most Probable Root Cause
|
||||||
|
|
||||||
|
The issue is likely that **the org(s) reporting 0 limits do NOT have an active row in `finance.org_subscriptions`** (Path A), and the **legacy Path B is failing** because `org.subscription_tier.rules` is `None` or the old FK points to a tier that doesn't have `allowances` populated.
|
||||||
|
|
||||||
|
**Next step**: Identify the exact org_id(s) that show 0 and check their `finance.org_subscriptions` presence and `fleet.organizations.subscription_tier_id` value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Test Script Location
|
||||||
|
|
||||||
|
The standalone test script is at:
|
||||||
|
[`tests/active/test_extract_limit_standalone.py`](tests/active/test_extract_limit_standalone.py)
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
```bash
|
||||||
|
docker compose exec sf_api python3 /app/tests/test_extract_limit_standalone.py
|
||||||
|
```
|
||||||
259
docs/p0_subscription_math_trace_report.md
Normal file
259
docs/p0_subscription_math_trace_report.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# P0 DEEP DIVE DIAGNOSTIC — Subscription Math Trace Report
|
||||||
|
|
||||||
|
**Date:** 2026-06-27
|
||||||
|
**Author:** Fast Coder (Core Developer)
|
||||||
|
**Scope:** Root cause analysis of `asset_limit` returning 0 for `corp_premium_plus_v1` tier
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. DATABASE DUMP — Raw JSONB for `corp_premium_plus_v1`
|
||||||
|
|
||||||
|
### Query
|
||||||
|
```sql
|
||||||
|
SELECT id, name, rules, feature_capabilities
|
||||||
|
FROM system.subscription_tiers
|
||||||
|
WHERE name = 'corp_premium_plus_v1';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Result
|
||||||
|
|
||||||
|
| Column | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| `id` | `17` |
|
||||||
|
| `name` | `corp_premium_plus_v1` |
|
||||||
|
| `rules` (JSONB) | See below |
|
||||||
|
| `feature_capabilities` | `{}` (empty object) |
|
||||||
|
|
||||||
|
### Raw `rules` JSONB (pretty-printed)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "corporate",
|
||||||
|
"pricing": {
|
||||||
|
"currency": "EUR",
|
||||||
|
"credit_price": 6000,
|
||||||
|
"yearly_price": 599.99,
|
||||||
|
"monthly_price": 59.99
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"days": 30,
|
||||||
|
"allow_stacking": true
|
||||||
|
},
|
||||||
|
"ad_policy": {
|
||||||
|
"show_ads": false,
|
||||||
|
"ad_free_grace_days": 0,
|
||||||
|
"max_daily_impressions": null
|
||||||
|
},
|
||||||
|
"affiliate": {
|
||||||
|
"referral_bonus_credits": 250,
|
||||||
|
"commission_rate_percent": 20
|
||||||
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"is_public": true
|
||||||
|
},
|
||||||
|
"marketing": {
|
||||||
|
"badge": "Plus",
|
||||||
|
"subtitle": "Nagy flották prémium menedzsmentje.",
|
||||||
|
"highlight_color": "#8B5CF6"
|
||||||
|
},
|
||||||
|
"allowances": {
|
||||||
|
"max_garages": 10,
|
||||||
|
"max_vehicles": 50,
|
||||||
|
"monthly_free_credits": 800
|
||||||
|
},
|
||||||
|
"display_name": "Céges Prémium Plus",
|
||||||
|
"entitlements": [
|
||||||
|
"SRV_DATA_EXPORT",
|
||||||
|
"SRV_AI_UPLOAD",
|
||||||
|
"SRV_ACCOUNTING_SYNC"
|
||||||
|
],
|
||||||
|
"pricing_zones": {
|
||||||
|
"HU": { "currency": "HUF", "credit_price": 6000, "yearly_price": 239990, "monthly_price": 23990 },
|
||||||
|
"US": { "currency": "USD", "credit_price": 6000, "yearly_price": 699.99, "monthly_price": 69.99 },
|
||||||
|
"DEFAULT": { "currency": "EUR", "credit_price": 6000, "yearly_price": 599.99, "monthly_price": 59.99 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Observation
|
||||||
|
|
||||||
|
The vehicle limit **`max_vehicles: 50`** is stored **inside a nested `allowances` object** within the `rules` JSONB column:
|
||||||
|
|
||||||
|
```
|
||||||
|
rules → allowances → max_vehicles: 50
|
||||||
|
```
|
||||||
|
|
||||||
|
The `feature_capabilities` column is **empty** (`{}`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MATH TRACE — The Aggregation Logic
|
||||||
|
|
||||||
|
### File: [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:600)
|
||||||
|
|
||||||
|
### The Formula (line 680)
|
||||||
|
|
||||||
|
```python
|
||||||
|
asset_limit = base_vehicles + extra_vehicles + addon_vehicles
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- `base_vehicles` ← extracted from the **base tier's** `rules` + `feature_capabilities` + `extra_allowances`
|
||||||
|
- `extra_vehicles` ← from `base_extra.get("extra_vehicles", 0)` (admin overrides in `OrganizationSubscription.extra_allowances`)
|
||||||
|
- `addon_vehicles` ← summed across all active add-on subscriptions
|
||||||
|
|
||||||
|
### The `_extract_limit` Function (lines 601–630)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _extract_limit(
|
||||||
|
rules: dict,
|
||||||
|
fc: dict,
|
||||||
|
extra: dict,
|
||||||
|
vehicle_keys: tuple = ("max_vehicles", "vehicle_limit", "max_assets", "assets_limit"),
|
||||||
|
branch_keys: tuple = ("max_branches", "branch_limit", "max_garages", "garages_limit"),
|
||||||
|
user_keys: tuple = ("max_users", "user_limit", "max_members", "members_limit"),
|
||||||
|
default: int = 0,
|
||||||
|
):
|
||||||
|
def _pick(keys: tuple) -> int:
|
||||||
|
# 1. Check root level of rules, fc, extra
|
||||||
|
for d in (rules, fc, extra):
|
||||||
|
for k in keys:
|
||||||
|
val = d.get(k)
|
||||||
|
if val is not None and isinstance(val, (int, float)):
|
||||||
|
return int(val)
|
||||||
|
# 2. Check nested 'allowances' object inside rules
|
||||||
|
allowances = rules.get("allowances", {})
|
||||||
|
if isinstance(allowances, dict):
|
||||||
|
for k in keys:
|
||||||
|
val = allowances.get(k)
|
||||||
|
if val is not None and isinstance(val, (int, float)):
|
||||||
|
return int(val)
|
||||||
|
return default
|
||||||
|
return _pick(vehicle_keys), _pick(branch_keys), _pick(user_keys)
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It's Called (line 648)
|
||||||
|
|
||||||
|
```python
|
||||||
|
base_vehicles, base_branches, base_users = _extract_limit(rules, fc, base_extra)
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- `rules` = `active_base_sub.tier.rules` (the JSONB shown above)
|
||||||
|
- `fc` = `active_base_sub.tier.feature_capabilities` → `{}` (empty)
|
||||||
|
- `base_extra` = `active_base_sub.extra_allowances` → `{}` (empty, unless admin set overrides)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ROOT CAUSE ANALYSIS
|
||||||
|
|
||||||
|
### Step-by-step trace of `_pick(vehicle_keys)`:
|
||||||
|
|
||||||
|
**Phase 1 — Root level scan of `rules`, `fc`, `extra`:**
|
||||||
|
|
||||||
|
The function iterates over `(rules, fc, extra)` and checks each key in `vehicle_keys = ("max_vehicles", "vehicle_limit", "max_assets", "assets_limit")`.
|
||||||
|
|
||||||
|
| Dictionary | `max_vehicles` | `vehicle_limit` | `max_assets` | `assets_limit` |
|
||||||
|
|------------|---------------|-----------------|--------------|----------------|
|
||||||
|
| `rules` (root level) | ❌ Not found | ❌ Not found | ❌ Not found | ❌ Not found |
|
||||||
|
| `fc` = `{}` | ❌ Not found | ❌ Not found | ❌ Not found | ❌ Not found |
|
||||||
|
| `extra` = `{}` | ❌ Not found | ❌ Not found | ❌ Not found | ❌ Not found |
|
||||||
|
|
||||||
|
**Result: `None` from all three dictionaries.**
|
||||||
|
|
||||||
|
**Phase 2 — Nested `allowances` scan:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
allowances = rules.get("allowances", {}) # → {"max_garages": 10, "max_vehicles": 50, "monthly_free_credits": 800}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now it checks `allowances.get("max_vehicles")` → **`50`** ✓
|
||||||
|
|
||||||
|
**This SHOULD work!** The nested `allowances` fallback was added as a "P0 JSONB NESTING HOTFIX" (see comment on line 612).
|
||||||
|
|
||||||
|
### The Real Problem: TWO CODE PATHS
|
||||||
|
|
||||||
|
The `get_organization_details` function has **two separate code paths** for computing the subscription summary:
|
||||||
|
|
||||||
|
#### Path A: `active_sub` exists (line 519–550) — **OLD CODE**
|
||||||
|
```python
|
||||||
|
if active_sub and active_sub.tier:
|
||||||
|
rules = active_sub.tier.rules or {}
|
||||||
|
fc = active_sub.tier.feature_capabilities or {}
|
||||||
|
extra = active_sub.extra_allowances or {}
|
||||||
|
|
||||||
|
# Base limits from tier rules (with explicit .get() — no `or` bug)
|
||||||
|
base_vehicles = rules.get("max_vehicles", fc.get("max_vehicles", 1))
|
||||||
|
base_branches = rules.get("max_branches", fc.get("max_branches", 0))
|
||||||
|
base_users = rules.get("max_users", fc.get("max_users", 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
**BUG:** This path uses `rules.get("max_vehicles", ...)` directly — it does **NOT** check the nested `allowances` object! Since `max_vehicles` is at `rules → allowances → max_vehicles`, not at `rules → max_vehicles`, this returns the fallback value of `1`.
|
||||||
|
|
||||||
|
#### Path B: Multi-subscription aggregation (line 600–699) — **NEW CODE with `_extract_limit`**
|
||||||
|
```python
|
||||||
|
if active_base_sub and active_base_sub.tier:
|
||||||
|
rules = active_base_sub.tier.rules or {}
|
||||||
|
fc = active_base_sub.tier.feature_capabilities or {}
|
||||||
|
base_extra = active_base_sub.extra_allowances or {}
|
||||||
|
|
||||||
|
base_vehicles, base_branches, base_users = _extract_limit(rules, fc, base_extra)
|
||||||
|
```
|
||||||
|
|
||||||
|
**This path DOES work correctly** because `_extract_limit` checks the nested `allowances` object.
|
||||||
|
|
||||||
|
### Which path is being hit?
|
||||||
|
|
||||||
|
Looking at the subscription query (lines 443–455):
|
||||||
|
|
||||||
|
```python
|
||||||
|
sub_stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.options(selectinload(OrganizationSubscription.tier))
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
OrganizationSubscription.valid_until >= now,
|
||||||
|
)
|
||||||
|
.order_by(OrganizationSubscription.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
sub_result = await db.execute(sub_stmt)
|
||||||
|
active_sub = sub_result.scalar_one_or_none()
|
||||||
|
```
|
||||||
|
|
||||||
|
This query returns a **single** subscription row (`.limit(1)`). If the org has a `finance.org_subscriptions` row with `is_active=True` and `valid_until >= now`, then `active_sub` is set and **Path A** is executed — which has the bug.
|
||||||
|
|
||||||
|
**Path B** (the multi-subscription aggregation with `_extract_limit`) is only reached if the subscription query returns NO rows (`active_sub` is None), which would be the case if the org has no subscription record.
|
||||||
|
|
||||||
|
### Summary of the Bug
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Bug Location** | [`admin_organizations.py:525`](backend/app/api/v1/endpoints/admin_organizations.py:525) — Path A |
|
||||||
|
| **Root Cause** | `rules.get("max_vehicles", ...)` only checks the **root level** of the `rules` JSONB. The actual value is nested inside `rules → allowances → max_vehicles`. |
|
||||||
|
| **Affected Data** | All tiers that store limits inside a nested `allowances` object (all tiers in the DB use this structure) |
|
||||||
|
| **Why it returns 0** | It doesn't return 0 — it returns `1` (the fallback from `fc.get("max_vehicles", 1)`). But the **Total Limit** equals the **Extra Limit** because the base calculation returns only the fallback `1` instead of the real `50`. |
|
||||||
|
| **Fix** | Path A needs to also check the nested `allowances` object, just like `_extract_limit` does in Path B. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. COMPARISON: `evidence.py` Works Correctly
|
||||||
|
|
||||||
|
Interestingly, the [`evidence.py`](backend/app/api/v1/endpoints/evidence.py:36) endpoint already handles this correctly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if tier and tier.rules:
|
||||||
|
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1)
|
||||||
|
max_allowed = max(int(max_vehicles), 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
It directly accesses `rules → allowances → max_vehicles` with the nested `.get("allowances", {}).get("max_vehicles", 1)` pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. CONCLUSION
|
||||||
|
|
||||||
|
The `_extract_limit` function (Path B) is **correct** — it properly handles the nested `allowances` structure. However, the **old Path A** (lines 519–550) is still active and is the one being hit when the org has a valid `finance.org_subscriptions` row. Path A uses `rules.get("max_vehicles", ...)` which only checks the root level of the JSONB, missing the nested `allowances` object.
|
||||||
|
|
||||||
|
**The fix is to update Path A** (lines 525–527) to also search inside the nested `allowances` object, or to refactor Path A to use the same `_extract_limit` helper that Path B uses.
|
||||||
8
frontend_admin/.nuxt/components.d.ts
vendored
8
frontend_admin/.nuxt/components.d.ts
vendored
@@ -14,6 +14,10 @@ type HydrationStrategies = {
|
|||||||
type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
|
type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
|
||||||
|
|
||||||
|
|
||||||
|
export const ChartsUserTrendChart: typeof import("../components/charts/UserTrendChart.vue")['default']
|
||||||
|
export const GaragesGarageFleetTab: typeof import("../components/garages/GarageFleetTab.vue")['default']
|
||||||
|
export const GaragesGarageGeneralTab: typeof import("../components/garages/GarageGeneralTab.vue")['default']
|
||||||
|
export const GaragesGarageSubscriptionTab: typeof import("../components/garages/GarageSubscriptionTab.vue")['default']
|
||||||
export const NuxtWelcome: typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['default']
|
export const NuxtWelcome: typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['default']
|
||||||
export const NuxtLayout: typeof import("../node_modules/nuxt/dist/app/components/nuxt-layout")['default']
|
export const NuxtLayout: typeof import("../node_modules/nuxt/dist/app/components/nuxt-layout")['default']
|
||||||
export const NuxtErrorBoundary: typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']
|
export const NuxtErrorBoundary: typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']
|
||||||
@@ -39,6 +43,10 @@ export const Head: typeof import("../node_modules/nuxt/dist/head/runtime/compone
|
|||||||
export const Html: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Html']
|
export const Html: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Html']
|
||||||
export const Body: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Body']
|
export const Body: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Body']
|
||||||
export const NuxtIsland: typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']
|
export const NuxtIsland: typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']
|
||||||
|
export const LazyChartsUserTrendChart: LazyComponent<typeof import("../components/charts/UserTrendChart.vue")['default']>
|
||||||
|
export const LazyGaragesGarageFleetTab: LazyComponent<typeof import("../components/garages/GarageFleetTab.vue")['default']>
|
||||||
|
export const LazyGaragesGarageGeneralTab: LazyComponent<typeof import("../components/garages/GarageGeneralTab.vue")['default']>
|
||||||
|
export const LazyGaragesGarageSubscriptionTab: LazyComponent<typeof import("../components/garages/GarageSubscriptionTab.vue")['default']>
|
||||||
export const LazyNuxtWelcome: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['default']>
|
export const LazyNuxtWelcome: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['default']>
|
||||||
export const LazyNuxtLayout: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-layout")['default']>
|
export const LazyNuxtLayout: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-layout")['default']>
|
||||||
export const LazyNuxtErrorBoundary: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']>
|
export const LazyNuxtErrorBoundary: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']>
|
||||||
|
|||||||
@@ -2311,6 +2311,7 @@ var packages$1 = {
|
|||||||
allowances: "Korlátok",
|
allowances: "Korlátok",
|
||||||
max_vehicles: "Max Jármű",
|
max_vehicles: "Max Jármű",
|
||||||
max_garages: "Max Garázs",
|
max_garages: "Max Garázs",
|
||||||
|
max_users: "Max Dolgozó",
|
||||||
monthly_credits: "Havi Kredit",
|
monthly_credits: "Havi Kredit",
|
||||||
tier_level: "Szint",
|
tier_level: "Szint",
|
||||||
form_name: "Rendszer Név",
|
form_name: "Rendszer Név",
|
||||||
@@ -2389,6 +2390,16 @@ var garages$1 = {
|
|||||||
subscription_updated: "előfizetése sikeresen frissítve!",
|
subscription_updated: "előfizetése sikeresen frissítve!",
|
||||||
view_details: "Részletek",
|
view_details: "Részletek",
|
||||||
indefinite: "Határozatlan",
|
indefinite: "Határozatlan",
|
||||||
|
addons_title: "Kiegészítők (Add-ons)",
|
||||||
|
addon_vehicles: "Extra Jármű",
|
||||||
|
addon_branches: "Extra Telephely",
|
||||||
|
addon_users: "Extra Dolgozó",
|
||||||
|
active_addons: "Aktív kiegészítők",
|
||||||
|
no_active_addons: "Nincsenek aktív kiegészítők",
|
||||||
|
addon_vehicles_expiration: "Extra Jármű lejárati dátuma",
|
||||||
|
addon_branches_expiration: "Extra Telephely lejárati dátuma",
|
||||||
|
addon_users_expiration: "Extra Dolgozó lejárati dátuma",
|
||||||
|
addon_expiration_hint: "Ha üres, a meglévő lejárati dátumok megmaradnak.",
|
||||||
details: {
|
details: {
|
||||||
loading: "Garázs adatainak betöltése...",
|
loading: "Garázs adatainak betöltése...",
|
||||||
retry: "Újra",
|
retry: "Újra",
|
||||||
@@ -2462,6 +2473,8 @@ var garages$1 = {
|
|||||||
vehicles_used: "Járművek",
|
vehicles_used: "Járművek",
|
||||||
branches_used: "Telephelyek",
|
branches_used: "Telephelyek",
|
||||||
employees_used: "Dolgozók",
|
employees_used: "Dolgozók",
|
||||||
|
branches: "telephely",
|
||||||
|
employees: "dolgozó",
|
||||||
status: "Státusz",
|
status: "Státusz",
|
||||||
subscription_history: "Előfizetési Előzmények",
|
subscription_history: "Előfizetési Előzmények",
|
||||||
history_date: "Dátum",
|
history_date: "Dátum",
|
||||||
@@ -2545,10 +2558,109 @@ var garages$1 = {
|
|||||||
no_fleet_access: "Nincs jogosultságod a flotta adatok megtekintéséhez."
|
no_fleet_access: "Nincs jogosultságod a flotta adatok megtekintéséhez."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var users$1 = {
|
||||||
|
title: "Felhasználó Kezelés",
|
||||||
|
subtitle: "Felhasználók listája, keresés, szűrés és tömeges műveletek",
|
||||||
|
loading: "Felhasználók betöltése...",
|
||||||
|
retry: "Újra",
|
||||||
|
search_placeholder: "Keresés email, név vagy telefonszám alapján...",
|
||||||
|
all_statuses: "Összes státusz",
|
||||||
|
status_active: "Aktív",
|
||||||
|
status_inactive: "Inaktív",
|
||||||
|
status_deleted: "Törölt",
|
||||||
|
status_banned: "Kitiltott",
|
||||||
|
all_roles: "Összes szerepkör",
|
||||||
|
role_user: "Felhasználó",
|
||||||
|
role_admin: "Admin",
|
||||||
|
role_staff: "Munkatárs",
|
||||||
|
role_superadmin: "Superadmin",
|
||||||
|
all_plans: "Összes csomag",
|
||||||
|
plan_free: "Ingyenes",
|
||||||
|
plan_premium: "Prémium",
|
||||||
|
plan_enterprise: "Vállalati",
|
||||||
|
total_users: "Összes Felhasználó",
|
||||||
|
active_users: "Aktív Felhasználók",
|
||||||
|
deleted_users: "Törölt Felhasználók",
|
||||||
|
banned_users: "Kitiltott Felhasználók",
|
||||||
|
new_today: "Ma Regisztrált",
|
||||||
|
filtered_results: "Szűrt találat",
|
||||||
|
selected_count: "{count} felhasználó kiválasztva",
|
||||||
|
bulk_activate: "Aktiválás",
|
||||||
|
bulk_deactivate: "Deaktiválás",
|
||||||
|
clear_selection: "Kijelölés törlése",
|
||||||
|
email: "E-mail",
|
||||||
|
name: "Név",
|
||||||
|
role: "Szerepkör",
|
||||||
|
status: "Státusz",
|
||||||
|
"package": "Csomag",
|
||||||
|
registration: "Regisztráció",
|
||||||
|
language: "Nyelv",
|
||||||
|
actions: "Műveletek",
|
||||||
|
view: "Megtekintés",
|
||||||
|
activate: "Aktiválás",
|
||||||
|
deactivate: "Deaktiválás",
|
||||||
|
no_results: "Nincs a keresésnek megfelelő felhasználó.",
|
||||||
|
showing: "Mutató",
|
||||||
|
prev: "Előző",
|
||||||
|
next: "Következő",
|
||||||
|
details: {
|
||||||
|
loading: "Felhasználó adatainak betöltése...",
|
||||||
|
retry: "Újra",
|
||||||
|
back: "Vissza a felhasználókhoz",
|
||||||
|
edit: "Felhasználó szerkesztése",
|
||||||
|
edit_user: "Felhasználó szerkesztése",
|
||||||
|
profile_tab: "Profil",
|
||||||
|
memberships_tab: "Tagságok",
|
||||||
|
account_info: "Fiók Információk",
|
||||||
|
user_id: "Felhasználó ID",
|
||||||
|
registration_date: "Regisztráció Dátuma",
|
||||||
|
last_login: "Utolsó Belépés",
|
||||||
|
ui_mode: "UI Mód",
|
||||||
|
region: "Régió",
|
||||||
|
currency: "Pénznem",
|
||||||
|
subscription_info: "Előfizetés Információk",
|
||||||
|
subscription_expires: "Lejárat Dátuma",
|
||||||
|
scope_level: "Hatáskör Szint",
|
||||||
|
scope_id: "Hatáskör ID",
|
||||||
|
max_vehicles: "Max Járművek",
|
||||||
|
max_garages: "Max Garázsok",
|
||||||
|
personal_info: "Személyes Információk",
|
||||||
|
last_name: "Vezetéknév",
|
||||||
|
first_name: "Keresztnév",
|
||||||
|
phone: "Telefonszám",
|
||||||
|
birth_place: "Születési Hely",
|
||||||
|
birth_date: "Születési Dátum",
|
||||||
|
mothers_name: "Anyja Vezetékneve",
|
||||||
|
mothers_first_name: "Anyja Keresztneve",
|
||||||
|
address: "Cím",
|
||||||
|
zip: "Irányítószám",
|
||||||
|
city: "Város",
|
||||||
|
street: "Utca",
|
||||||
|
house_number: "Házszám",
|
||||||
|
no_person_record: "Nem található személyes adat ehhez a felhasználóhoz.",
|
||||||
|
memberships: "Szervezeti Tagságok",
|
||||||
|
memberships_count: "{count} tagság",
|
||||||
|
no_memberships: "Ez a felhasználó nem tagja egyetlen szervezetnek sem.",
|
||||||
|
org_name: "Szervezet",
|
||||||
|
org_role: "Szerepkör",
|
||||||
|
member_status: "Státusz",
|
||||||
|
joined_at: "Csatlakozott",
|
||||||
|
verified: "Ellenőrzött",
|
||||||
|
verified_yes: "Igen",
|
||||||
|
verified_no: "Nem",
|
||||||
|
preferred_language: "Előnyben Részesített Nyelv",
|
||||||
|
is_active: "Aktív",
|
||||||
|
is_vip: "VIP",
|
||||||
|
cancel: "Mégse",
|
||||||
|
save: "Változtatások Mentése",
|
||||||
|
saving: "Mentés..."
|
||||||
|
}
|
||||||
|
};
|
||||||
const locale_hu_46json_ee06c965 = {
|
const locale_hu_46json_ee06c965 = {
|
||||||
permissions: permissions$1,
|
permissions: permissions$1,
|
||||||
packages: packages$1,
|
packages: packages$1,
|
||||||
garages: garages$1
|
garages: garages$1,
|
||||||
|
users: users$1
|
||||||
};
|
};
|
||||||
|
|
||||||
var permissions = {
|
var permissions = {
|
||||||
@@ -2610,6 +2722,7 @@ var packages = {
|
|||||||
allowances: "Allowances",
|
allowances: "Allowances",
|
||||||
max_vehicles: "Max Vehicles",
|
max_vehicles: "Max Vehicles",
|
||||||
max_garages: "Max Garages",
|
max_garages: "Max Garages",
|
||||||
|
max_users: "Max Users",
|
||||||
monthly_credits: "Monthly Credits",
|
monthly_credits: "Monthly Credits",
|
||||||
tier_level: "Level",
|
tier_level: "Level",
|
||||||
form_name: "System Name",
|
form_name: "System Name",
|
||||||
@@ -2688,6 +2801,16 @@ var garages = {
|
|||||||
subscription_updated: "subscription updated successfully!",
|
subscription_updated: "subscription updated successfully!",
|
||||||
view_details: "Details",
|
view_details: "Details",
|
||||||
indefinite: "Indefinite",
|
indefinite: "Indefinite",
|
||||||
|
addons_title: "Add-ons",
|
||||||
|
addon_vehicles: "Extra Vehicle",
|
||||||
|
addon_branches: "Extra Branch",
|
||||||
|
addon_users: "Extra Employee",
|
||||||
|
active_addons: "Active Add-ons",
|
||||||
|
no_active_addons: "No active add-ons",
|
||||||
|
addon_vehicles_expiration: "Extra Vehicle Expiration",
|
||||||
|
addon_branches_expiration: "Extra Branch Expiration",
|
||||||
|
addon_users_expiration: "Extra Employee Expiration",
|
||||||
|
addon_expiration_hint: "If empty, existing expiration dates remain.",
|
||||||
details: {
|
details: {
|
||||||
loading: "Loading garage details...",
|
loading: "Loading garage details...",
|
||||||
retry: "Retry",
|
retry: "Retry",
|
||||||
@@ -2761,6 +2884,8 @@ var garages = {
|
|||||||
vehicles_used: "Vehicles",
|
vehicles_used: "Vehicles",
|
||||||
branches_used: "Branches",
|
branches_used: "Branches",
|
||||||
employees_used: "Employees",
|
employees_used: "Employees",
|
||||||
|
branches: "branches",
|
||||||
|
employees: "employees",
|
||||||
status: "Status",
|
status: "Status",
|
||||||
subscription_history: "Subscription History",
|
subscription_history: "Subscription History",
|
||||||
history_date: "Date",
|
history_date: "Date",
|
||||||
@@ -2844,10 +2969,109 @@ var garages = {
|
|||||||
no_fleet_access: "You don't have permission to view fleet data."
|
no_fleet_access: "You don't have permission to view fleet data."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
var users = {
|
||||||
|
title: "User Management",
|
||||||
|
subtitle: "User list, search, filters and bulk actions",
|
||||||
|
loading: "Loading users...",
|
||||||
|
retry: "Retry",
|
||||||
|
search_placeholder: "Search by email, name or phone...",
|
||||||
|
all_statuses: "All statuses",
|
||||||
|
status_active: "Active",
|
||||||
|
status_inactive: "Inactive",
|
||||||
|
status_deleted: "Deleted",
|
||||||
|
status_banned: "Banned",
|
||||||
|
all_roles: "All roles",
|
||||||
|
role_user: "User",
|
||||||
|
role_admin: "Admin",
|
||||||
|
role_staff: "Staff",
|
||||||
|
role_superadmin: "Superadmin",
|
||||||
|
all_plans: "All plans",
|
||||||
|
plan_free: "Free",
|
||||||
|
plan_premium: "Premium",
|
||||||
|
plan_enterprise: "Enterprise",
|
||||||
|
total_users: "Total Users",
|
||||||
|
active_users: "Active Users",
|
||||||
|
deleted_users: "Deleted Users",
|
||||||
|
banned_users: "Banned Users",
|
||||||
|
new_today: "New Today",
|
||||||
|
filtered_results: "Filtered results",
|
||||||
|
selected_count: "{count} user(s) selected",
|
||||||
|
bulk_activate: "Activate",
|
||||||
|
bulk_deactivate: "Deactivate",
|
||||||
|
clear_selection: "Clear",
|
||||||
|
email: "Email",
|
||||||
|
name: "Name",
|
||||||
|
role: "Role",
|
||||||
|
status: "Status",
|
||||||
|
"package": "Package",
|
||||||
|
registration: "Registration",
|
||||||
|
language: "Language",
|
||||||
|
actions: "Actions",
|
||||||
|
view: "View",
|
||||||
|
activate: "Activate",
|
||||||
|
deactivate: "Deactivate",
|
||||||
|
no_results: "No users match your search.",
|
||||||
|
showing: "Showing",
|
||||||
|
prev: "Previous",
|
||||||
|
next: "Next",
|
||||||
|
details: {
|
||||||
|
loading: "Loading user details...",
|
||||||
|
retry: "Retry",
|
||||||
|
back: "Back to Users",
|
||||||
|
edit: "Edit User",
|
||||||
|
edit_user: "Edit User",
|
||||||
|
profile_tab: "Profile",
|
||||||
|
memberships_tab: "Memberships",
|
||||||
|
account_info: "Account Information",
|
||||||
|
user_id: "User ID",
|
||||||
|
registration_date: "Registration Date",
|
||||||
|
last_login: "Last Login",
|
||||||
|
ui_mode: "UI Mode",
|
||||||
|
region: "Region",
|
||||||
|
currency: "Currency",
|
||||||
|
subscription_info: "Subscription Information",
|
||||||
|
subscription_expires: "Expires At",
|
||||||
|
scope_level: "Scope Level",
|
||||||
|
scope_id: "Scope ID",
|
||||||
|
max_vehicles: "Max Vehicles",
|
||||||
|
max_garages: "Max Garages",
|
||||||
|
personal_info: "Personal Information",
|
||||||
|
last_name: "Last Name",
|
||||||
|
first_name: "First Name",
|
||||||
|
phone: "Phone",
|
||||||
|
birth_place: "Birth Place",
|
||||||
|
birth_date: "Birth Date",
|
||||||
|
mothers_name: "Mother's Name",
|
||||||
|
mothers_first_name: "Mother's First Name",
|
||||||
|
address: "Address",
|
||||||
|
zip: "ZIP Code",
|
||||||
|
city: "City",
|
||||||
|
street: "Street",
|
||||||
|
house_number: "House Number",
|
||||||
|
no_person_record: "No personal information record found for this user.",
|
||||||
|
memberships: "Organization Memberships",
|
||||||
|
memberships_count: "{count} membership(s)",
|
||||||
|
no_memberships: "This user is not a member of any organization.",
|
||||||
|
org_name: "Organization",
|
||||||
|
org_role: "Role",
|
||||||
|
member_status: "Status",
|
||||||
|
joined_at: "Joined",
|
||||||
|
verified: "Verified",
|
||||||
|
verified_yes: "Yes",
|
||||||
|
verified_no: "No",
|
||||||
|
preferred_language: "Preferred Language",
|
||||||
|
is_active: "Active",
|
||||||
|
is_vip: "VIP",
|
||||||
|
cancel: "Cancel",
|
||||||
|
save: "Save Changes",
|
||||||
|
saving: "Saving..."
|
||||||
|
}
|
||||||
|
};
|
||||||
const locale_en_46json_0b63539c = {
|
const locale_en_46json_0b63539c = {
|
||||||
permissions: permissions,
|
permissions: permissions,
|
||||||
packages: packages,
|
packages: packages,
|
||||||
garages: garages
|
garages: garages,
|
||||||
|
users: users
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
@@ -3496,16 +3720,16 @@ _wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
|
|||||||
const assets = {
|
const assets = {
|
||||||
"/index.mjs": {
|
"/index.mjs": {
|
||||||
"type": "text/javascript; charset=utf-8",
|
"type": "text/javascript; charset=utf-8",
|
||||||
"etag": "\"253ec-9gWita9FIFOcxrWJJImCHqiAiEc\"",
|
"etag": "\"26f47-FxUsDl+Qw3SM1eutSdt3YslLdnY\"",
|
||||||
"mtime": "2026-06-26T13:42:06.440Z",
|
"mtime": "2026-06-29T13:04:36.052Z",
|
||||||
"size": 152556,
|
"size": 159559,
|
||||||
"path": "index.mjs"
|
"path": "index.mjs"
|
||||||
},
|
},
|
||||||
"/index.mjs.map": {
|
"/index.mjs.map": {
|
||||||
"type": "application/json",
|
"type": "application/json",
|
||||||
"etag": "\"83251-gdWWzINLTOdOWQypEY8kD82/CME\"",
|
"etag": "\"83331-ZjNHkLjUBSxWFNgfXByOQYuJexY\"",
|
||||||
"mtime": "2026-06-26T13:42:06.440Z",
|
"mtime": "2026-06-29T13:04:36.052Z",
|
||||||
"size": 537169,
|
"size": 537393,
|
||||||
"path": "index.mjs.map"
|
"path": "index.mjs.map"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/BL04W_c6.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BL04W_c6.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{K as a,B as i,C as o,h as l,L as c}from"./CGTxeHv7.js";const h=a(async(s,f)=>{let t,r;if(s.path==="/login")return;if(!i("access_token").value)return o("/login");const e=l();if(!e.user)try{[t,r]=c(()=>e.fetchUser()),await t,r()}catch{return e.logout(),o("/login")}if(!e.user)return e.logout(),o("/login");const n=["SUPERADMIN","ADMIN","MODERATOR","SALES_REP","SERVICE_MGR"],u=(e.user.role||"").toUpperCase();if(!n.includes(u))return e.logout(),o("/login")});export{h as default};
|
import{N as a,h as i,G as o,s as l,O as c}from"./Dc9IRd06.js";const h=a(async(s,f)=>{let t,r;if(s.path==="/login")return;if(!i("access_token").value)return o("/login");const e=l();if(!e.user)try{[t,r]=c(()=>e.fetchUser()),await t,r()}catch{return e.logout(),o("/login")}if(!e.user)return e.logout(),o("/login");const n=["SUPERADMIN","ADMIN","MODERATOR","SALES_REP","SERVICE_MGR"],u=(e.user.role||"").toUpperCase();if(!n.includes(u))return e.logout(),o("/login")});export{h as default};
|
||||||
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/BRSeVrJ6.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BRSeVrJ6.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/BZYi31Gu.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BZYi31Gu.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{S as _,l as u,m as a}from"./Dc9IRd06.js";const h=_("region",()=>{const t=u([]),n=u(null),c=u(!1),l=u(null),o=a(()=>t.value.find(e=>e.country_code==="DEFAULT")||null),s=a(()=>{const e=new Map;for(const r of t.value)e.set(r.country_code,r);return e}),i=a(()=>n.value?.currency||o.value?.currency||"EUR"),v=a(()=>n.value?.locale_code||o.value?.locale_code||"en-EU"),f=a(()=>n.value?.default_vat_rate??o.value?.default_vat_rate??0),d=a(()=>n.value?.timezone||o.value?.timezone||"Europe/Brussels");async function g(){if(!(t.value.length>0)){c.value=!0,l.value=null;try{const e=await $fetch("/api/v1/system/regions");t.value=e||[]}catch(e){l.value=e?.data?.detail||e?.message||"Failed to load regions",console.error("[RegionStore] Fetch error:",e)}finally{c.value=!1}}}function m(e){const r=t.value.find(R=>R.country_code===e);n.value=r||o.value}function y(e){return s.value.get(e)}return{regions:t,activeRegion:n,isLoading:c,error:l,defaultRegion:o,regionMap:s,activeCurrency:i,activeLocale:v,activeVatRate:f,activeTimezone:d,fetchRegions:g,setActiveRegion:m,getRegion:y}});export{h as u};
|
||||||
1
frontend_admin/.nuxt/dist/client/_nuxt/BkOfz63t.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BkOfz63t.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/BkQDb8NA.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BkQDb8NA.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/BopcD-t8.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BopcD-t8.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/C94yYWrE.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/C94yYWrE.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{_ as s}from"./DlAUqK2U.js";import{u as a,o as i,c as u,a as e,t as o}from"./CGTxeHv7.js";const l={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},c={class:"max-w-520px text-center"},d=["textContent"],p=["textContent"],f={__name:"error-500",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:500},statusText:{type:String,default:"Server error"},description:{type:String,default:"This page is temporarily unavailable."}},setup(t){const r=t;return a({title:`${r.status} - ${r.statusText} | ${r.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(g,n)=>(i(),u("div",l,[n[0]||(n[0]=e("div",{class:"-bottom-1/2 fixed h-1/2 left-0 right-0 spotlight"},null,-1)),e("div",c,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:o(t.status)},null,8,d),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:o(t.description)},null,8,p)])]))}},h=s(f,[["__scopeId","data-v-a01dd0ba"]]);export{h as default};
|
import{_ as s}from"./DlAUqK2U.js";import{u as a,o as i,c as u,a as e,t as o}from"./Dc9IRd06.js";const l={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},c={class:"max-w-520px text-center"},d=["textContent"],p=["textContent"],f={__name:"error-500",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:500},statusText:{type:String,default:"Server error"},description:{type:String,default:"This page is temporarily unavailable."}},setup(t){const r=t;return a({title:`${r.status} - ${r.statusText} | ${r.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(g,n)=>(i(),u("div",l,[n[0]||(n[0]=e("div",{class:"-bottom-1/2 fixed h-1/2 left-0 right-0 spotlight"},null,-1)),e("div",c,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:o(t.status)},null,8,d),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:o(t.description)},null,8,p)])]))}},h=s(f,[["__scopeId","data-v-a01dd0ba"]]);export{h as default};
|
||||||
1
frontend_admin/.nuxt/dist/client/_nuxt/CYfDGx4h.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/CYfDGx4h.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{_ as h}from"./BPZZgRgP.js";import{p as v,e as y,g as _,h as k,c as r,a as t,f as S,i as s,t as c,j as u,k as V,l as p,v as m,m as x,b as L,w as C,q as f,o as i,d as N}from"./CGTxeHv7.js";const B=v("/sf_logo.png"),E={class:"min-h-screen bg-slate-900 flex items-center justify-center px-4"},R={class:"w-full max-w-md"},j={class:"bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8"},A={key:0,class:"mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm"},q=["disabled"],D={key:0,class:"animate-spin h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},F={class:"mt-6 text-center"},P=y({__name:"login",setup(I){const a=f(""),l=f(""),b=_(),o=k();async function g(){try{await o.login(a.value,l.value),b.push("/")}catch(d){console.error("Login failed:",d)}}return(d,e)=>{const w=h;return i(),r("div",E,[t("div",R,[e[6]||(e[6]=S('<div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img src="'+B+'" class="h-12 w-auto drop-shadow-md" alt="ServiceFinder Logo"><span class="text-3xl font-extrabold tracking-wider"><span class="text-white">SERVICE</span><span class="text-cyan-400">FINDER</span></span></div><h1 class="text-xl font-semibold text-slate-300">Staff Portal</h1><p class="text-sm text-slate-500 mt-1">Authorised personnel only</p></div>',1)),t("div",j,[s(o).error?(i(),r("div",A,c(s(o).error),1)):u("",!0),t("form",{onSubmit:V(g,["prevent"]),class:"space-y-5"},[t("div",null,[e[2]||(e[2]=t("label",{for:"email",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Email Address ",-1)),p(t("input",{id:"email","onUpdate:modelValue":e[0]||(e[0]=n=>x(a)?a.value=n:null),type:"email",autocomplete:"email",required:"",placeholder:"admin@example.com",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(a)]])]),t("div",null,[e[3]||(e[3]=t("label",{for:"password",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Password ",-1)),p(t("input",{id:"password","onUpdate:modelValue":e[1]||(e[1]=n=>x(l)?l.value=n:null),type:"password",autocomplete:"current-password",required:"",placeholder:"••••••••",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(l)]])]),t("button",{type:"submit",disabled:s(o).isLoading,class:"w-full py-2.5 px-4 bg-cyan-600 hover:bg-cyan-500 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition duration-200 flex items-center justify-center gap-2"},[s(o).isLoading?(i(),r("svg",D,[...e[4]||(e[4]=[t("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),t("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"},null,-1)])])):u("",!0),t("span",null,c(s(o).isLoading?"Signing in...":"Sign In"),1)],8,q)],32),t("div",F,[L(w,{to:"/",class:"text-sm text-slate-500 hover:text-cyan-400 transition"},{default:C(()=>[...e[5]||(e[5]=[N(" ← Back to ServiceFinder ",-1)])]),_:1})])])])])}}});export{P as default};
|
import{_ as y}from"./C94yYWrE.js";import{p as v,e as h,q as _,s as k,c as r,a as t,v as S,i as s,t as c,j as u,x as V,y as p,z as m,A as x,b as L,w as C,l as f,o as i,d as N}from"./Dc9IRd06.js";const A=v("/sf_logo.png"),B={class:"min-h-screen bg-slate-900 flex items-center justify-center px-4"},E={class:"w-full max-w-md"},R={class:"bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8"},j={key:0,class:"mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm"},q=["disabled"],D={key:0,class:"animate-spin h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},F={class:"mt-6 text-center"},z=h({__name:"login",setup(I){const a=f(""),l=f(""),b=_(),o=k();async function g(){try{await o.login(a.value,l.value),b.push("/")}catch(d){console.error("Login failed:",d)}}return(d,e)=>{const w=y;return i(),r("div",B,[t("div",E,[e[6]||(e[6]=S('<div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img src="'+A+'" class="h-12 w-auto drop-shadow-md" alt="ServiceFinder Logo"><span class="text-3xl font-extrabold tracking-wider"><span class="text-white">SERVICE</span><span class="text-cyan-400">FINDER</span></span></div><h1 class="text-xl font-semibold text-slate-300">Staff Portal</h1><p class="text-sm text-slate-500 mt-1">Authorised personnel only</p></div>',1)),t("div",R,[s(o).error?(i(),r("div",j,c(s(o).error),1)):u("",!0),t("form",{onSubmit:V(g,["prevent"]),class:"space-y-5"},[t("div",null,[e[2]||(e[2]=t("label",{for:"email",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Email Address ",-1)),p(t("input",{id:"email","onUpdate:modelValue":e[0]||(e[0]=n=>x(a)?a.value=n:null),type:"email",autocomplete:"email",required:"",placeholder:"admin@example.com",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(a)]])]),t("div",null,[e[3]||(e[3]=t("label",{for:"password",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Password ",-1)),p(t("input",{id:"password","onUpdate:modelValue":e[1]||(e[1]=n=>x(l)?l.value=n:null),type:"password",autocomplete:"current-password",required:"",placeholder:"••••••••",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(l)]])]),t("button",{type:"submit",disabled:s(o).isLoading,class:"w-full py-2.5 px-4 bg-cyan-600 hover:bg-cyan-500 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition duration-200 flex items-center justify-center gap-2"},[s(o).isLoading?(i(),r("svg",D,[...e[4]||(e[4]=[t("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),t("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"},null,-1)])])):u("",!0),t("span",null,c(s(o).isLoading?"Signing in...":"Sign In"),1)],8,q)],32),t("div",F,[L(w,{to:"/",class:"text-sm text-slate-500 hover:text-cyan-400 transition"},{default:C(()=>[...e[5]||(e[5]=[N(" ← Back to ServiceFinder ",-1)])]),_:1})])])])])}}});export{z as default};
|
||||||
1
frontend_admin/.nuxt/dist/client/_nuxt/CmLwdxkM.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/CmLwdxkM.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{_ as s}from"./DlAUqK2U.js";import{o,c as t,P as r}from"./Dc9IRd06.js";const c={},n={class:"bg-slate-900 min-h-screen"};function a(e,l){return o(),t("div",n,[r(e.$slots,"default")])}const d=s(c,[["render",a]]);export{d as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{_ as a}from"./BPZZgRgP.js";import{_ as i}from"./DlAUqK2U.js";import{u,o as c,c as l,a as e,t as r,b as d,w as p,d as f}from"./CGTxeHv7.js";const m={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},g={class:"max-w-520px text-center z-20"},b=["textContent"],h=["textContent"],x={class:"flex items-center justify-center w-full"},y={__name:"error-404",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:404},statusText:{type:String,default:"Not Found"},description:{type:String,default:"Sorry, the page you are looking for could not be found."},backHome:{type:String,default:"Go back home"}},setup(t){const n=t;return u({title:`${n.status} - ${n.statusText} | ${n.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(k,o)=>{const s=a;return c(),l("div",m,[o[0]||(o[0]=e("div",{class:"fixed left-0 right-0 spotlight z-10"},null,-1)),e("div",g,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:r(t.status)},null,8,b),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:r(t.description)},null,8,h),e("div",x,[d(s,{to:"/",class:"cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"},{default:p(()=>[f(r(t.backHome),1)]),_:1})])])])}}},z=i(y,[["__scopeId","data-v-1bd9e11a"]]);export{z as default};
|
import{_ as a}from"./C94yYWrE.js";import{_ as i}from"./DlAUqK2U.js";import{u,o as c,c as l,a as e,t as r,b as d,w as p,d as f}from"./Dc9IRd06.js";const m={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},g={class:"max-w-520px text-center z-20"},b=["textContent"],h=["textContent"],x={class:"flex items-center justify-center w-full"},y={__name:"error-404",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:404},statusText:{type:String,default:"Not Found"},description:{type:String,default:"Sorry, the page you are looking for could not be found."},backHome:{type:String,default:"Go back home"}},setup(t){const n=t;return u({title:`${n.status} - ${n.statusText} | ${n.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(k,o)=>{const s=a;return c(),l("div",m,[o[0]||(o[0]=e("div",{class:"fixed left-0 right-0 spotlight z-10"},null,-1)),e("div",g,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:r(t.status)},null,8,b),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:r(t.description)},null,8,h),e("div",x,[d(s,{to:"/",class:"cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"},{default:p(()=>[f(r(t.backHome),1)]),_:1})])])])}}},z=i(y,[["__scopeId","data-v-1bd9e11a"]]);export{z as default};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{_ as s}from"./DlAUqK2U.js";import{o,c as t,M as r}from"./CGTxeHv7.js";const c={},n={class:"bg-slate-900 min-h-screen"};function a(e,l){return o(),t("div",n,[r(e.$slots,"default")])}const d=s(c,[["render",a]]);export{d as default};
|
|
||||||
File diff suppressed because one or more lines are too long
6
frontend_admin/.nuxt/dist/client/_nuxt/Dc9IRd06.js
vendored
Normal file
6
frontend_admin/.nuxt/dist/client/_nuxt/Dc9IRd06.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/DmyQTZVv.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/DmyQTZVv.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
5
frontend_admin/.nuxt/dist/client/_nuxt/b-bIta7J.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/client/_nuxt/b-bIta7J.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/bPZppHdV.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/bPZppHdV.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/iRX-7sV5.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/iRX-7sV5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{a7 as _,q as u,z as a}from"./CGTxeHv7.js";const h=_("region",()=>{const t=u([]),n=u(null),c=u(!1),l=u(null),o=a(()=>t.value.find(e=>e.country_code==="DEFAULT")||null),s=a(()=>{const e=new Map;for(const r of t.value)e.set(r.country_code,r);return e}),i=a(()=>n.value?.currency||o.value?.currency||"EUR"),v=a(()=>n.value?.locale_code||o.value?.locale_code||"en-EU"),f=a(()=>n.value?.default_vat_rate??o.value?.default_vat_rate??0),d=a(()=>n.value?.timezone||o.value?.timezone||"Europe/Brussels");async function g(){if(!(t.value.length>0)){c.value=!0,l.value=null;try{const e=await $fetch("/api/v1/system/regions");t.value=e||[]}catch(e){l.value=e?.data?.detail||e?.message||"Failed to load regions",console.error("[RegionStore] Fetch error:",e)}finally{c.value=!1}}}function y(e){const r=t.value.find(R=>R.country_code===e);n.value=r||o.value}function m(e){return s.value.get(e)}return{regions:t,activeRegion:n,isLoading:c,error:l,defaultRegion:o,regionMap:s,activeCurrency:i,activeLocale:v,activeVatRate:f,activeTimezone:d,fetchRegions:g,setActiveRegion:y,getRegion:m}});export{h as u};
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
import { executeAsync } from "/app/node_modules/unctx/dist/index.mjs";
|
import { executeAsync } from "/app/node_modules/unctx/dist/index.mjs";
|
||||||
import { e as defineNuxtRouteMiddleware, c as useCookie, n as navigateTo, b as useAuthStore } from "../server.mjs";
|
import { e as defineNuxtRouteMiddleware, d as useCookie, n as navigateTo, c as useAuthStore } from "../server.mjs";
|
||||||
import "vue";
|
import "vue";
|
||||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||||
import "#internal/nuxt/paths";
|
import "#internal/nuxt/paths";
|
||||||
@@ -50,4 +50,4 @@ const auth = defineNuxtRouteMiddleware(async (to, from) => {
|
|||||||
export {
|
export {
|
||||||
auth as default
|
auth as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=auth-B3jrOAhu.js.map
|
//# sourceMappingURL=auth-Cq1Vc_k9.js.map
|
||||||
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"auth-B3jrOAhu.js","sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"names":["__executeAsync"],"mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;"}
|
{"version":3,"file":"auth-Cq1Vc_k9.js","sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"names":["__executeAsync"],"mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;"}
|
||||||
@@ -1 +1 @@
|
|||||||
{"file":"auth-B3jrOAhu.js","mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;","names":["__executeAsync"],"sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"version":3}
|
{"file":"auth-Cq1Vc_k9.js","mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;","names":["__executeAsync"],"sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"version":3}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { _ as __nuxt_component_0 } from "./nuxt-link-C5PnX__I.js";
|
import { _ as __nuxt_component_0 } from "./nuxt-link-Ci8vU-Yt.js";
|
||||||
import { defineComponent, computed, ref, mergeProps, withCtx, createVNode, toDisplayString, unref, openBlock, createBlock, createTextVNode, useSSRContext } from "vue";
|
import { defineComponent, computed, ref, mergeProps, withCtx, createVNode, toDisplayString, unref, openBlock, createBlock, createTextVNode, useSSRContext } from "vue";
|
||||||
import { ssrRenderAttrs, ssrRenderClass, ssrRenderComponent, ssrRenderAttr, ssrRenderList, ssrRenderStyle, ssrInterpolate, ssrRenderSlot } from "vue/server-renderer";
|
import { ssrRenderAttrs, ssrRenderClass, ssrRenderComponent, ssrRenderAttr, ssrRenderList, ssrRenderStyle, ssrInterpolate, ssrRenderSlot } from "vue/server-renderer";
|
||||||
import { publicAssetsURL } from "#internal/nuxt/paths";
|
import { publicAssetsURL } from "#internal/nuxt/paths";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { u as useRegionStore } from "./region-0mvXqaMM.js";
|
import { u as useRegionStore } from "./region-0mvXqaMM.js";
|
||||||
import { a as useRouter, b as useAuthStore, d as useI18n } from "../server.mjs";
|
import { b as useRouter, c as useAuthStore, a as useI18n } from "../server.mjs";
|
||||||
import "/app/node_modules/klona/dist/index.mjs";
|
import "/app/node_modules/klona/dist/index.mjs";
|
||||||
import "/app/node_modules/hookable/dist/index.mjs";
|
import "/app/node_modules/hookable/dist/index.mjs";
|
||||||
import "/app/node_modules/ufo/dist/index.mjs";
|
import "/app/node_modules/ufo/dist/index.mjs";
|
||||||
@@ -71,10 +71,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
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 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></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 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/users",
|
||||||
/* P0 HOTFIX: placeholder until /users page is built */
|
|
||||||
label: "Felhasználók listája",
|
label: "Felhasználók listája",
|
||||||
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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/persons",
|
||||||
|
label: "Személyek",
|
||||||
|
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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -117,6 +121,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
if (route.path === "/") return "Dashboard";
|
if (route.path === "/") return "Dashboard";
|
||||||
if (route.path.startsWith("/permissions")) return "Jogosultság Kezelés";
|
if (route.path.startsWith("/permissions")) return "Jogosultság Kezelés";
|
||||||
if (route.path.startsWith("/garages")) return "Garázsok";
|
if (route.path.startsWith("/garages")) return "Garázsok";
|
||||||
|
if (route.path.startsWith("/persons")) return "Személyek";
|
||||||
if (route.path.startsWith("/users")) return "Felhasználók";
|
if (route.path.startsWith("/users")) return "Felhasználók";
|
||||||
if (route.path.startsWith("/logs")) return "Rendszernaplók";
|
if (route.path.startsWith("/logs")) return "Rendszernaplók";
|
||||||
if (route.path.startsWith("/packages")) return "Csomagok";
|
if (route.path.startsWith("/packages")) return "Csomagok";
|
||||||
@@ -287,4 +292,4 @@ _sfc_main.setup = (props, ctx) => {
|
|||||||
export {
|
export {
|
||||||
_sfc_main as default
|
_sfc_main as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=default-C9mWt9Pc.js.map
|
//# sourceMappingURL=default-C8swInD9.js.map
|
||||||
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/default-C8swInD9.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/default-C8swInD9.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -58,6 +58,7 @@ const resource = {
|
|||||||
"allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Allowances" } },
|
"allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Allowances" } },
|
||||||
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Vehicles" } },
|
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Vehicles" } },
|
||||||
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garages" } },
|
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garages" } },
|
||||||
|
"max_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Users" } },
|
||||||
"monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Monthly Credits" } },
|
"monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Monthly Credits" } },
|
||||||
"tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Level" } },
|
"tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Level" } },
|
||||||
"form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "System Name" } },
|
"form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "System Name" } },
|
||||||
@@ -136,6 +137,16 @@ const resource = {
|
|||||||
"subscription_updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "subscription updated successfully!" } },
|
"subscription_updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "subscription updated successfully!" } },
|
||||||
"view_details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Details" } },
|
"view_details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Details" } },
|
||||||
"indefinite": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Indefinite" } },
|
"indefinite": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Indefinite" } },
|
||||||
|
"addons_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Add-ons" } },
|
||||||
|
"addon_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Vehicle" } },
|
||||||
|
"addon_branches": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Branch" } },
|
||||||
|
"addon_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Employee" } },
|
||||||
|
"active_addons": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active Add-ons" } },
|
||||||
|
"no_active_addons": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No active add-ons" } },
|
||||||
|
"addon_vehicles_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Vehicle Expiration" } },
|
||||||
|
"addon_branches_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Branch Expiration" } },
|
||||||
|
"addon_users_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Employee Expiration" } },
|
||||||
|
"addon_expiration_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "If empty, existing expiration dates remain." } },
|
||||||
"details": {
|
"details": {
|
||||||
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading garage details..." } },
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading garage details..." } },
|
||||||
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } },
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } },
|
||||||
@@ -210,6 +221,8 @@ const resource = {
|
|||||||
"vehicles_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vehicles" } },
|
"vehicles_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vehicles" } },
|
||||||
"branches_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Branches" } },
|
"branches_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Branches" } },
|
||||||
"employees_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Employees" } },
|
"employees_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Employees" } },
|
||||||
|
"branches": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "branches" } },
|
||||||
|
"employees": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "employees" } },
|
||||||
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } },
|
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } },
|
||||||
"subscription_history": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Subscription History" } },
|
"subscription_history": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Subscription History" } },
|
||||||
"history_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Date" } },
|
"history_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Date" } },
|
||||||
@@ -292,9 +305,107 @@ const resource = {
|
|||||||
"no_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No vehicles assigned to this garage yet." } },
|
"no_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No vehicles assigned to this garage yet." } },
|
||||||
"no_fleet_access": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "You don't have permission to view fleet data." } }
|
"no_fleet_access": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "You don't have permission to view fleet data." } }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "User Management" } },
|
||||||
|
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "User list, search, filters and bulk actions" } },
|
||||||
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading users..." } },
|
||||||
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } },
|
||||||
|
"search_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Search by email, name or phone..." } },
|
||||||
|
"all_statuses": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "All statuses" } },
|
||||||
|
"status_active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active" } },
|
||||||
|
"status_inactive": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Inactive" } },
|
||||||
|
"status_deleted": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deleted" } },
|
||||||
|
"status_banned": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Banned" } },
|
||||||
|
"all_roles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "All roles" } },
|
||||||
|
"role_user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "User" } },
|
||||||
|
"role_admin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Admin" } },
|
||||||
|
"role_staff": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Staff" } },
|
||||||
|
"role_superadmin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Superadmin" } },
|
||||||
|
"all_plans": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "All plans" } },
|
||||||
|
"plan_free": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Free" } },
|
||||||
|
"plan_premium": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Premium" } },
|
||||||
|
"plan_enterprise": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Enterprise" } },
|
||||||
|
"total_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Total Users" } },
|
||||||
|
"active_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active Users" } },
|
||||||
|
"deleted_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deleted Users" } },
|
||||||
|
"banned_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Banned Users" } },
|
||||||
|
"new_today": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "New Today" } },
|
||||||
|
"filtered_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Filtered results" } },
|
||||||
|
"selected_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " user(s) selected" }] } },
|
||||||
|
"bulk_activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Activate" } },
|
||||||
|
"bulk_deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deactivate" } },
|
||||||
|
"clear_selection": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Clear" } },
|
||||||
|
"email": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Email" } },
|
||||||
|
"name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Name" } },
|
||||||
|
"role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Role" } },
|
||||||
|
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } },
|
||||||
|
"package": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Package" } },
|
||||||
|
"registration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Registration" } },
|
||||||
|
"language": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Language" } },
|
||||||
|
"actions": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Actions" } },
|
||||||
|
"view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "View" } },
|
||||||
|
"activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Activate" } },
|
||||||
|
"deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deactivate" } },
|
||||||
|
"no_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No users match your search." } },
|
||||||
|
"showing": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Showing" } },
|
||||||
|
"prev": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Previous" } },
|
||||||
|
"next": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Next" } },
|
||||||
|
"details": {
|
||||||
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading user details..." } },
|
||||||
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } },
|
||||||
|
"back": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Back to Users" } },
|
||||||
|
"edit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Edit User" } },
|
||||||
|
"edit_user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Edit User" } },
|
||||||
|
"profile_tab": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Profile" } },
|
||||||
|
"memberships_tab": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Memberships" } },
|
||||||
|
"account_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Account Information" } },
|
||||||
|
"user_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "User ID" } },
|
||||||
|
"registration_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Registration Date" } },
|
||||||
|
"last_login": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Last Login" } },
|
||||||
|
"ui_mode": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "UI Mode" } },
|
||||||
|
"region": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Region" } },
|
||||||
|
"currency": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Currency" } },
|
||||||
|
"subscription_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Subscription Information" } },
|
||||||
|
"subscription_expires": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Expires At" } },
|
||||||
|
"scope_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Scope Level" } },
|
||||||
|
"scope_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Scope ID" } },
|
||||||
|
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Vehicles" } },
|
||||||
|
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garages" } },
|
||||||
|
"personal_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Personal Information" } },
|
||||||
|
"last_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Last Name" } },
|
||||||
|
"first_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "First Name" } },
|
||||||
|
"phone": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Phone" } },
|
||||||
|
"birth_place": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Birth Place" } },
|
||||||
|
"birth_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Birth Date" } },
|
||||||
|
"mothers_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mother's Name" } },
|
||||||
|
"mothers_first_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mother's First Name" } },
|
||||||
|
"address": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Address" } },
|
||||||
|
"zip": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "ZIP Code" } },
|
||||||
|
"city": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "City" } },
|
||||||
|
"street": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Street" } },
|
||||||
|
"house_number": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "House Number" } },
|
||||||
|
"no_person_record": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No personal information record found for this user." } },
|
||||||
|
"memberships": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Organization Memberships" } },
|
||||||
|
"memberships_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " membership(s)" }] } },
|
||||||
|
"no_memberships": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "This user is not a member of any organization." } },
|
||||||
|
"org_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Organization" } },
|
||||||
|
"org_role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Role" } },
|
||||||
|
"member_status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } },
|
||||||
|
"joined_at": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Joined" } },
|
||||||
|
"verified": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Verified" } },
|
||||||
|
"verified_yes": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Yes" } },
|
||||||
|
"verified_no": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No" } },
|
||||||
|
"preferred_language": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Preferred Language" } },
|
||||||
|
"is_active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active" } },
|
||||||
|
"is_vip": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "VIP" } },
|
||||||
|
"cancel": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Cancel" } },
|
||||||
|
"save": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Save Changes" } },
|
||||||
|
"saving": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Saving..." } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export {
|
export {
|
||||||
resource as default
|
resource as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=en-BI6r-iRW.js.map
|
//# sourceMappingURL=en-BPBMPZpX.js.map
|
||||||
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"en-BI6r-iRW.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
{"version":3,"file":"en-BPBMPZpX.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
||||||
@@ -1 +1 @@
|
|||||||
{"file":"en-BI6r-iRW.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}
|
{"file":"en-BPBMPZpX.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"entry-styles-1.mjs-Be0Mk_0H.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"file":"entry-styles-1.mjs-Be0Mk_0H.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
|
||||||
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-C-JbU1OR.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-C-JbU1OR.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-C-JbU1OR.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-C-JbU1OR.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"entry-styles-1.mjs-C-JbU1OR.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-C-JbU1OR.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-C-JbU1OR.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"file":"entry-styles-1.mjs-C-JbU1OR.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"entry-styles-2.mjs-CiZbT56B.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"file":"entry-styles-2.mjs-CiZbT56B.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
|
||||||
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-e4rjr5TT.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-e4rjr5TT.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-e4rjr5TT.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-e4rjr5TT.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"entry-styles-2.mjs-e4rjr5TT.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-e4rjr5TT.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-e4rjr5TT.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"file":"entry-styles-2.mjs-e4rjr5TT.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||||
8
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles.DI3TVdQz.mjs
vendored
Normal file
8
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles.DI3TVdQz.mjs
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import style_0 from "./entry-styles-1.mjs-C-JbU1OR.js";
|
||||||
|
import style_1 from "./entry-styles-2.mjs-e4rjr5TT.js";
|
||||||
|
import style_2 from "./entry-styles-3.mjs-C1rWf53M.js";
|
||||||
|
export default [
|
||||||
|
style_0,
|
||||||
|
style_1,
|
||||||
|
style_2
|
||||||
|
]
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import style_0 from "./entry-styles-1.mjs-Be0Mk_0H.js";
|
|
||||||
import style_1 from "./entry-styles-2.mjs-CiZbT56B.js";
|
|
||||||
import style_2 from "./entry-styles-3.mjs-C1rWf53M.js";
|
|
||||||
export default [
|
|
||||||
style_0,
|
|
||||||
style_1,
|
|
||||||
style_2
|
|
||||||
]
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { _ as __nuxt_component_0 } from "./nuxt-link-C5PnX__I.js";
|
import { _ as __nuxt_component_0 } from "./nuxt-link-Ci8vU-Yt.js";
|
||||||
import { mergeProps, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue";
|
import { mergeProps, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue";
|
||||||
import { ssrRenderAttrs, ssrInterpolate, ssrRenderComponent } from "vue/server-renderer";
|
import { ssrRenderAttrs, ssrInterpolate, ssrRenderComponent } from "vue/server-renderer";
|
||||||
import { _ as _export_sfc } from "./_plugin-vue_export-helper-1tPrXgE0.js";
|
import { _ as _export_sfc } from "./_plugin-vue_export-helper-1tPrXgE0.js";
|
||||||
@@ -94,4 +94,4 @@ const error404 = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-1
|
|||||||
export {
|
export {
|
||||||
error404 as default
|
error404 as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=error-404-DPt4byml.js.map
|
//# sourceMappingURL=error-404-qU7XehyZ.js.map
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -58,6 +58,7 @@ const resource = {
|
|||||||
"allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Korlátok" } },
|
"allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Korlátok" } },
|
||||||
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Jármű" } },
|
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Jármű" } },
|
||||||
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garázs" } },
|
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garázs" } },
|
||||||
|
"max_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Dolgozó" } },
|
||||||
"monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Havi Kredit" } },
|
"monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Havi Kredit" } },
|
||||||
"tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szint" } },
|
"tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szint" } },
|
||||||
"form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Rendszer Név" } },
|
"form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Rendszer Név" } },
|
||||||
@@ -136,6 +137,16 @@ const resource = {
|
|||||||
"subscription_updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "előfizetése sikeresen frissítve!" } },
|
"subscription_updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "előfizetése sikeresen frissítve!" } },
|
||||||
"view_details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Részletek" } },
|
"view_details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Részletek" } },
|
||||||
"indefinite": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Határozatlan" } },
|
"indefinite": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Határozatlan" } },
|
||||||
|
"addons_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Kiegészítők (Add-ons)" } },
|
||||||
|
"addon_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Jármű" } },
|
||||||
|
"addon_branches": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Telephely" } },
|
||||||
|
"addon_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Dolgozó" } },
|
||||||
|
"active_addons": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív kiegészítők" } },
|
||||||
|
"no_active_addons": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nincsenek aktív kiegészítők" } },
|
||||||
|
"addon_vehicles_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Jármű lejárati dátuma" } },
|
||||||
|
"addon_branches_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Telephely lejárati dátuma" } },
|
||||||
|
"addon_users_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Dolgozó lejárati dátuma" } },
|
||||||
|
"addon_expiration_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ha üres, a meglévő lejárati dátumok megmaradnak." } },
|
||||||
"details": {
|
"details": {
|
||||||
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garázs adatainak betöltése..." } },
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garázs adatainak betöltése..." } },
|
||||||
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } },
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } },
|
||||||
@@ -210,6 +221,8 @@ const resource = {
|
|||||||
"vehicles_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Járművek" } },
|
"vehicles_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Járművek" } },
|
||||||
"branches_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Telephelyek" } },
|
"branches_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Telephelyek" } },
|
||||||
"employees_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Dolgozók" } },
|
"employees_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Dolgozók" } },
|
||||||
|
"branches": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "telephely" } },
|
||||||
|
"employees": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "dolgozó" } },
|
||||||
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Státusz" } },
|
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Státusz" } },
|
||||||
"subscription_history": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előfizetési Előzmények" } },
|
"subscription_history": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előfizetési Előzmények" } },
|
||||||
"history_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Dátum" } },
|
"history_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Dátum" } },
|
||||||
@@ -292,9 +305,107 @@ const resource = {
|
|||||||
"no_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ehhez a garázshoz még nincsenek járművek rendelve." } },
|
"no_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ehhez a garázshoz még nincsenek járművek rendelve." } },
|
||||||
"no_fleet_access": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nincs jogosultságod a flotta adatok megtekintéséhez." } }
|
"no_fleet_access": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nincs jogosultságod a flotta adatok megtekintéséhez." } }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó Kezelés" } },
|
||||||
|
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználók listája, keresés, szűrés és tömeges műveletek" } },
|
||||||
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználók betöltése..." } },
|
||||||
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } },
|
||||||
|
"search_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Keresés email, név vagy telefonszám alapján..." } },
|
||||||
|
"all_statuses": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes státusz" } },
|
||||||
|
"status_active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív" } },
|
||||||
|
"status_inactive": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Inaktív" } },
|
||||||
|
"status_deleted": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Törölt" } },
|
||||||
|
"status_banned": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Kitiltott" } },
|
||||||
|
"all_roles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes szerepkör" } },
|
||||||
|
"role_user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó" } },
|
||||||
|
"role_admin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Admin" } },
|
||||||
|
"role_staff": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Munkatárs" } },
|
||||||
|
"role_superadmin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Superadmin" } },
|
||||||
|
"all_plans": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes csomag" } },
|
||||||
|
"plan_free": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ingyenes" } },
|
||||||
|
"plan_premium": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Prémium" } },
|
||||||
|
"plan_enterprise": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vállalati" } },
|
||||||
|
"total_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes Felhasználó" } },
|
||||||
|
"active_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív Felhasználók" } },
|
||||||
|
"deleted_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Törölt Felhasználók" } },
|
||||||
|
"banned_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Kitiltott Felhasználók" } },
|
||||||
|
"new_today": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ma Regisztrált" } },
|
||||||
|
"filtered_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szűrt találat" } },
|
||||||
|
"selected_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " felhasználó kiválasztva" }] } },
|
||||||
|
"bulk_activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktiválás" } },
|
||||||
|
"bulk_deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deaktiválás" } },
|
||||||
|
"clear_selection": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Kijelölés törlése" } },
|
||||||
|
"email": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "E-mail" } },
|
||||||
|
"name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Név" } },
|
||||||
|
"role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szerepkör" } },
|
||||||
|
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Státusz" } },
|
||||||
|
"package": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csomag" } },
|
||||||
|
"registration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Regisztráció" } },
|
||||||
|
"language": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nyelv" } },
|
||||||
|
"actions": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Műveletek" } },
|
||||||
|
"view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Megtekintés" } },
|
||||||
|
"activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktiválás" } },
|
||||||
|
"deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deaktiválás" } },
|
||||||
|
"no_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nincs a keresésnek megfelelő felhasználó." } },
|
||||||
|
"showing": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mutató" } },
|
||||||
|
"prev": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előző" } },
|
||||||
|
"next": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Következő" } },
|
||||||
|
"details": {
|
||||||
|
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó adatainak betöltése..." } },
|
||||||
|
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } },
|
||||||
|
"back": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vissza a felhasználókhoz" } },
|
||||||
|
"edit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó szerkesztése" } },
|
||||||
|
"edit_user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó szerkesztése" } },
|
||||||
|
"profile_tab": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Profil" } },
|
||||||
|
"memberships_tab": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Tagságok" } },
|
||||||
|
"account_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Fiók Információk" } },
|
||||||
|
"user_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó ID" } },
|
||||||
|
"registration_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Regisztráció Dátuma" } },
|
||||||
|
"last_login": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Utolsó Belépés" } },
|
||||||
|
"ui_mode": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "UI Mód" } },
|
||||||
|
"region": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Régió" } },
|
||||||
|
"currency": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Pénznem" } },
|
||||||
|
"subscription_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előfizetés Információk" } },
|
||||||
|
"subscription_expires": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Lejárat Dátuma" } },
|
||||||
|
"scope_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Hatáskör Szint" } },
|
||||||
|
"scope_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Hatáskör ID" } },
|
||||||
|
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Járművek" } },
|
||||||
|
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garázsok" } },
|
||||||
|
"personal_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Személyes Információk" } },
|
||||||
|
"last_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vezetéknév" } },
|
||||||
|
"first_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Keresztnév" } },
|
||||||
|
"phone": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Telefonszám" } },
|
||||||
|
"birth_place": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Születési Hely" } },
|
||||||
|
"birth_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Születési Dátum" } },
|
||||||
|
"mothers_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Anyja Vezetékneve" } },
|
||||||
|
"mothers_first_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Anyja Keresztneve" } },
|
||||||
|
"address": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Cím" } },
|
||||||
|
"zip": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Irányítószám" } },
|
||||||
|
"city": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Város" } },
|
||||||
|
"street": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Utca" } },
|
||||||
|
"house_number": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Házszám" } },
|
||||||
|
"no_person_record": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nem található személyes adat ehhez a felhasználóhoz." } },
|
||||||
|
"memberships": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szervezeti Tagságok" } },
|
||||||
|
"memberships_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " tagság" }] } },
|
||||||
|
"no_memberships": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ez a felhasználó nem tagja egyetlen szervezetnek sem." } },
|
||||||
|
"org_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szervezet" } },
|
||||||
|
"org_role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szerepkör" } },
|
||||||
|
"member_status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Státusz" } },
|
||||||
|
"joined_at": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csatlakozott" } },
|
||||||
|
"verified": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ellenőrzött" } },
|
||||||
|
"verified_yes": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Igen" } },
|
||||||
|
"verified_no": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nem" } },
|
||||||
|
"preferred_language": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előnyben Részesített Nyelv" } },
|
||||||
|
"is_active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív" } },
|
||||||
|
"is_vip": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "VIP" } },
|
||||||
|
"cancel": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mégse" } },
|
||||||
|
"save": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Változtatások Mentése" } },
|
||||||
|
"saving": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mentés..." } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export {
|
export {
|
||||||
resource as default
|
resource as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=hu-Ck1VzT5S.js.map
|
//# sourceMappingURL=hu-DFYycw88.js.map
|
||||||
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"hu-Ck1VzT5S.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
{"version":3,"file":"hu-DFYycw88.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
||||||
@@ -1 +1 @@
|
|||||||
{"file":"hu-Ck1VzT5S.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}
|
{"file":"hu-DFYycw88.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@ import { defineComponent, ref, computed, unref, useSSRContext } from "vue";
|
|||||||
import { ssrRenderAttrs, ssrInterpolate, ssrRenderClass, ssrRenderList, ssrIncludeBooleanAttr, ssrRenderAttr } from "vue/server-renderer";
|
import { ssrRenderAttrs, ssrInterpolate, ssrRenderClass, ssrRenderList, ssrIncludeBooleanAttr, ssrRenderAttr } from "vue/server-renderer";
|
||||||
import "/app/node_modules/hookable/dist/index.mjs";
|
import "/app/node_modules/hookable/dist/index.mjs";
|
||||||
import "/app/node_modules/klona/dist/index.mjs";
|
import "/app/node_modules/klona/dist/index.mjs";
|
||||||
import { d as useI18n } from "../server.mjs";
|
import { a as useI18n } from "../server.mjs";
|
||||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||||
import "#internal/nuxt/paths";
|
import "#internal/nuxt/paths";
|
||||||
import "/app/node_modules/unctx/dist/index.mjs";
|
import "/app/node_modules/unctx/dist/index.mjs";
|
||||||
@@ -209,4 +209,4 @@ _sfc_main.setup = (props, ctx) => {
|
|||||||
export {
|
export {
|
||||||
_sfc_main as default
|
_sfc_main as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=index-DgwDY6Ie.js.map
|
//# sourceMappingURL=index-BD2eKDSC.js.map
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
481
frontend_admin/.nuxt/dist/server/_nuxt/index-BXFH9zj5.js
vendored
Normal file
481
frontend_admin/.nuxt/dist/server/_nuxt/index-BXFH9zj5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/index-BXFH9zj5.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-BXFH9zj5.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/index-BXFH9zj5.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-BXFH9zj5.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
323
frontend_admin/.nuxt/dist/server/_nuxt/index-Bd_e7zBi.js
vendored
Normal file
323
frontend_admin/.nuxt/dist/server/_nuxt/index-Bd_e7zBi.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/index-Bd_e7zBi.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-Bd_e7zBi.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/index-Bd_e7zBi.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-Bd_e7zBi.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1011
frontend_admin/.nuxt/dist/server/_nuxt/index-CKl-ge5n.js
vendored
Normal file
1011
frontend_admin/.nuxt/dist/server/_nuxt/index-CKl-ge5n.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/index-CKl-ge5n.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-CKl-ge5n.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/index-CKl-ge5n.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-CKl-ge5n.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
|||||||
import { _ as __nuxt_component_0 } from "./nuxt-link-C5PnX__I.js";
|
import { _ as __nuxt_component_0 } from "./nuxt-link-Ci8vU-Yt.js";
|
||||||
import { defineComponent, ref, watch, computed, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue";
|
import { defineComponent, ref, watch, computed, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue";
|
||||||
import { ssrRenderAttrs, ssrInterpolate, ssrRenderAttr, ssrIncludeBooleanAttr, ssrLooseContain, ssrLooseEqual, ssrRenderList, ssrRenderClass, ssrRenderComponent } from "vue/server-renderer";
|
import { ssrRenderAttrs, ssrInterpolate, ssrRenderAttr, ssrIncludeBooleanAttr, ssrLooseContain, ssrLooseEqual, ssrRenderList, ssrRenderClass, ssrRenderComponent } from "vue/server-renderer";
|
||||||
import "/app/node_modules/hookable/dist/index.mjs";
|
import "/app/node_modules/hookable/dist/index.mjs";
|
||||||
import { c as useCookie } from "../server.mjs";
|
import { d as useCookie } from "../server.mjs";
|
||||||
import "/app/node_modules/ufo/dist/index.mjs";
|
import "/app/node_modules/ufo/dist/index.mjs";
|
||||||
import "/app/node_modules/defu/dist/defu.mjs";
|
import "/app/node_modules/defu/dist/defu.mjs";
|
||||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||||
@@ -263,4 +263,4 @@ _sfc_main.setup = (props, ctx) => {
|
|||||||
export {
|
export {
|
||||||
_sfc_main as default
|
_sfc_main as default
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=index-CzKBE-M2.js.map
|
//# sourceMappingURL=index-CemkpUgu.js.map
|
||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user