backend admin flotta bekötése
This commit is contained in:
217
.roo/history.md
217
.roo/history.md
@@ -492,3 +492,220 @@ Dinamikus permission management UI építése a DB-driven RBAC rendszerhez. Back
|
||||
- `_build_user_response` async függvény, ami `rbac_service.get_role_permissions(db, role_id)`-t hív
|
||||
- `/auth/me` és `/users/me` endpoint-ok 38 db system_capabilities-t adnak vissza (mind True)
|
||||
- `Capability` osztály eltávolítva a kódbázisból
|
||||
|
||||
## 2026-06-25 - P0 EXECUTION: Garage CRM Completion (Status & Edit)
|
||||
|
||||
### 🎯 Cél
|
||||
Garázs CRM funkciók teljes körű implementálása: backend státuszváltó és adatszerkesztő végpontok, frontend UI integráció a listázó és részletes oldalakon.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
|
||||
**1. Backend API - [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:512)**
|
||||
- `PUT /{org_id}/status` — Státuszváltó végpont (`OrgStatusUpdate` séma): validálja a státuszt (active/inactive/suspended/pending_verification), szinkronizálja `org.is_active`-t, rögzíti `last_deactivated_at`-t. Védve: `Depends(RequirePermission("org:edit"))`.
|
||||
- `PUT /{org_id}` — Adatszerkesztő végpont (`OrganizationUpdate` séma): 10 mező (name, full_name, display_name, tax_number, reg_number, address_zip/city/street_name/street_type/house_number). Csak a nem-None mezőket frissíti. Védve: `Depends(RequirePermission("org:edit"))`.
|
||||
|
||||
**2. Frontend Lista - [`frontend_admin/pages/garages/index.vue`](frontend_admin/pages/garages/index.vue)**
|
||||
- `toggleStatus()` függvény: valós API hívással (`PUT /api/v1/admin/organizations/{id}/status`), dinamikus gombszín (piros/zöld), success/error toast, automatikus refresh.
|
||||
|
||||
**3. Frontend Részletek - [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue)**
|
||||
- "Adatok Szerkesztése" gomb a header-ben
|
||||
- Edit Modal: 8 mező (full_name, display_name, tax_number, reg_number, address_zip/city/street_name/street_type/house_number), pre-fill, Mentés → `PUT /api/v1/admin/organizations/{id}`, success/error toast, automatikus refresh
|
||||
|
||||
**4. i18n - [`frontend_admin/i18n/locales/hu.json`](frontend_admin/i18n/locales/hu.json) + [`frontend_admin/i18n/locales/en.json`](frontend_admin/i18n/locales/en.json)**
|
||||
- `garages.details.edit_details`, `garages.details.save`, `garages.details.zip`, `garages.details.city`, `garages.details.street_name`, `garages.details.street_type`, `garages.details.house_number` kulcsok (magyar + angol)
|
||||
|
||||
### ✅ Verifikáció
|
||||
- **Sync Engine:** ✅ 1265 OK, 0 Fixed, 0 Extra
|
||||
- **PUT status (inactive):** ✅ 200 — "A(z) 'KYC Garázsa' garázs státusza frissítve: 'active' → 'inactive'."
|
||||
- **PUT status (active):** ✅ 200 — "A(z) 'KYC Garázsa' garázs státusza frissítve: 'inactive' → 'active'."
|
||||
- **PUT update:** ✅ 200 — "A(z) 'KYC Garázsa' garázs adatai frissítve." (updated_fields: ['display_name'])
|
||||
|
||||
## 2026-06-25 - P0 Garage CRM Expansion: Contact/Billing Fields, Fleet Tab, RBAC UI
|
||||
|
||||
### 🎯 Cél
|
||||
Teljes Garage CRM profil bővítése: kapcsolattartói, számlázási és értesítési cím mezők hozzáadása a backendhez, flotta járművek végpont létrehozása, és a frontend garancia részletes nézetének teljes körű implementálása RBAC védelemmel.
|
||||
|
||||
### 🔧 Módosított Fájlok
|
||||
|
||||
**1. BACKEND - Modell (`backend/app/models/marketplace/organization.py`)**
|
||||
- 14 új oszlop hozzáadva a `Organization` modellhez:
|
||||
- `contact_person_name`, `contact_email`, `contact_phone` (kapcsolattartó)
|
||||
- `billing_zip`, `billing_city`, `billing_street_name`, `billing_street_type`, `billing_house_number` (számlázási cím)
|
||||
- `notification_zip`, `notification_city`, `notification_street_name`, `notification_street_type`, `notification_house_number` (értesítési cím)
|
||||
|
||||
**2. BACKEND - API Végpont (`backend/app/api/v1/endpoints/admin_organizations.py`)**
|
||||
- `OrganizationUpdate` séma bővítve a 14 új mezővel
|
||||
- `GarageDetailsResponse` séma bővítve ugyanezen mezőkkel
|
||||
- Új `GET /{org_id}/vehicles` végpont létrehozva (`GarageVehicleItem`, `GarageFleetResponse` sémákkal)
|
||||
- `GarageVehicleItem` mezői: id, license_plate, vin, brand, model, year, color, status, branch_name, created_at
|
||||
|
||||
**3. FRONTEND - i18n (`frontend_admin/i18n/locales/hu.json`, `en.json`)**
|
||||
- Új kulcsok a `garages.details`, `garages.employees`, `garages.fleet` szekciókban
|
||||
|
||||
**4. FRONTEND - Garage Detail Page (`frontend_admin/pages/garages/[id]/index.vue`)**
|
||||
- Teljes körű implementáció (1366 sor):
|
||||
- **STEP 4:** "Missing Data" warning banner (tax_number, contact_email, contact_phone, billing_address ellenőrzés)
|
||||
- **STEP 5:** Tabbed Edit Modal (Alapadatok / Kapcsolat / Címek fülekkel)
|
||||
- **STEP 6:** Strict RBAC UI enforcement (`hasOrgPermission()` helper)
|
||||
- **STEP 7:** Fleet tab adattáblával (license_plate, brand, model, year, status)
|
||||
- **STEP 8:** "Add Employee" gomb a Dolgozók fülön
|
||||
- Add Employee, Edit Role, Remove confirmation modálok
|
||||
- Notification Toast komponens
|
||||
- Teljes `<script setup lang="ts">` szekció (API hívások, állapotkezelés, helper függvények)
|
||||
|
||||
### ✅ Verifikáció
|
||||
- **Sync Engine:** ✅ 1278 OK, 0 Fixed, 0 Extra
|
||||
- **Adatbázis séma:** 14 új oszlop sikeresen alkalmazva a `fleet.organizations` táblában
|
||||
|
||||
## 2026-06-25 - P0 UI Hotfix: Garage Details Tabok, Edit Modal & i18n
|
||||
|
||||
### 🎯 Cél
|
||||
Garage Details oldal (`garages/[id]/index.vue`) UI hibajavítások: hiányzó 4. "Analitika" fül visszaállítása, i18n kulcsok pótlása, edit modal adatkötés javítása, hiányzó adatok riasztó javítása.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
1. **`frontend_admin/i18n/locales/hu.json`** — Hozzáadva: `generalTab`, `employeesTab`, `fleetTab`, `analyticsTab`, `editBasicTab`, `editContactTab`, `editAddressTab` kulcsok
|
||||
2. **`frontend_admin/i18n/locales/en.json`** — Hozzáadva: `generalTab`, `employeesTab`, `fleetTab`, `analyticsTab`, `editBasicTab`, `editContactTab`, `editAddressTab` kulcsok
|
||||
3. **`frontend_admin/pages/garages/[id]/index.vue`** — Több javítás:
|
||||
- 4. "analytics" tab hozzáadva a `tabs` tömbhöz
|
||||
- Analytics tab tartalom (Coming Soon placeholder) hozzáadva a template-hez
|
||||
- `editForm` ref kibővítve: `full_name`, `display_name`, `reg_number`, `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number` mezőkkel
|
||||
- Edit form template v-model javítva: `editForm.zip` → `editForm.address_zip`, stb.
|
||||
- `address_street_type` input mező hozzáadva az Alapadatok tabhoz
|
||||
- `openEditModal()` kibővítve az összes hiányzó mező pre-fill-lel
|
||||
- `saveEdit()` javítva: API válaszból `result.organization` kinyerése
|
||||
- `missingFields` computed javítva: helyes i18n kulcsok (`missing_tax_number`, `missing_contact_email`, `missing_contact_phone`, `missing_billing`) és `{ key, label }` objektum struktúra
|
||||
- `formattedAddress` computed javítva: `garage.value.zip` → `garage.value.address_zip`, stb.
|
||||
|
||||
### ✅ Verifikáció
|
||||
- **Nuxt Build:** ✅ Sikeres (7.27s)
|
||||
- **Backend:** Nem volt szükség módosításra (model és API séma már tartalmazta az összes billing/contact mezőt)
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-25 - P0 Hotfix: Garage CRM Smart Validation (i18n, Tab Switching, Missing Data, Contact Propagation)
|
||||
|
||||
### 🎯 Cél
|
||||
4 kritikus javítás a Garage CRM Smart Validation rendszerben:
|
||||
1. **i18n rendering** - nyers kulcsok (pl. `garages.details.generalTab`) megjelenítése helyett fordított szöveg
|
||||
2. **Tab switching** - edit modal tabok nem működtek (halott kattintás)
|
||||
3. **Missing Data logika** - Private Garages (individual org_type) esetén ne kérjen adószámot/számlázási címet
|
||||
4. **Edit Modal contact info** - email/phone propagálása Person/User rekordokba individual org_type esetén
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. Frontend - i18n javítások:**
|
||||
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:125) - `{{ tab.label }}` → `{{ $t(tab.label) }}` (fő tab navigáció)
|
||||
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:509) - `{{ tab.label }}` → `{{ $t(tab.label) }}` (edit modal tabok)
|
||||
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:55) - `{{ field.label }}` → `{{ $t(field.label) }}` (missing data tag-ek)
|
||||
|
||||
**2. Frontend - Tab switching javítás:**
|
||||
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:983) - `editModalTabs` kulcs javítva: `'address'` → `'addresses'` (egyezés a template `v-if="editModalTab === 'addresses'"` feltétellel)
|
||||
|
||||
**3. Frontend - Missing Data logika:**
|
||||
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:995) - `missingFields` computed:
|
||||
- `tax_number` ellenőrzés kihagyása `org_type === 'individual'` esetén
|
||||
- `contact_email`/`contact_phone` mellett `email`/`phone` fallback ellenőrzés
|
||||
- Számlázási cím ellenőrzés kihagyása individual org_type esetén
|
||||
|
||||
**4. Frontend - Edit Modal contact info:**
|
||||
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:1123) - `saveEdit()`:
|
||||
- Csak nem-üres mezők küldése a payload-ban
|
||||
- Individual garages esetén `contact_email` → `email` és `contact_phone` → `phone` mapping
|
||||
|
||||
**5. Backend - OrganizationUpdate séma bővítés:**
|
||||
- [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:623) - `OrganizationUpdate` séma: új `email` és `phone` mezők hozzáadva
|
||||
|
||||
**6. Backend - Propagációs logika:**
|
||||
- [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:702) - `update_organization` végpont:
|
||||
- Individual org_type + legal_owner_id esetén:
|
||||
- `contact_phone`/`phone` propagálása `Person.phone`-ba
|
||||
- `contact_email`/`email` propagálása a hozzá tartozó `User.email`-be
|
||||
|
||||
### ✅ Verifikáció
|
||||
- **Backend Schema:** ✅ Sikeres (`OrganizationUpdate` séma tartalmazza az új `email` és `phone` mezőket)
|
||||
- **Frontend:** Mind a 4 javítás implementálva és ellenőrizve
|
||||
|
||||
## 2026-06-25 - P0 HOTFIX: Zero-Duplication Contact & 401 Auth Header
|
||||
|
||||
### 🎯 Cél
|
||||
Az Architect által jelzett 3 kritikus hiba javítása:
|
||||
1. Frontend 401 Auth Header (token whitespace/newline trimming)
|
||||
2. Backend Zero-Duplication Contact Logic (owner fallback)
|
||||
3. Owner mindig látható a Dolgozók (Employees) tabon
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. Frontend 401 Auth Header Fix**
|
||||
- Fájl: `frontend_admin/pages/garages/[id]/index.vue`
|
||||
- Minden `fetch` hívásnál a token `.trim()`-elve kerül a `Bearer` header-be
|
||||
- Érintett függvények: `fetchDetails`, `fetchFleetVehicles`, `saveEdit`, `addEmployee`, `saveRoleEdit`, `executeRemove`
|
||||
|
||||
**2. Backend Zero-Duplication Contact Logic**
|
||||
- Fájl: `backend/app/api/v1/endpoints/admin_organizations.py`
|
||||
- `get_organization_details` endpoint kibővítve:
|
||||
- `Organization.owner` + `User.person` eager loading
|
||||
- Ha `contact_person_name` NULL → `owner.person` full name
|
||||
- Ha `contact_email` NULL → `owner.email`
|
||||
- Ha `contact_phone` NULL → `owner.person.phone`
|
||||
|
||||
**3. Owner Presence in Members List**
|
||||
- Ha az owner nem szerepel az `OrganizationMember` táblában, dinamikusan prependáljuk
|
||||
- `synthetic_owner` rekord `id=0` markerrel, `role="OWNER"`
|
||||
- `member_count` is nő, ha szintetikus owner került hozzáadásra
|
||||
|
||||
### ✅ Verifikáció
|
||||
- **Syntax check:** ✅ Sikeres (py_compile)
|
||||
- **DB Sync:** ✅ Tökéletes szinkron (1278 elem)
|
||||
- **API Test:** ✅ Sikeres
|
||||
- Individual org: contact_person_name="TestUser KYC", contact_email=owner email
|
||||
- Business org with owner: contact info correctly populated
|
||||
- Owner minden esetben szerepel a members listában
|
||||
|
||||
## 2026-06-25 - P0 Security & UI Execution: 401 Interceptor, Employees Tab, Add Employee Modal
|
||||
|
||||
### 🎯 Cél
|
||||
P0 Security & UI Execution feladat három komponenssel:
|
||||
1. **Global 401 Unauthorized Interceptor** - lejárt token esetén automatikus logout és redirect /login-ra
|
||||
2. **Employees Tab renderelése** - OWNER szerepkör arany badge-dzsel
|
||||
3. **Add Employee Modal** - email alapú tag hozzáadás
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. Global 401 Interceptor** ([`frontend_admin/plugins/api.ts`](frontend_admin/plugins/api.ts))
|
||||
- Új Nuxt plugin, amely `globalThis.fetch`-et wrap-eli
|
||||
- 401-es válasz esetén meghívja `authStore.logout()`-ot, majd redirectel `/login`-ra
|
||||
- Kivétel: `/auth/login` és `/auth/refresh` végpontok (redirect loop elkerülése)
|
||||
|
||||
**2. Backend email-based member lookup** ([`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:18))
|
||||
- `OrganizationMemberCreate` séma most már támogatja az `email` mezőt `user_id` alternatívaként
|
||||
- [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:990) - email alapú felhasználó keresés implementálva
|
||||
|
||||
**3. Frontend javítások** ([`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue))
|
||||
- `openEditRoleModal` → `openEditRole` függvénynév mismatch javítva
|
||||
- Add Employee, Edit Role, Remove modálok dark theme-re átállítva (`bg-slate-800`, `text-white`)
|
||||
- Notification toast dark theme-re átállítva
|
||||
|
||||
**4. i18n kiegészítések** ([`frontend_admin/i18n/locales/en.json`](frontend_admin/i18n/locales/en.json:207), [`frontend_admin/i18n/locales/hu.json`](frontend_admin/i18n/locales/hu.json:207))
|
||||
- `editRoleTitle`, `removeTitle`, `removeConfirm` camelCase kulcsok hozzáadva
|
||||
|
||||
### ✅ Verifikáció
|
||||
- **DB Sync:** ✅ Tökéletes szinkron (1278 elem)
|
||||
- **Syntax:** ✅ Minden fájl szintaktikailag helyes
|
||||
|
||||
## 2026-06-26 - P0 HOTFIX: GET /admin/organizations/{org_id}/vehicles 500 Internal Server Error
|
||||
|
||||
### 🔍 Diagnózis
|
||||
A frontend által hívott `GET /api/v1/admin/organizations/43/vehicles` végpont 500-as hibát dobott. A Docker logok három egymásra épülő `AttributeError`-t mutattak:
|
||||
|
||||
1. **`Asset.branch`** — Az [`Asset`](backend/app/models/vehicle/asset.py:69) modellben nincs `branch` relationship, csak `branch_id` oszlop. A kód `.options(selectinload(Asset.branch))`-et használt.
|
||||
2. **`Asset.year`** — Az Asset modellben `year_of_manufacture` a mező neve, nem `year`.
|
||||
3. **`Asset.color`** — Az Asset modellben nincs `color` mező.
|
||||
|
||||
### 🔧 Javítások ([`admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:1289))
|
||||
- **`selectinload(Asset.branch)` eltávolítva** — Helyette explicit `outerjoin(Branch, Asset.branch_id == Branch.id)` a `Branch.name` lekéréséhez.
|
||||
- **`v.year` → `v.year_of_manufacture`** — Javítva a mezőnév az Asset modell valós oszlopnevére.
|
||||
- **`color` mező eltávolítva** a `GarageVehicleItem` Pydantic sémából és a leképzésből, mert az Asset modell nem tartalmaz ilyen attribútumot.
|
||||
|
||||
### ✅ Verifikáció
|
||||
- `GET /api/v1/admin/organizations/43/vehicles` → **200 OK**
|
||||
- Visszaadott 3 jármű helyes adatokkal (brand, model, year, license_plate, branch_name)
|
||||
|
||||
@@ -17,7 +17,7 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, func, update as sa_update, delete as sa_delete
|
||||
from sqlalchemy import select, func, or_, update as sa_update, delete as sa_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -125,7 +125,7 @@ class SubscriptionSummary(BaseModel):
|
||||
|
||||
|
||||
class GarageDetailsResponse(BaseModel):
|
||||
"""Garázs részletes adatai (General Tab)."""
|
||||
"""Garázs részletes adatai (General Tab) — kibővített CRM profil."""
|
||||
# Szervezet adatok
|
||||
id: int
|
||||
name: str
|
||||
@@ -135,7 +135,7 @@ class GarageDetailsResponse(BaseModel):
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
org_type: str
|
||||
# Cím adatok
|
||||
# Cím adatok (elsődleges)
|
||||
address_city: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
@@ -143,9 +143,25 @@ class GarageDetailsResponse(BaseModel):
|
||||
address_house_number: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
reg_number: Optional[str] = None
|
||||
# ── Kapcsolattartó (Contact Person) ──
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
# ── Számlázási cím (Billing Address) ──
|
||||
billing_zip: Optional[str] = None
|
||||
billing_city: Optional[str] = None
|
||||
billing_street_name: Optional[str] = None
|
||||
billing_street_type: Optional[str] = None
|
||||
billing_house_number: Optional[str] = None
|
||||
# ── Értesítési cím (Notification Address) ──
|
||||
notification_zip: Optional[str] = None
|
||||
notification_city: Optional[str] = None
|
||||
notification_street_name: Optional[str] = None
|
||||
notification_street_type: Optional[str] = None
|
||||
notification_house_number: Optional[str] = None
|
||||
# Előfizetés
|
||||
subscription: Optional[SubscriptionSummary] = None
|
||||
# Kapcsolattartó
|
||||
# Kapcsolattartó (ContactPerson modellből)
|
||||
primary_contact: Optional[ContactPersonInfo] = None
|
||||
# Meta
|
||||
created_at: Optional[str] = None
|
||||
@@ -171,6 +187,7 @@ async def list_organizations(
|
||||
search_term: Optional[str] = Query(None, description="Keresés név vagy e-mail alapján"),
|
||||
status_filter: Optional[str] = Query(None, description="Szűrés státusz szerint (active/inactive/suspended/pending_verification)"),
|
||||
org_type_filter: Optional[str] = Query(None, description="Szűrés szervezet típus szerint"),
|
||||
tier_name_filter: Optional[str] = Query(None, description="Szűrés előfizetési csomag neve alapján (pl. 'Premium', 'Free/Fallback')"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:view")),
|
||||
@@ -183,33 +200,54 @@ async def list_organizations(
|
||||
- Taglétszámot (OrganizationMember-ek száma)
|
||||
- Aktív előfizetés adatait (tier név, lejárati dátum)
|
||||
"""
|
||||
# Alap lekérdezés
|
||||
query = select(Organization).options(
|
||||
selectinload(Organization.subscription_tier),
|
||||
)
|
||||
# Alap lekérdezés — outerjoin owner (User) hogy a tulajdonos emailjét is kereshessük
|
||||
base_query = select(Organization.id).outerjoin(Organization.owner)
|
||||
|
||||
# Szűrés
|
||||
if search_term:
|
||||
like_pattern = f"%{search_term}%"
|
||||
query = query.where(
|
||||
base_query = base_query.where(
|
||||
Organization.full_name.ilike(like_pattern) |
|
||||
Organization.name.ilike(like_pattern) |
|
||||
Organization.display_name.ilike(like_pattern)
|
||||
Organization.display_name.ilike(like_pattern) |
|
||||
Organization.contact_email.ilike(like_pattern) |
|
||||
Organization.contact_person_name.ilike(like_pattern) |
|
||||
User.email.ilike(like_pattern)
|
||||
)
|
||||
if status_filter:
|
||||
query = query.where(Organization.status == status_filter)
|
||||
base_query = base_query.where(Organization.status == status_filter)
|
||||
if org_type_filter:
|
||||
query = query.where(Organization.org_type == org_type_filter)
|
||||
base_query = base_query.where(Organization.org_type == org_type_filter)
|
||||
if tier_name_filter:
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
base_query = base_query.join(SubscriptionTier, Organization.subscription_tier_id == SubscriptionTier.id)
|
||||
base_query = base_query.where(SubscriptionTier.name.ilike(f"%{tier_name_filter}%"))
|
||||
|
||||
# Teljes számolás
|
||||
count_stmt = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Lapozás
|
||||
query = query.order_by(Organization.id.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
organizations = result.scalars().all()
|
||||
# Lapozás — get IDs first, then fetch full objects
|
||||
id_query = base_query.order_by(Organization.id.desc()).offset(skip).limit(limit)
|
||||
id_result = await db.execute(id_query)
|
||||
org_ids_subset = [row[0] for row in id_result.all()]
|
||||
|
||||
# Fetch full Organization objects by IDs with joinedload to avoid MissingGreenlet
|
||||
if org_ids_subset:
|
||||
from sqlalchemy.orm import joinedload
|
||||
query = select(Organization).options(
|
||||
joinedload(Organization.subscription_tier),
|
||||
joinedload(Organization.owner),
|
||||
).where(Organization.id.in_(org_ids_subset))
|
||||
result = await db.execute(query)
|
||||
# Use unique() because joinedload may produce duplicate rows
|
||||
organizations = result.unique().scalars().all()
|
||||
# Preserve the ordering from the id_query
|
||||
org_map = {org.id: org for org in organizations}
|
||||
organizations = [org_map[oid] for oid in org_ids_subset if oid in org_map]
|
||||
else:
|
||||
organizations = []
|
||||
|
||||
# Összes org_id begyűjtése a tömeges subscription lekérdezéshez
|
||||
org_ids = [org.id for org in organizations]
|
||||
@@ -276,12 +314,15 @@ async def list_organizations(
|
||||
tier_name = org.subscription_tier.name
|
||||
expires_at = org.subscription_expires_at.isoformat() if org.subscription_expires_at else None
|
||||
|
||||
# Resolve email: prefer contact_email, fallback to owner's email
|
||||
resolved_email = org.contact_email or (org.owner.email if org.owner else None)
|
||||
|
||||
garage_list.append(GarageListItem(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
full_name=org.full_name,
|
||||
display_name=org.display_name,
|
||||
email=None, # Organization-nak nincs közvetlen email mezője
|
||||
email=resolved_email,
|
||||
status=org.status,
|
||||
is_active=org.is_active,
|
||||
is_verified=org.is_verified,
|
||||
@@ -331,12 +372,17 @@ async def get_organization_details(
|
||||
- Előfizetés összefoglalót (tier név, lejárat, járműszám/korlát)
|
||||
- Elsődleges kapcsolattartó adatait (fleet.contact_persons)
|
||||
- Tagok teljes listáját (OrganizationMember) nested Person adatokkal
|
||||
|
||||
P0 Zero-Duplication:
|
||||
- Ha contact_email/phone/person_name NULL, az owner adatai kerülnek bele.
|
||||
- Az owner mindig szerepel a members listában (OWNER role).
|
||||
"""
|
||||
# 1. Szervezet lekérése subscription_tier kapcsolattal
|
||||
# 1. Szervezet lekérése subscription_tier kapcsolattal + owner
|
||||
org_stmt = (
|
||||
select(Organization)
|
||||
.options(
|
||||
selectinload(Organization.subscription_tier),
|
||||
selectinload(Organization.owner).selectinload(User.person),
|
||||
)
|
||||
.where(Organization.id == org_id)
|
||||
)
|
||||
@@ -438,8 +484,34 @@ async def get_organization_details(
|
||||
is_primary=contact.is_primary,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# P0: Owner adatok betöltése a Zero-Duplication Contact Logic-hoz
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
owner: Optional[User] = org.owner
|
||||
owner_person: Optional[Person] = None
|
||||
owner_full_name: Optional[str] = None
|
||||
owner_email: Optional[str] = None
|
||||
owner_phone: Optional[str] = None
|
||||
|
||||
if owner:
|
||||
owner_email = owner.email
|
||||
owner_person = owner.person
|
||||
if owner_person:
|
||||
owner_full_name = f"{owner_person.last_name} {owner_person.first_name}".strip()
|
||||
owner_phone = owner_person.phone
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# P0: Zero-Duplication Override Pattern
|
||||
# Ha a contact mező NULL, az owner adatait injectáljuk
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
resolved_contact_person_name = org.contact_person_name or owner_full_name
|
||||
resolved_contact_email = org.contact_email or owner_email
|
||||
resolved_contact_phone = org.contact_phone or owner_phone
|
||||
|
||||
# 7. Tagok összeállítása nested Person adatokkal
|
||||
members_list: List[OrganizationMemberResponse] = []
|
||||
owner_found_in_members = False
|
||||
|
||||
for m in member_rows:
|
||||
person_data = None
|
||||
# Try to get Person from the member's direct person relationship first
|
||||
@@ -458,6 +530,10 @@ async def get_organization_details(
|
||||
phone=person_obj.phone,
|
||||
)
|
||||
|
||||
# Check if this member is the owner
|
||||
if owner and m.user_id == owner.id and m.role == "OWNER":
|
||||
owner_found_in_members = True
|
||||
|
||||
members_list.append(OrganizationMemberResponse(
|
||||
id=m.id,
|
||||
user_id=m.user_id,
|
||||
@@ -470,6 +546,48 @@ async def get_organization_details(
|
||||
updated_at=m.updated_at.isoformat() if m.updated_at else None,
|
||||
))
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# P0: Ha az owner NEM szerepel a members listában, dinamikusan hozzáfűzzük
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
if owner and not owner_found_in_members:
|
||||
# Build a fully-formed PersonBrief — even if owner_person is None,
|
||||
# we still provide an empty PersonBrief so the frontend never crashes
|
||||
# on null person access.
|
||||
owner_person_data = PersonBrief(
|
||||
first_name=owner_person.first_name or "" if owner_person else "",
|
||||
last_name=owner_person.last_name or "" if owner_person else "",
|
||||
email=owner_email,
|
||||
phone=owner_phone,
|
||||
)
|
||||
# Create a synthetic member entry for the owner.
|
||||
# P0 HOTFIX: The synthetic member MUST include display_name/name/email
|
||||
# at the top level so the frontend memberFullName() helper can render
|
||||
# a human-readable name instead of falling back to user_id.
|
||||
synthetic_owner = OrganizationMemberResponse(
|
||||
id=0, # synthetic marker
|
||||
user_id=owner.id,
|
||||
organization_id=org_id,
|
||||
role="OWNER",
|
||||
status="active",
|
||||
is_verified=True,
|
||||
person=owner_person_data,
|
||||
created_at=org.created_at.isoformat() if org.created_at else None,
|
||||
updated_at=None,
|
||||
)
|
||||
# Inject top-level display fields for frontend memberFullName()
|
||||
# (the Pydantic model doesn't have these fields, so we use object.__setattr__)
|
||||
object.__setattr__(synthetic_owner, 'display_name', owner_full_name or owner_email or f"Owner #{owner.id}")
|
||||
object.__setattr__(synthetic_owner, 'name', owner_full_name or owner_email or f"Owner #{owner.id}")
|
||||
object.__setattr__(synthetic_owner, 'email', owner_email)
|
||||
# Prepend the owner so they appear first in the list
|
||||
members_list.insert(0, synthetic_owner)
|
||||
# Also bump the member_count
|
||||
member_count += 1
|
||||
logger.info(
|
||||
f"P0: Dynamically prepended owner user_id={owner.id} to members list "
|
||||
f"for org {org_id} ({org.full_name})"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) fetched details for org {org_id} "
|
||||
f"({org.full_name}) — {len(members_list)} members"
|
||||
@@ -491,6 +609,22 @@ async def get_organization_details(
|
||||
address_house_number=org.address_house_number,
|
||||
tax_number=org.tax_number,
|
||||
reg_number=org.reg_number,
|
||||
# ── P0: Zero-Duplication Kapcsolattartó (owner fallback) ──
|
||||
contact_person_name=resolved_contact_person_name,
|
||||
contact_email=resolved_contact_email,
|
||||
contact_phone=resolved_contact_phone,
|
||||
# ── Számlázási cím ──
|
||||
billing_zip=org.billing_zip,
|
||||
billing_city=org.billing_city,
|
||||
billing_street_name=org.billing_street_name,
|
||||
billing_street_type=org.billing_street_type,
|
||||
billing_house_number=org.billing_house_number,
|
||||
# ── Értesítési cím ──
|
||||
notification_zip=org.notification_zip,
|
||||
notification_city=org.notification_city,
|
||||
notification_street_name=org.notification_street_name,
|
||||
notification_street_type=org.notification_street_type,
|
||||
notification_house_number=org.notification_house_number,
|
||||
subscription=subscription_summary,
|
||||
primary_contact=primary_contact,
|
||||
created_at=org.created_at.isoformat() if org.created_at else None,
|
||||
@@ -499,6 +633,240 @@ async def get_organization_details(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUT /{org_id}/status — Garázs státuszának módosítása
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrgStatusUpdate(BaseModel):
|
||||
"""Státusz módosításának kérése."""
|
||||
status: str = Field(..., description="Új státusz: active, inactive, suspended, pending_verification")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{org_id}/status",
|
||||
summary="Garázs státuszának módosítása",
|
||||
description="Frissíti egy garázs/szervezet státuszát. "
|
||||
"Lehetséges értékek: active, inactive, suspended, pending_verification. "
|
||||
"A státuszváltás hatással van az is_active mezőre is.",
|
||||
)
|
||||
async def update_org_status(
|
||||
org_id: int,
|
||||
payload: OrgStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:edit")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Garázs státuszának módosítása.
|
||||
|
||||
- Ellenőrzi, hogy a szervezet létezik-e.
|
||||
- Frissíti a status mezőt a megadott értékre.
|
||||
- Az is_active mezőt automatikusan szinkronizálja:
|
||||
- 'active' → is_active = True
|
||||
- 'inactive' vagy 'suspended' → is_active = False
|
||||
"""
|
||||
# 1. Ellenőrizzük a szervezetet
|
||||
org_stmt = select(Organization).where(Organization.id == org_id)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||
)
|
||||
|
||||
# 2. Validáljuk a státusz értéket
|
||||
valid_statuses = {"active", "inactive", "suspended", "pending_verification"}
|
||||
new_status = payload.status.lower()
|
||||
if new_status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Érvénytelen státusz: '{payload.status}'. "
|
||||
f"Lehetséges értékek: {', '.join(sorted(valid_statuses))}",
|
||||
)
|
||||
|
||||
# 3. Mentjük a régi státuszt naplózáshoz
|
||||
old_status = org.status
|
||||
|
||||
# 4. Frissítjük a státuszt és az is_active mezőt
|
||||
org.status = new_status
|
||||
org.is_active = (new_status == "active")
|
||||
|
||||
# Ha inaktiváljuk, jegyezzük fel az időpontot
|
||||
if new_status in ("inactive", "suspended") and old_status == "active":
|
||||
from datetime import datetime as dt
|
||||
org.last_deactivated_at = dt.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) changed status for org {org_id} "
|
||||
f"({org.full_name}): '{old_status}' → '{new_status}'"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"A(z) '{org.full_name}' garázs státusza frissítve: '{old_status}' → '{new_status}'.",
|
||||
"org_id": org.id,
|
||||
"old_status": old_status,
|
||||
"new_status": new_status,
|
||||
"is_active": org.is_active,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUT /{org_id} — Garázs adatainak szerkesztése
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrganizationUpdate(BaseModel):
|
||||
"""Garázs adatainak szerkesztésére szolgáló séma (kibővített CRM)."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255, description="Rövid név (slug-szerű)")
|
||||
full_name: Optional[str] = Field(None, min_length=1, max_length=255, description="Teljes cégnév")
|
||||
display_name: Optional[str] = Field(None, max_length=50, description="Megjelenítési név")
|
||||
tax_number: Optional[str] = Field(None, max_length=20, description="Adószám")
|
||||
reg_number: Optional[str] = Field(None, max_length=50, description="Cégjegyzékszám")
|
||||
# ── Org-level email/phone (used for individual garages to propagate to Person/User) ──
|
||||
email: Optional[str] = Field(None, max_length=255, description="Szervezeti e-mail (individual garages esetén a User rekordba propagálódik)")
|
||||
phone: Optional[str] = Field(None, max_length=50, description="Szervezeti telefon (individual garages esetén a Person rekordba propagálódik)")
|
||||
# ── Elsődleges cím (Primary Address) ──
|
||||
address_zip: Optional[str] = Field(None, max_length=10, description="Irányítószám")
|
||||
address_city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Utca típusa (utca, tér, stb.)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám")
|
||||
# ── Kapcsolattartó (Contact Person) ──
|
||||
contact_person_name: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó neve")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó e-mail címe")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Kapcsolattartó telefonszáma")
|
||||
# ── Számlázási cím (Billing Address) ──
|
||||
billing_zip: Optional[str] = Field(None, max_length=10, description="Számlázási irányítószám")
|
||||
billing_city: Optional[str] = Field(None, max_length=100, description="Számlázási város")
|
||||
billing_street_name: Optional[str] = Field(None, max_length=150, description="Számlázási utca neve")
|
||||
billing_street_type: Optional[str] = Field(None, max_length=50, description="Számlázási utca típusa")
|
||||
billing_house_number: Optional[str] = Field(None, max_length=20, description="Számlázási házszám")
|
||||
# ── Értesítési cím (Notification Address) ──
|
||||
notification_zip: Optional[str] = Field(None, max_length=10, description="Értesítési irányítószám")
|
||||
notification_city: Optional[str] = Field(None, max_length=100, description="Értesítési város")
|
||||
notification_street_name: Optional[str] = Field(None, max_length=150, description="Értesítési utca neve")
|
||||
notification_street_type: Optional[str] = Field(None, max_length=50, description="Értesítési utca típusa")
|
||||
notification_house_number: Optional[str] = Field(None, max_length=20, description="Értesítési házszám")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{org_id}",
|
||||
summary="Garázs adatainak szerkesztése",
|
||||
description="Frissíti egy garázs alapadatait (név, adószám, cím, stb.). "
|
||||
"Csak a megadott mezők frissülnek; a None értékű mezők változatlanok maradnak.",
|
||||
)
|
||||
async def update_organization(
|
||||
org_id: int,
|
||||
payload: OrganizationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("org:edit")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Garázs adatainak szerkesztése.
|
||||
|
||||
- Ellenőrzi, hogy a szervezet létezik-e.
|
||||
- Csak a megadott (nem None) mezőket frissíti.
|
||||
- Visszaadja a frissített adatokat.
|
||||
"""
|
||||
# 1. Ellenőrizzük a szervezetet
|
||||
org_stmt = select(Organization).where(Organization.id == org_id)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||
)
|
||||
|
||||
# 2. Összegyűjtjük a változásokat
|
||||
update_fields = {}
|
||||
for field_name in payload.model_dump(exclude_none=True):
|
||||
value = getattr(payload, field_name)
|
||||
if value is not None and hasattr(org, field_name):
|
||||
setattr(org, field_name, value)
|
||||
update_fields[field_name] = value
|
||||
|
||||
if not update_fields:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Nem adtál meg egyetlen módosítandó mezőt sem.",
|
||||
)
|
||||
|
||||
# 3. Individual (private) garages: propagate contact info to Person/User
|
||||
if org.org_type == 'individual' and org.legal_owner_id:
|
||||
person_stmt = select(Person).where(Person.id == org.legal_owner_id)
|
||||
person_result = await db.execute(person_stmt)
|
||||
person = person_result.scalar_one_or_none()
|
||||
|
||||
if person:
|
||||
person_updated = False
|
||||
|
||||
# Propagate phone to Person record (from either contact_phone or phone field)
|
||||
phone_value = update_fields.get('contact_phone') or update_fields.get('phone')
|
||||
if phone_value:
|
||||
person.phone = phone_value
|
||||
person_updated = True
|
||||
|
||||
# Propagate email to the linked User record (from either contact_email or email field)
|
||||
email_value = update_fields.get('contact_email') or update_fields.get('email')
|
||||
if email_value and person.user_id:
|
||||
user_stmt = select(User).where(User.id == person.user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
user.email = email_value
|
||||
logger.info(
|
||||
f"Propagated email to user {user.id} for individual org {org_id}"
|
||||
)
|
||||
|
||||
if person_updated:
|
||||
logger.info(
|
||||
f"Propagated phone to person {person.id} for individual org {org_id}"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) updated org {org_id} "
|
||||
f"({org.full_name}): fields={list(update_fields.keys())}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"A(z) '{org.full_name}' garázs adatai frissítve.",
|
||||
"org_id": org.id,
|
||||
"updated_fields": list(update_fields.keys()),
|
||||
"organization": {
|
||||
"id": org.id,
|
||||
"name": org.name,
|
||||
"full_name": org.full_name,
|
||||
"display_name": org.display_name,
|
||||
"tax_number": org.tax_number,
|
||||
"reg_number": org.reg_number,
|
||||
"address_zip": org.address_zip,
|
||||
"address_city": org.address_city,
|
||||
"address_street_name": org.address_street_name,
|
||||
"address_street_type": org.address_street_type,
|
||||
"address_house_number": org.address_house_number,
|
||||
"status": org.status,
|
||||
"is_active": org.is_active,
|
||||
"contact_email": org.contact_email,
|
||||
"contact_phone": org.contact_phone,
|
||||
"contact_person_name": org.contact_person_name,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PATCH /{org_id}/subscription — Előfizetés módosítása
|
||||
# =============================================================================
|
||||
@@ -620,7 +988,8 @@ async def update_org_subscription(
|
||||
response_model=OrganizationMemberResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Új tag hozzáadása",
|
||||
description="Hozzáad egy felhasználót a garázshoz a megadott szerepkörrel.",
|
||||
description="Hozzáad egy felhasználót a garázshoz a megadott szerepkörrel. "
|
||||
"Támogatja a user_id és email alapú hozzáadást is.",
|
||||
)
|
||||
async def add_organization_member(
|
||||
org_id: int,
|
||||
@@ -632,8 +1001,12 @@ async def add_organization_member(
|
||||
"""
|
||||
Új tag hozzáadása egy garázshoz.
|
||||
|
||||
Két mód támogatott:
|
||||
1. user_id alapján (közvetlen)
|
||||
2. email alapján (a rendszer megkeresi a felhasználót)
|
||||
|
||||
- Ellenőrzi, hogy a szervezet létezik-e.
|
||||
- Ellenőrzi, hogy a user_id létezik-e az identity.users táblában.
|
||||
- Ellenőrzi, hogy a felhasználó létezik-e az identity.users táblában.
|
||||
- Ellenőrzi, hogy a felhasználó még nem tagja a szervezetnek.
|
||||
- Létrehozza az OrganizationMember rekordot.
|
||||
"""
|
||||
@@ -648,21 +1021,39 @@ async def add_organization_member(
|
||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||
)
|
||||
|
||||
# 2. Ellenőrizzük a felhasználót
|
||||
user_stmt = select(User).where(User.id == payload.user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
# 2. Felhasználó azonosítása: user_id vagy email alapján
|
||||
resolved_user_id = payload.user_id
|
||||
if resolved_user_id is None and payload.email:
|
||||
# Email-based lookup
|
||||
user_stmt = select(User).where(User.email == payload.email)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Felhasználó nem található e-mail címmel: {payload.email}",
|
||||
)
|
||||
resolved_user_id = user.id
|
||||
elif resolved_user_id is not None:
|
||||
# user_id-based lookup
|
||||
user_stmt = select(User).where(User.id == resolved_user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Felhasználó nem található ID-vel: {resolved_user_id}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Felhasználó nem található ID-vel: {payload.user_id}",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Meg kell adni a user_id vagy email mezőt.",
|
||||
)
|
||||
|
||||
# 3. Ellenőrizzük, hogy a felhasználó még nem tagja a szervezetnek
|
||||
existing_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.organization_id == org_id,
|
||||
OrganizationMember.user_id == payload.user_id,
|
||||
OrganizationMember.user_id == resolved_user_id,
|
||||
OrganizationMember.status != "archived",
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
@@ -671,14 +1062,14 @@ async def add_organization_member(
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"A felhasználó (ID: {payload.user_id}) már tagja ennek a szervezetnek.",
|
||||
detail=f"A felhasználó (ID: {resolved_user_id}) már tagja ennek a szervezetnek.",
|
||||
)
|
||||
|
||||
# 4. Létrehozzuk az új tag rekordot
|
||||
person_id = user.person_id
|
||||
new_member = OrganizationMember(
|
||||
organization_id=org_id,
|
||||
user_id=payload.user_id,
|
||||
user_id=resolved_user_id,
|
||||
person_id=person_id,
|
||||
role=payload.role,
|
||||
status=payload.status,
|
||||
@@ -706,7 +1097,7 @@ async def add_organization_member(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) added member user_id={payload.user_id} "
|
||||
f"Admin {current_user.id} ({current_user.email}) added member user_id={resolved_user_id} "
|
||||
f"to org {org_id} ({org.full_name}) with role={payload.role}"
|
||||
)
|
||||
|
||||
@@ -862,3 +1253,122 @@ async def remove_organization_member(
|
||||
"status": "success",
|
||||
"message": f"Tag (ID: {member_id}) eltávolítva a szervezetből (ID: {org_id}).",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /{org_id}/vehicles — Garázs járműveinek listázása (Flotta Tab)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GarageVehicleItem(BaseModel):
|
||||
"""Egy jármű adatai a garázs flotta listájában."""
|
||||
id: str
|
||||
license_plate: Optional[str] = None
|
||||
vin: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
status: str = "active"
|
||||
branch_name: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class GarageFleetResponse(BaseModel):
|
||||
"""Garázs flotta járműveinek válasza."""
|
||||
total: int
|
||||
vehicles: List[GarageVehicleItem]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{org_id}/vehicles",
|
||||
response_model=GarageFleetResponse,
|
||||
summary="Garázs járműveinek listázása",
|
||||
description="Visszaadja egy garázshoz tartozó összes járművet a Flotta Tab számára.",
|
||||
)
|
||||
async def get_organization_vehicles(
|
||||
org_id: int,
|
||||
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("fleet:view")),
|
||||
) -> GarageFleetResponse:
|
||||
"""
|
||||
Garázs járműveinek lekérése.
|
||||
|
||||
Két forrásból keres:
|
||||
1. vehicle.assets.current_organization_id (elsődleges)
|
||||
2. fleet.asset_assignments (történeti kapcsolatok)
|
||||
"""
|
||||
# 1. Ellenőrizzük a szervezetet
|
||||
org_stmt = select(Organization).where(Organization.id == org_id)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||
)
|
||||
|
||||
# 2. Járművek lekérése a vehicle.assets táblából
|
||||
from app.models.vehicle.asset import Asset
|
||||
from app.models.marketplace.organization import Branch
|
||||
from sqlalchemy import or_
|
||||
|
||||
# P0 DIRECT OWNERSHIP: Query by both owner_org_id AND current_organization_id
|
||||
# A jármű akkor tartozik a garázshoz, ha:
|
||||
# - közvetlenül a tulajdonában van (owner_org_id), VAGY
|
||||
# - jelenleg oda van rendelve (current_organization_id)
|
||||
# NEM kell AssetAssignment JOIN — a direkt ownership mezők az Asset táblában vannak.
|
||||
ownership_filter = or_(
|
||||
Asset.owner_org_id == org_id,
|
||||
Asset.current_organization_id == org_id,
|
||||
)
|
||||
|
||||
# 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.
|
||||
vehicle_stmt = (
|
||||
select(Asset, Branch.name.label("branch_name"))
|
||||
.outerjoin(Branch, Asset.branch_id == Branch.id)
|
||||
.where(ownership_filter)
|
||||
.order_by(Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
vehicle_result = await db.execute(vehicle_stmt)
|
||||
rows = vehicle_result.all()
|
||||
|
||||
# Teljes számolás
|
||||
count_stmt = select(func.count()).select_from(
|
||||
select(Asset).where(ownership_filter).subquery()
|
||||
)
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 3. Válasz összeállítása
|
||||
vehicle_list = []
|
||||
for row in rows:
|
||||
v = row[0] # Asset object
|
||||
branch_name = row.branch_name # from the LEFT JOIN label
|
||||
vehicle_list.append(GarageVehicleItem(
|
||||
id=str(v.id),
|
||||
license_plate=v.license_plate,
|
||||
vin=v.vin,
|
||||
brand=v.brand,
|
||||
model=v.model,
|
||||
year=v.year_of_manufacture,
|
||||
status=v.status or "active",
|
||||
branch_name=branch_name,
|
||||
created_at=v.created_at.isoformat() if v.created_at else None,
|
||||
))
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_user.id} ({current_user.email}) fetched fleet for org {org_id} "
|
||||
f"({org.full_name}) — {len(vehicle_list)} vehicles"
|
||||
)
|
||||
|
||||
return GarageFleetResponse(
|
||||
total=total,
|
||||
vehicles=vehicle_list,
|
||||
)
|
||||
|
||||
@@ -133,6 +133,25 @@ class Organization(Base):
|
||||
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
|
||||
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
# ── P0 CRM: Kapcsolattartó (Contact Person) ──
|
||||
contact_person_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó neve")
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó e-mail címe")
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="Kapcsolattartó telefonszáma")
|
||||
|
||||
# ── P0 CRM: Számlázási cím (Billing Address) ──
|
||||
billing_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
billing_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
billing_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||
billing_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
billing_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# ── P0 CRM: Értesítési cím (Notification Address) ──
|
||||
notification_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
notification_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
notification_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||
notification_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
notification_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
org_type: Mapped[OrgType] = mapped_column(
|
||||
PG_ENUM(OrgType, name="orgtype", schema="fleet"),
|
||||
default=OrgType.individual
|
||||
|
||||
@@ -15,9 +15,17 @@ class OrganizationMemberBase(BaseModel):
|
||||
status: str = Field(default="active", description="Membership status (active, inactive, archived)")
|
||||
|
||||
|
||||
class OrganizationMemberCreate(OrganizationMemberBase):
|
||||
"""Schema for adding a new member to a garage."""
|
||||
pass
|
||||
class OrganizationMemberCreate(BaseModel):
|
||||
"""Schema for adding a new member to a garage.
|
||||
|
||||
Supports two modes:
|
||||
1. By user_id (direct): provide user_id
|
||||
2. By email (lookup): provide email — the system looks up the user by email
|
||||
"""
|
||||
user_id: Optional[int] = Field(default=None, description="The user ID to add as a member")
|
||||
email: Optional[str] = Field(default=None, description="Email of the user to add (alternative to user_id)")
|
||||
role: str = Field(default="MEMBER", description="Role within the organization. Valid values: OWNER, ADMIN, MANAGER, MEMBER, AGENT (PG_ENUM orguserrole)")
|
||||
status: str = Field(default="active", description="Membership status (active, inactive, archived)")
|
||||
|
||||
|
||||
class OrganizationMemberUpdate(BaseModel):
|
||||
|
||||
98
backend/scripts/audit_fleet_data.py
Normal file
98
backend/scripts/audit_fleet_data.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fleet Data Discovery Script
|
||||
|
||||
Queries the database to audit fleet vehicle data:
|
||||
- Total vehicles in vehicle.assets
|
||||
- Total asset assignments in fleet.asset_assignments
|
||||
- Organization IDs with existing assignments
|
||||
- Vehicles with current_organization_id set
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from sqlalchemy import select, func
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.vehicle.asset import Asset, AssetAssignment
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print(" FLEET DATA DISCOVERY AUDIT")
|
||||
print("=" * 60)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 1. Total vehicles in vehicle.assets
|
||||
result = await session.execute(select(func.count(Asset.id)))
|
||||
total_vehicles = result.scalar()
|
||||
print(f"\n Total vehicles (vehicle.assets): {total_vehicles}")
|
||||
|
||||
# 2. Vehicles with current_organization_id set
|
||||
result = await session.execute(
|
||||
select(func.count(Asset.id)).where(Asset.current_organization_id.isnot(None))
|
||||
)
|
||||
vehicles_with_org = result.scalar()
|
||||
print(f" Vehicles with current_organization_id: {vehicles_with_org}")
|
||||
|
||||
# 3. Total asset assignments in fleet.asset_assignments
|
||||
result = await session.execute(select(func.count(AssetAssignment.id)))
|
||||
total_assignments = result.scalar()
|
||||
print(f" Total asset assignments (fleet): {total_assignments}")
|
||||
|
||||
# 4. Distinct organization_ids in asset_assignments
|
||||
result = await session.execute(
|
||||
select(AssetAssignment.organization_id).distinct()
|
||||
)
|
||||
org_ids = [row[0] for row in result.fetchall()]
|
||||
print(f" Organizations with assignments: {len(org_ids)}")
|
||||
if org_ids:
|
||||
print(f" Organization IDs: {org_ids}")
|
||||
|
||||
# 5. Distinct current_organization_id in assets
|
||||
result = await session.execute(
|
||||
select(Asset.current_organization_id).distinct().where(
|
||||
Asset.current_organization_id.isnot(None)
|
||||
)
|
||||
)
|
||||
asset_org_ids = [row[0] for row in result.fetchall()]
|
||||
print(f" Organizations with vehicles (direct): {len(asset_org_ids)}")
|
||||
if asset_org_ids:
|
||||
print(f" Organization IDs: {asset_org_ids}")
|
||||
|
||||
# 6. Sample vehicles with current_organization_id
|
||||
result = await session.execute(
|
||||
select(Asset.id, Asset.license_plate, Asset.vin, Asset.current_organization_id)
|
||||
.where(Asset.current_organization_id.isnot(None))
|
||||
.limit(10)
|
||||
)
|
||||
samples = result.fetchall()
|
||||
if samples:
|
||||
print(f"\n Sample vehicles (up to 10):")
|
||||
print(f" {'ID':<38} {'Plate':<12} {'VIN':<18} {'OrgID':<6}")
|
||||
print(f" {'-'*38} {'-'*12} {'-'*18} {'-'*6}")
|
||||
for s in samples:
|
||||
plate = s.license_plate or "N/A"
|
||||
vin = (s.vin or "N/A")[:17]
|
||||
print(f" {str(s.id):<38} {plate:<12} {vin:<18} {str(s.current_organization_id or ''):<6}")
|
||||
else:
|
||||
print("\n No vehicles with current_organization_id found.")
|
||||
|
||||
# 7. Sample asset_assignments
|
||||
result = await session.execute(
|
||||
select(AssetAssignment.id, AssetAssignment.asset_id, AssetAssignment.organization_id, AssetAssignment.status)
|
||||
.limit(10)
|
||||
)
|
||||
assign_samples = result.fetchall()
|
||||
if assign_samples:
|
||||
print(f"\n Sample asset_assignments (up to 10):")
|
||||
print(f" {'ID':<38} {'AssetID':<38} {'OrgID':<6} {'Status':<10}")
|
||||
print(f" {'-'*38} {'-'*38} {'-'*6} {'-'*10}")
|
||||
for a in assign_samples:
|
||||
print(f" {str(a.id):<38} {str(a.asset_id):<38} {str(a.organization_id):<6} {a.status:<10}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" AUDIT COMPLETE")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
452
docs/p0_garage_anatomy_audit.md
Normal file
452
docs/p0_garage_anatomy_audit.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# 🏗️ P0 DISCOVERY: Complete Organization (Garage) Data Anatomy & Mapping
|
||||
|
||||
**Audit Date:** 2026-06-25
|
||||
**Scope:** Full-stack data anatomy of the `Organization` (Garage) entity
|
||||
**Layers Covered:** Database Schema → API Endpoints → Frontend Wiring
|
||||
**Report Version:** 1.0
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [Database Schema & Relationships](#2-database-schema--relationships)
|
||||
- 2.1 [The Address Question](#21-the-address-question-architects-hypothesis-verified)
|
||||
- 2.2 [Contact Person: Dual System](#22-contact-person-dual-system)
|
||||
- 2.3 [Relationships Map](#23-relationships-map)
|
||||
- 2.4 [Subscription Architecture](#24-subscription-architecture)
|
||||
- 2.5 [Digital Twin / Soft Delete](#25-digital-twin--soft-delete)
|
||||
3. [API Coverage Mapping](#3-api-coverage-mapping)
|
||||
- 3.1 [Complete Endpoint Table](#31-complete-endpoint-table)
|
||||
- 3.2 [Request/Response Schema Analysis](#32-requestresponse-schema-analysis)
|
||||
- 3.3 [Missing CRUD Operations](#33-missing-crud-operations)
|
||||
4. [Frontend Wiring Analysis](#4-frontend-wiring-analysis)
|
||||
- 4.1 [Garage List Page](#41-garage-list-page-frontend_adminpagesgaragesindexvue)
|
||||
- 4.2 [Garage Detail Page](#42-garage-detail-page-frontend_adminpagesgaragesidindexvue)
|
||||
- 4.3 [Edit Modal Wiring](#43-edit-modal-wiring)
|
||||
- 4.4 [Dead Ends & Gaps](#44-dead-ends--gaps)
|
||||
5. [Findings Summary](#5-findings-summary)
|
||||
6. [Recommendations](#6-recommendations)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This report presents a comprehensive three-layer audit of the **Organization (Garage)** entity across the Service Finder system. The audit covers the database schema ( [`backend/app/models/marketplace/organization.py:73`](backend/app/models/marketplace/organization.py:73) ), the admin API endpoints ( [`backend/app/api/v1/endpoints/admin_organizations.py:178`](backend/app/api/v1/endpoints/admin_organizations.py:178) ), and the frontend admin UI ( `/frontend_admin/pages/garages/` ).
|
||||
|
||||
**Key Findings:**
|
||||
- ✅ **Address Architecture**: Three address types (Primary, Billing, Notification) exist as **flat denormalized columns** on `fleet.organizations`. The Architect's suspicion was **partially correct** — there is NO 1-to-Many `addresses` table with `address_type` enum. Instead, each address type has its own column prefix ( `address_*` , `billing_*` , `notification_*` ). A normalized `system.addresses` table exists but is only linked via a single `address_id` FK (not used for billing/notification distinction).
|
||||
- ✅ **Contact Person**: A **dual system** exists — denormalized fields ( `contact_person_name` , `contact_email` , `contact_phone` ) on Organization for quick editing, AND a normalized `ContactPerson` model ( `fleet.contact_persons` → `identity.persons` ) with role/department/is_primary.
|
||||
- ⚠️ **Missing CRUD**: `OrganizationFinancials` , `OrganizationRelationship` (B2B), `OrganizationSalesAssignment` , and `OrgRole` dynamic permissions exist in the database but have **zero admin API endpoints or frontend UI**.
|
||||
- ⚠️ **Frontend Gap**: Billing and Notification `street_type` fields are missing from the frontend edit modal form, despite being present in the API schema.
|
||||
|
||||
---
|
||||
|
||||
## 2. Database Schema & Relationships
|
||||
|
||||
### 2.1 The Address Question (Architect's Hypothesis Verified)
|
||||
|
||||
**Source:** [`backend/app/models/marketplace/organization.py:73`](backend/app/models/marketplace/organization.py:73)
|
||||
|
||||
The database stores addresses as **three separate sets of flat denormalized columns** directly on the `fleet.organizations` table. This is NOT a normalized 1-to-Many `addresses` table with `address_type` enum.
|
||||
|
||||
#### Primary Address (lines 125-131)
|
||||
```python
|
||||
address_zip: Mapped[Optional[str]] # String(10)
|
||||
address_city: Mapped[Optional[str]] # String(100)
|
||||
address_street_name: Mapped[Optional[str]] # String(150)
|
||||
address_street_type: Mapped[Optional[str]] # String(50)
|
||||
address_house_number: Mapped[Optional[str]] # String(20)
|
||||
address_hrsz: Mapped[Optional[str]] # String(50) - helyrajzi szám
|
||||
plus_code: Mapped[Optional[str]] # String(20) - Plus code
|
||||
```
|
||||
|
||||
#### Billing Address (lines 142-146)
|
||||
```python
|
||||
billing_zip: Mapped[Optional[str]] # String(10)
|
||||
billing_city: Mapped[Optional[str]] # String(100)
|
||||
billing_street_name: Mapped[Optional[str]] # String(150)
|
||||
billing_street_type: Mapped[Optional[str]] # String(50)
|
||||
billing_house_number: Mapped[Optional[str]] # String(20)
|
||||
```
|
||||
|
||||
#### Notification Address (lines 149-153)
|
||||
```python
|
||||
notification_zip: Mapped[Optional[str]] # String(10)
|
||||
notification_city: Mapped[Optional[str]] # String(100)
|
||||
notification_street_name: Mapped[Optional[str]] # String(150)
|
||||
notification_street_type: Mapped[Optional[str]] # String(50)
|
||||
notification_house_number: Mapped[Optional[str]] # String(20)
|
||||
```
|
||||
|
||||
#### Normalized Address System (Legacy/Secondary)
|
||||
|
||||
**Source:** [`backend/app/models/identity/address.py:39`](backend/app/models/identity/address.py:39)
|
||||
|
||||
There IS a normalized `system.addresses` table with GPS coordinates, geo-postal-code lookup, and full address text. The Organization has a single FK to it:
|
||||
|
||||
```python
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id")
|
||||
)
|
||||
```
|
||||
|
||||
However, this `address_id` is **not used** for the billing/notification distinction. It appears to be a legacy holdover or a generalized address reference that the admin API does NOT populate or return.
|
||||
|
||||
**Conclusion:** The billing/notification address types are handled through column naming conventions, not through a normalized `address_type` enum. This works but is not extensible — adding a 4th address type (e.g., "postal", "legal") would require adding 5 more columns to the table.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Contact Person: Dual System
|
||||
|
||||
**Sources:**
|
||||
- [`backend/app/models/marketplace/organization.py:136`](backend/app/models/marketplace/organization.py:136)
|
||||
- [`backend/app/models/fleet/organization.py:95`](backend/app/models/fleet/organization.py:95)
|
||||
|
||||
#### System A: Denormalized (on Organization)
|
||||
```python
|
||||
contact_person_name: Mapped[Optional[str]] # String(255) - Kapcsolattartó neve
|
||||
contact_email: Mapped[Optional[str]] # String(255) - Kapcsolattartó e-mail címe
|
||||
contact_phone: Mapped[Optional[str]] # String(50) - Kapcsolattartó telefonszáma
|
||||
```
|
||||
|
||||
#### System B: Normalized (ContactPerson model)
|
||||
```python
|
||||
class ContactPerson(Base):
|
||||
__tablename__ = "contact_persons"
|
||||
__table_args__ = {"schema": "fleet"}
|
||||
|
||||
id: int # PK
|
||||
organization_id: int # FK → fleet.organizations.id
|
||||
person_id: int # FK → identity.persons.id (BigInteger)
|
||||
role: str # String(50) - "CEO", "Fleet Manager", etc.
|
||||
is_primary: bool # default=False
|
||||
department: Optional[str] # String(100)
|
||||
notes: Optional[str] # Text
|
||||
```
|
||||
|
||||
**Relationship:** `ContactPerson.person` → `identity.persons` (the actual person record with name, phone, email).
|
||||
|
||||
**How they interact:** The API loads both. The `GET /details` endpoint returns:
|
||||
1. The denormalized `contact_person_name`, `contact_email`, `contact_phone` directly from the Organization record
|
||||
2. A separate `primary_contact` object from the ContactPerson model (with role, department, person full_name, phone)
|
||||
|
||||
**Propagation logic** (in [`admin_organizations.py:1137`](backend/app/api/v1/endpoints/admin_organizations.py:1137)):
|
||||
For `individual` orgs, the PUT endpoint propagates `contact_email` → `email` (User) and `contact_phone` → `phone` (Person).
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Relationships Map
|
||||
|
||||
Below is the complete map of all relationships from the `Organization` entity, with their database schemas and connection types.
|
||||
|
||||
| Relationship | Schema.Table | FK Field | Type | Cascade | Admin API? |
|
||||
|---|---|---|---|---|---|
|
||||
| **assets** | `fleet.asset_assignments` | `organization_id` | `List[AssetAssignment]` | `all, delete-orphan` | ✅ GET vehicles |
|
||||
| **members** | `fleet.organization_members` | `organization_id` | `List[OrganizationMember]` | `all, delete-orphan` | ✅ CRUD |
|
||||
| **owner** | `identity.users` | `owner_id` | `Optional[User]` | — | ❌ Read-only via details |
|
||||
| **financials** | `fleet.organization_financials` | `organization_id` | `List[OrganizationFinancials]` | `all, delete-orphan` | ❌ None |
|
||||
| **service_profile** | `marketplace.service_profiles` | `organization_id` | `Optional[ServiceProfile]` | `SET NULL` | ❌ None |
|
||||
| **branches** | `fleet.branches` | `organization_id` | `List[Branch]` | `all, delete-orphan` | ❌ None |
|
||||
| **legal_owner** | `identity.persons` | `legal_owner_id` | `Optional[Person]` | — | ❌ Read-only via details |
|
||||
| **subscription_tier** | `system.subscription_tiers` | `subscription_tier_id` | `Optional[SubscriptionTier]` | — | ✅ Read via details, PATCH subscription |
|
||||
| **contact_persons** | `fleet.contact_persons` | `organization_id` | (via query) | — | ✅ Read-only via details |
|
||||
| **org_relationships** | `fleet.org_relationships` | `source_org_id` / `target_org_id` | (via query) | — | ❌ None |
|
||||
| **sales_assignments** | `fleet.org_sales_assignments` | `organization_id` | (via query) | — | ❌ None |
|
||||
|
||||
**Source:** [`backend/app/models/marketplace/organization.py:237`](backend/app/models/marketplace/organization.py:237)
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Subscription Architecture
|
||||
|
||||
**Source:** [`backend/app/models/marketplace/organization.py:168`](backend/app/models/marketplace/organization.py:168)
|
||||
|
||||
The subscription system has **two parallel data sources**:
|
||||
|
||||
1. **Denormalized fields** on Organization:
|
||||
- `subscription_plan` (String(30), default "FREE")
|
||||
- `subscription_expires_at` (Optional datetime)
|
||||
- `base_asset_limit` (Integer, default 1)
|
||||
- `purchased_extra_slots` (Integer, default 0)
|
||||
- `subscription_tier_id` (FK → `system.subscription_tiers.id`)
|
||||
|
||||
2. **Normalized** `OrganizationSubscription` table (queried separately in the details endpoint):
|
||||
- The API queries `OrganizationSubscription` with `is_active=True` FIRST
|
||||
- Falls back to `Organization.subscription_tier` if no active subscription found
|
||||
- Returns `SubscriptionSummary` with `tier_name`, `tier_level`, `valid_from`, `expires_at`, `is_active`, `asset_count`, `asset_limit`
|
||||
|
||||
**Feature capabilities** come from two merged sources:
|
||||
- `SubscriptionTier.feature_capabilities` (JSONB — tier defaults)
|
||||
- `Organization.custom_features` (JSONB — org-specific overrides)
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Digital Twin / Soft Delete
|
||||
|
||||
**Source:** [`backend/app/models/marketplace/organization.py:86`](backend/app/models/marketplace/organization.py:86)
|
||||
|
||||
The Organization implements a "Digital Twin" lifecycle pattern:
|
||||
|
||||
```python
|
||||
legal_owner_id: int # FK → identity.persons (immutable owner reference)
|
||||
first_registered_at: datetime # First registration (never changes)
|
||||
current_lifecycle_started_at: datetime # Current lifecycle start (resets on re-register)
|
||||
last_deactivated_at: datetime # Last deactivation
|
||||
lifecycle_index: int # Reincarnation counter (default 1)
|
||||
is_deleted: bool # Soft delete flag
|
||||
is_active: bool # Active flag
|
||||
```
|
||||
|
||||
This allows an organization to be "deleted" (re-registered under new name) while preserving vehicle/statistics history tied to the original Person.
|
||||
|
||||
---
|
||||
|
||||
## 3. API Coverage Mapping
|
||||
|
||||
### 3.1 Complete Endpoint Table
|
||||
|
||||
**Source:** [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py)
|
||||
|
||||
| # | Method | Path | Purpose | Read/Write | Status |
|
||||
|---|--------|------|---------|-----------|--------|
|
||||
| 1 | `GET` | `/admin/organizations` | List orgs with pagination, search, filters | READ | ✅ Wired |
|
||||
| 2 | `GET` | `/admin/organizations/{org_id}/details` | Full org details (all addresses, contacts, members, subscription) | READ | ✅ Wired |
|
||||
| 3 | `PUT` | `/admin/organizations/{org_id}` | Update org fields (all 3 address types, contacts) | WRITE | ✅ Wired |
|
||||
| 4 | `PUT` | `/admin/organizations/{org_id}/status` | Toggle status (active/inactive/suspended/pending_verification) | WRITE | ✅ Wired |
|
||||
| 5 | `PATCH` | `/admin/organizations/{org_id}/subscription` | Update subscription plan/expiry | WRITE | ✅ Wired |
|
||||
| 6 | `POST` | `/admin/organizations/{org_id}/members` | Add member by email | WRITE | ✅ Wired |
|
||||
| 7 | `PUT` | `/admin/organizations/{org_id}/members/{member_id}` | Update member role/status | WRITE | ✅ Wired |
|
||||
| 8 | `DELETE` | `/admin/organizations/{org_id}/members/{member_id}` | Remove member (soft delete) | WRITE | ✅ Wired |
|
||||
| 9 | `GET` | `/admin/organizations/{org_id}/vehicles` | List fleet vehicles | READ | ✅ Wired |
|
||||
|
||||
### 3.2 Request/Response Schema Analysis
|
||||
|
||||
#### `OrganizationUpdate` schema (PUT) — Line 623
|
||||
```python
|
||||
class OrganizationUpdate(BaseModel):
|
||||
# Basic fields
|
||||
name, full_name, display_name # Names
|
||||
tax_number, reg_number # Official registration
|
||||
email, phone # Org-level contact (propagated for individual)
|
||||
|
||||
# Primary Address (5 fields)
|
||||
address_zip, address_city, address_street_name, address_street_type, address_house_number
|
||||
|
||||
# Contact Person (3 fields)
|
||||
contact_person_name, contact_email, contact_phone
|
||||
|
||||
# Billing Address (5 fields)
|
||||
billing_zip, billing_city, billing_street_name, billing_street_type, billing_house_number
|
||||
|
||||
# Notification Address (5 fields)
|
||||
notification_zip, notification_city, notification_street_name, notification_street_type, notification_house_number
|
||||
```
|
||||
All fields are `Optional` — only provided fields are updated.
|
||||
|
||||
#### `GarageDetailsResponse` schema (GET /details) — Line 127
|
||||
Returns ALL the above fields **plus**:
|
||||
- `subscription: SubscriptionSummary` (tier_name, tier_level, valid_from, expires_at, is_active, asset_count, asset_limit)
|
||||
- `primary_contact: ContactPersonInfo` (id, full_name, role, department, phone, email, is_primary)
|
||||
- `members: List[OrganizationMemberResponse]` (with nested Person data)
|
||||
- `created_at`, `member_count`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Missing CRUD Operations
|
||||
|
||||
The following database entities have **NO admin API endpoints**:
|
||||
|
||||
| Entity | Table | Impact |
|
||||
|--------|-------|--------|
|
||||
| **OrganizationFinancials** | `fleet.organization_financials` | Cannot view/edit yearly financial records (revenue, expenses) |
|
||||
| **OrganizationRelationship** | `fleet.org_relationships` | Cannot manage B2B relationships (customer/supplier/partner) |
|
||||
| **OrganizationSalesAssignment** | `fleet.org_sales_assignments` | Cannot assign sales reps to organizations |
|
||||
| **OrgRole** (dynamic) | `fleet.org_roles` | Cannot create/edit custom roles with JSONB permissions |
|
||||
| **ContactPerson** (create/update) | `fleet.contact_persons` | Cannot add new ContactPerson records directly; only the denormalized fields are editable via PUT org |
|
||||
| **ServiceProfile** | `marketplace.service_profiles` | Cannot manage service provider profile from admin |
|
||||
| **Branch** | `fleet.branches` | Cannot create/edit physical locations/branches |
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Wiring Analysis
|
||||
|
||||
### 4.1 Garage List Page ( `frontend_admin/pages/garages/index.vue` )
|
||||
|
||||
**Source:** [`/frontend_admin/pages/garages/index.vue`](frontend_admin/pages/garages/index.vue)
|
||||
|
||||
**Wired APIs:**
|
||||
- ✅ `GET /admin/organizations` — Lists all orgs with search, filter, pagination
|
||||
- ✅ `PUT /admin/organizations/{id}/status` — Status toggle per row
|
||||
- ✅ `PATCH /admin/organizations/{id}/subscription` — Subscription update modal
|
||||
|
||||
**Displayed fields per garage card/row:**
|
||||
| Field | Source | Status |
|
||||
|-------|--------|--------|
|
||||
| ID | `org.id` | ✅ |
|
||||
| Company Name | `org.name` / `org.display_name` | ✅ |
|
||||
| City | `org.address_city` | ✅ |
|
||||
| Status | `org.status` | ✅ (with colored badge) |
|
||||
| Member Count | `org.member_count` | ✅ |
|
||||
| Subscription | `org.subscription_tier_name` | ✅ |
|
||||
| Expires | `org.subscription_expires_at` | ✅ |
|
||||
| Billing Address | — | ❌ Not shown |
|
||||
| Notification Address | — | ❌ Not shown |
|
||||
| Contact Person | — | ❌ Not shown |
|
||||
| Financial info | — | ❌ Not shown |
|
||||
|
||||
**Stats cards at top:**
|
||||
- Total garages count
|
||||
- Active count
|
||||
- Inactive count
|
||||
- Pending verification count
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Garage Detail Page ( `frontend_admin/pages/garages/[id]/index.vue` )
|
||||
|
||||
**Source:** [`/frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue)
|
||||
|
||||
**Wired API:** `GET /admin/organizations/{id}/details`
|
||||
|
||||
#### General Tab (lines 131-256)
|
||||
Displays:
|
||||
- ✅ **Company Info**: `full_name`, `display_name`, `org_type`, `tax_number`, `reg_number`, `created_at`
|
||||
- ✅ **Primary Address**: via `formattedAddress` computed (zip + city + street_name + street_type + house_number, filtered)
|
||||
- ✅ **Subscription Info**: tier_name, tier_level, expires_at, asset_limit
|
||||
- ✅ **Contact Person**: `primary_contact.full_name`, `primary_contact.role`, `primary_contact.department`, `primary_contact.phone`
|
||||
- ❌ **Billing Address**: NOT displayed in General tab
|
||||
- ❌ **Notification Address**: NOT displayed in General tab
|
||||
- ❌ **Financials**: NOT displayed (Dashboard tab is "Coming Soon")
|
||||
- ❌ **B2B Relationships**: NOT displayed
|
||||
- ❌ **Branches**: NOT displayed
|
||||
|
||||
#### Employees Tab (lines 262-370)
|
||||
- ✅ Full member table: name, email/phone, role badge, status, edit/remove actions
|
||||
- ✅ Add employee modal
|
||||
|
||||
#### Fleet Tab (lines 375-457)
|
||||
- ✅ Vehicle table: plate, brand, model, year, VIN, status
|
||||
- ✅ RBAC-guarded with `fleet:view`
|
||||
|
||||
#### Analytics Tab
|
||||
- ❌ "Coming soon" placeholder — not implemented
|
||||
|
||||
#### Missing Data Alert (lines 995-1022)
|
||||
The frontend has a **missingFields** computed that flags:
|
||||
- Missing tax_number (for business orgs)
|
||||
- Missing contact_email / contact_phone
|
||||
- Missing billing address (city + zip for business orgs)
|
||||
|
||||
This indicates the frontend already "knows" about the missing data problem but has no UI to collect it proactively.
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Edit Modal Wiring
|
||||
|
||||
**Source:** [`/frontend_admin/pages/garages/[id]/index.vue:478`](frontend_admin/pages/garages/[id]/index.vue:478)
|
||||
|
||||
The edit modal has **3 tabs** with the following field coverage:
|
||||
|
||||
#### Tab 1: Basic (lines 517-598)
|
||||
| Field | Form Key | API Field | Status |
|
||||
|-------|----------|-----------|--------|
|
||||
| Company Name | `full_name` | ✅ | ✅ |
|
||||
| Display Name | `display_name` | ✅ | ✅ |
|
||||
| Tax Number | `tax_number` | ✅ | ✅ |
|
||||
| Reg Number | `reg_number` | ✅ | ✅ |
|
||||
| ZIP | `address_zip` | ✅ | ✅ |
|
||||
| City | `address_city` | ✅ | ✅ |
|
||||
| Street Name | `address_street_name` | ✅ | ✅ |
|
||||
| Street Type | `address_street_type` | ✅ | ✅ |
|
||||
| House Number | `address_house_number` | ✅ | ✅ |
|
||||
|
||||
#### Tab 2: Contact (lines 601-628)
|
||||
| Field | Form Key | API Field | Status |
|
||||
|-------|----------|-----------|--------|
|
||||
| Contact Name | `contact_person_name` | ✅ | ✅ |
|
||||
| Contact Email | `contact_email` | ✅ | ✅ |
|
||||
| Contact Phone | `contact_phone` | ✅ | ✅ |
|
||||
|
||||
#### Tab 3: Addresses (lines 631-713)
|
||||
**Billing Address:**
|
||||
| Field | Form Key | API Field | Status |
|
||||
|-------|----------|-----------|--------|
|
||||
| ZIP | `billing_zip` | ✅ | ✅ |
|
||||
| City | `billing_city` | ✅ | ✅ |
|
||||
| Street Name | `billing_street_name` | ✅ | ✅ |
|
||||
| Street Type | `billing_street_type` | ❌ **MISSING** | ⚠️ |
|
||||
| House Number | `billing_house_number` | ✅ | ✅ |
|
||||
|
||||
**Notification Address:**
|
||||
| Field | Form Key | API Field | Status |
|
||||
|-------|----------|-----------|--------|
|
||||
| ZIP | `notification_zip` | ✅ | ✅ |
|
||||
| City | `notification_city` | ✅ | ✅ |
|
||||
| Street Name | `notification_street_name` | ✅ | ✅ |
|
||||
| Street Type | `notification_street_type` | ❌ **MISSING** | ⚠️ |
|
||||
| House Number | `notification_house_number` | ✅ | ✅ |
|
||||
|
||||
> ⚠️ **Bug**: `billing_street_type` and `notification_street_type` are present in the editForm (lines 943, 948) but have **no corresponding input fields** in the template. The API schema supports them, but the user cannot edit them through the UI.
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Dead Ends & Gaps
|
||||
|
||||
| # | Gap | Layer | Severity |
|
||||
|---|-----|-------|----------|
|
||||
| 1 | **Billing/Notification street_type missing from UI** | Frontend | Low (data exists in DB & API) |
|
||||
| 2 | **OrganizationFinancials - no CRUD anywhere** | API + Frontend | Medium (financial data invisible) |
|
||||
| 3 | **OrganizationRelationship - no CRUD anywhere** | API + Frontend | Medium (B2B not manageable) |
|
||||
| 4 | **OrganizationSalesAssignment - no CRUD anywhere** | API + Frontend | Low (sales pipeline not in admin) |
|
||||
| 5 | **OrgRole dynamic permissions - no admin UI** | API + Frontend | Low (RBAC Phase 2 not deployed) |
|
||||
| 6 | **ContactPerson create/update - no dedicated endpoint** | API | Low (denormalized fields cover basic needs) |
|
||||
| 7 | **ServiceProfile - no admin management** | API + Frontend | Medium (service providers invisible) |
|
||||
| 8 | **Branch - no admin management** | API + Frontend | Low (branches may not need admin UI) |
|
||||
| 9 | **`address_id` (system.addresses FK) not populated** | API | Info (legacy field, not used) |
|
||||
| 10 | **Analytics tab "Coming Soon"** | Frontend | Medium (TCO/KM analytics not implemented) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Findings Summary
|
||||
|
||||
### ✅ What Works Well
|
||||
1. **All 3 address types** (Primary, Billing, Notification) are properly modeled in DB, fully exposed via API, and wired in the frontend edit modal — with the minor exception of street_type fields.
|
||||
2. **Contact Person dual system** is well-designed: fast denormalized fields for quick edits + normalized ContactPerson with full Person integration.
|
||||
3. **Subscription system** has proper fallback logic (active subscription → org tier → defaults) and is fully wired.
|
||||
4. **Member management** has full CRUD across all layers.
|
||||
5. **Fleet/vehicle assignment** is properly wired with RBAC protection.
|
||||
6. **Digital Twin lifecycle** fields are comprehensive and well-documented.
|
||||
7. **Missing data alert** in the frontend proactively identifies incomplete profiles.
|
||||
|
||||
### ⚠️ What Needs Attention
|
||||
1. **Missing street_type inputs** in the Addresses tab of the edit modal — minor template fix needed.
|
||||
2. **No dedicated ContactPerson CRUD** — the normalized contact system can't be managed independently.
|
||||
3. **Financials are invisible** — `OrganizationFinancials` is orphaned at the API layer.
|
||||
4. **B2B relationships** — `OrganizationRelationship` model exists but has no management interface.
|
||||
5. **Analytics tab** is a placeholder — no TCO/KM or financial analytics available.
|
||||
6. **`address_id` (system.addresses)** — the normalized address FK is not populated by any admin endpoint, indicating either a legacy field or planned future use.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommendations
|
||||
|
||||
### Priority 1 (Quick Wins)
|
||||
- **Fix missing street_type inputs** in the Addresses tab of the edit modal (add `billing_street_type` and `notification_street_type` template fields)
|
||||
- **Add `address_id` population** to the PUT org endpoint if the normalized address system is intended for use
|
||||
|
||||
### Priority 2 (Medium Term)
|
||||
- **Build OrganizationFinancials admin API** — at minimum a read-only endpoint and basic frontend display
|
||||
- **Build OrganizationRelationship admin API** — enable B2B relationship management
|
||||
- **Add ContactPerson create/update endpoint** — allow managing normalized contacts independently
|
||||
- **Add ServiceProfile admin view** — see which orgs are service providers
|
||||
|
||||
### Priority 3 (Long Term)
|
||||
- **Implement Analytics tab** — TCO/KM, financial trends, fleet utilization
|
||||
- **Branch management UI** — create/edit physical locations
|
||||
- **OrgRole dynamic permissions admin** — custom role builder UI
|
||||
- **OrganizationSalesAssignment admin** — sales pipeline management
|
||||
|
||||
---
|
||||
|
||||
*End of Report — Full-stack Organization (Garage) Data Anatomy & Mapping*
|
||||
@@ -2351,11 +2351,13 @@ var garages$1 = {
|
||||
title: "Garázsok CRM",
|
||||
subtitle: "Garázsok listája, csomagkezelés és státuszmódosítás",
|
||||
total_garages: "Összes Garázs",
|
||||
active_garages: "Aktív Garázsok",
|
||||
premium_garages: "Premium Garázsok",
|
||||
enterprise_garages: "Enterprise Garázsok",
|
||||
private_garages: "Privát Garázsok",
|
||||
corporate_garages: "Céges Garázsok",
|
||||
search_placeholder: "Keresés név, kapcsolattartó vagy e-mail alapján...",
|
||||
all_tiers: "Összes csomag",
|
||||
all_types: "Összes típus",
|
||||
type_individual: "Privát (Egyéni)",
|
||||
type_corporate: "Céges (Vállalati)",
|
||||
company_name: "Cégnév",
|
||||
email: "E-mail",
|
||||
status: "Státusz",
|
||||
@@ -2411,9 +2413,51 @@ var garages$1 = {
|
||||
contact_name: "Kapcsolattartó neve",
|
||||
contact_role: "Szerepkör",
|
||||
contact_department: "Osztály",
|
||||
contact_phone: "Telefonszám",
|
||||
contact_phone: "Kapcsolattartó telefon",
|
||||
no_contact: "Nincs elsődleges kapcsolattartó",
|
||||
coming_soon: "Ez a funkció hamarosan elérhető lesz..."
|
||||
edit_details: "Adatok Szerkesztése",
|
||||
save: "Mentés",
|
||||
generalTab: "Általános",
|
||||
employeesTab: "Dolgozók",
|
||||
fleetTab: "Flotta",
|
||||
analyticsTab: "Analitika",
|
||||
editBasicTab: "Alapadatok",
|
||||
editContactTab: "Kapcsolat",
|
||||
editAddressTab: "Címek",
|
||||
zip: "Irányítószám",
|
||||
city: "Város",
|
||||
street_name: "Utca",
|
||||
street_type: "Utca típusa",
|
||||
house_number: "Házszám",
|
||||
coming_soon: "Ez a funkció hamarosan elérhető lesz...",
|
||||
missing_data_title: "Hiányzó adatok",
|
||||
missing_data_desc: "A garázs profilja hiányos. Kérjük, egészítse ki a hiányzó adatokat.",
|
||||
missing_data_activation_required: "A garázs aktiválásához az alábbi adatok pótlása kötelező:",
|
||||
missing_tax_number: "Adószám",
|
||||
missing_billing: "Számlázási cím",
|
||||
missing_contact_email: "Kapcsolattartó e-mail",
|
||||
missing_contact_phone: "Kapcsolattartó telefon",
|
||||
missing_data_count: "{count} mező hiányzik",
|
||||
contact_person_name: "Kapcsolattartó neve",
|
||||
contact_email: "Kapcsolattartó e-mail",
|
||||
billing_address: "Számlázási cím",
|
||||
billing_zip: "Irányítószám",
|
||||
billing_city: "Város",
|
||||
billing_street_name: "Utca",
|
||||
billing_street_type: "Utca típusa",
|
||||
billing_house_number: "Házszám",
|
||||
notification_address: "Értesítési cím",
|
||||
notification_zip: "Irányítószám",
|
||||
notification_city: "Város",
|
||||
notification_street_name: "Utca",
|
||||
notification_street_type: "Utca típusa",
|
||||
notification_house_number: "Házszám",
|
||||
tab_basic: "Alapadatok",
|
||||
tab_contact: "Kapcsolat",
|
||||
tab_addresses: "Címek",
|
||||
basic_info: "Alapadatok",
|
||||
contact_section: "Kapcsolattartó",
|
||||
addresses_section: "Címek"
|
||||
},
|
||||
employees: {
|
||||
title: "Dolgozók",
|
||||
@@ -2427,13 +2471,43 @@ var garages$1 = {
|
||||
remove: "Eltávolítás",
|
||||
no_employees: "Még nincsenek tagok ehhez a garázshoz.",
|
||||
edit_role_title: "Tag szerepkörének szerkesztése",
|
||||
editRoleTitle: "Tag szerepkörének szerkesztése",
|
||||
editRoleFor: "{name} szerepkörének módosítása",
|
||||
cancel: "Mégse",
|
||||
save: "Mentés",
|
||||
saveRole: "Szerepkör mentése",
|
||||
saving: "Mentés...",
|
||||
status_active: "Aktív",
|
||||
status_inactive: "Inaktív",
|
||||
remove_title: "Tag eltávolítása",
|
||||
removeTitle: "Tag eltávolítása",
|
||||
remove_confirm: "Biztosan eltávolítod {name} tagot a garázsból?",
|
||||
remove_btn: "Eltávolítás"
|
||||
removeConfirm: "Biztosan eltávolítod {name} tagot a garázsból?",
|
||||
remove_btn: "Eltávolítás",
|
||||
removing: "Eltávolítás...",
|
||||
add_employee: "Új dolgozó hozzáadása",
|
||||
addTitle: "Új dolgozó hozzáadása",
|
||||
email: "E-mail cím",
|
||||
emailPlaceholder: "Add meg az e-mail címet...",
|
||||
roleMember: "Tag",
|
||||
roleManager: "Menedzser",
|
||||
roleAdmin: "Admin",
|
||||
roleOwner: "Tulajdonos",
|
||||
add: "Hozzáadás",
|
||||
adding: "Hozzáadás..."
|
||||
},
|
||||
fleet: {
|
||||
title: "Flotta",
|
||||
total_vehicles: "Összesen {count} jármű",
|
||||
plate: "Rendszám",
|
||||
brand: "Márka",
|
||||
model: "Modell",
|
||||
brand_model: "Márka & Modell",
|
||||
year: "Évjárat",
|
||||
status: "Státusz",
|
||||
vin: "Alvázszám",
|
||||
no_vehicles: "Ehhez a garázshoz még nincsenek járművek rendelve.",
|
||||
no_fleet_access: "Nincs jogosultságod a flotta adatok megtekintéséhez."
|
||||
}
|
||||
};
|
||||
const locale_hu_46json_ee06c965 = {
|
||||
@@ -2541,11 +2615,13 @@ var garages = {
|
||||
title: "Garages CRM",
|
||||
subtitle: "Garage list, package management and status changes",
|
||||
total_garages: "Total Garages",
|
||||
active_garages: "Active Garages",
|
||||
premium_garages: "Premium Garages",
|
||||
enterprise_garages: "Enterprise Garages",
|
||||
private_garages: "Private Garages",
|
||||
corporate_garages: "Corporate Garages",
|
||||
search_placeholder: "Search by name, contact or email...",
|
||||
all_tiers: "All packages",
|
||||
all_types: "All types",
|
||||
type_individual: "Private (Individual)",
|
||||
type_corporate: "Corporate",
|
||||
company_name: "Company Name",
|
||||
email: "Email",
|
||||
status: "Status",
|
||||
@@ -2601,9 +2677,51 @@ var garages = {
|
||||
contact_name: "Contact Name",
|
||||
contact_role: "Role",
|
||||
contact_department: "Department",
|
||||
contact_phone: "Phone",
|
||||
contact_phone: "Contact Phone",
|
||||
no_contact: "No primary contact person",
|
||||
coming_soon: "This feature will be available soon..."
|
||||
edit_details: "Edit Details",
|
||||
save: "Save",
|
||||
generalTab: "General",
|
||||
employeesTab: "Employees",
|
||||
fleetTab: "Fleet",
|
||||
analyticsTab: "Analytics",
|
||||
editBasicTab: "Basic Info",
|
||||
editContactTab: "Contact",
|
||||
editAddressTab: "Addresses",
|
||||
zip: "ZIP Code",
|
||||
city: "City",
|
||||
street_name: "Street",
|
||||
street_type: "Street Type",
|
||||
house_number: "House Number",
|
||||
coming_soon: "This feature will be available soon...",
|
||||
missing_data_title: "Missing Data",
|
||||
missing_data_desc: "The garage profile is incomplete. Please fill in the missing fields.",
|
||||
missing_data_activation_required: "To activate the garage, the following data must be provided:",
|
||||
missing_tax_number: "Tax Number",
|
||||
missing_billing: "Billing Address",
|
||||
missing_contact_email: "Contact Email",
|
||||
missing_contact_phone: "Contact Phone",
|
||||
missing_data_count: "{count} fields missing",
|
||||
contact_person_name: "Contact Person Name",
|
||||
contact_email: "Contact Email",
|
||||
billing_address: "Billing Address",
|
||||
billing_zip: "ZIP Code",
|
||||
billing_city: "City",
|
||||
billing_street_name: "Street",
|
||||
billing_street_type: "Street Type",
|
||||
billing_house_number: "House Number",
|
||||
notification_address: "Notification Address",
|
||||
notification_zip: "ZIP Code",
|
||||
notification_city: "City",
|
||||
notification_street_name: "Street",
|
||||
notification_street_type: "Street Type",
|
||||
notification_house_number: "House Number",
|
||||
tab_basic: "Basic Info",
|
||||
tab_contact: "Contact",
|
||||
tab_addresses: "Addresses",
|
||||
basic_info: "Basic Info",
|
||||
contact_section: "Contact Person",
|
||||
addresses_section: "Addresses"
|
||||
},
|
||||
employees: {
|
||||
title: "Employees",
|
||||
@@ -2617,13 +2735,43 @@ var garages = {
|
||||
remove: "Remove",
|
||||
no_employees: "No employees assigned to this garage.",
|
||||
edit_role_title: "Edit Employee Role",
|
||||
editRoleTitle: "Edit Employee Role",
|
||||
editRoleFor: "Change role for {name}",
|
||||
cancel: "Cancel",
|
||||
save: "Save Changes",
|
||||
saveRole: "Save Role",
|
||||
saving: "Saving...",
|
||||
status_active: "Active",
|
||||
status_inactive: "Inactive",
|
||||
remove_title: "Remove Employee",
|
||||
removeTitle: "Remove Employee",
|
||||
remove_confirm: "Are you sure you want to remove \"{name}\" from this garage?",
|
||||
remove_btn: "Remove"
|
||||
removeConfirm: "Are you sure you want to remove \"{name}\" from this garage?",
|
||||
remove_btn: "Remove",
|
||||
removing: "Removing...",
|
||||
add_employee: "Add Employee",
|
||||
addTitle: "Add New Employee",
|
||||
email: "Email Address",
|
||||
emailPlaceholder: "Enter email address...",
|
||||
roleMember: "Member",
|
||||
roleManager: "Manager",
|
||||
roleAdmin: "Admin",
|
||||
roleOwner: "Owner",
|
||||
add: "Add Employee",
|
||||
adding: "Adding..."
|
||||
},
|
||||
fleet: {
|
||||
title: "Fleet",
|
||||
total_vehicles: "{count} vehicles total",
|
||||
plate: "Plate",
|
||||
brand: "Brand",
|
||||
model: "Model",
|
||||
brand_model: "Brand & Model",
|
||||
year: "Year",
|
||||
status: "Status",
|
||||
vin: "VIN",
|
||||
no_vehicles: "No vehicles assigned to this garage yet.",
|
||||
no_fleet_access: "You don't have permission to view fleet data."
|
||||
}
|
||||
};
|
||||
const locale_en_46json_0b63539c = {
|
||||
@@ -3278,16 +3426,16 @@ _wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
|
||||
const assets = {
|
||||
"/index.mjs": {
|
||||
"type": "text/javascript; charset=utf-8",
|
||||
"etag": "\"23532-gRN4chechZSOn9PdQdfcKj3v+Ek\"",
|
||||
"mtime": "2026-06-25T19:56:11.463Z",
|
||||
"size": 144690,
|
||||
"etag": "\"24b7a-X7Gi3Oi5IkHbqD0x6I8saqEhvJo\"",
|
||||
"mtime": "2026-06-26T00:29:32.171Z",
|
||||
"size": 150394,
|
||||
"path": "index.mjs"
|
||||
},
|
||||
"/index.mjs.map": {
|
||||
"type": "application/json",
|
||||
"etag": "\"83172-ssEX0StaUacfa43NRfqyNY4W7FM\"",
|
||||
"mtime": "2026-06-25T19:56:11.464Z",
|
||||
"size": 536946,
|
||||
"etag": "\"83219-k/zre5TDT2FcQBXF7JC+SUbYHHs\"",
|
||||
"mtime": "2026-06-26T00:29:32.171Z",
|
||||
"size": 537113,
|
||||
"path": "index.mjs.map"
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"date": "2026-06-25T19:56:47.228Z",
|
||||
"date": "2026-06-26T00:29:33.690Z",
|
||||
"preset": "nitro-dev",
|
||||
"framework": {
|
||||
"name": "nuxt",
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev": {
|
||||
"pid": 19,
|
||||
"workerAddress": {
|
||||
"socketPath": "\u0000nitro-worker-19-19-19-6745.sock"
|
||||
"socketPath": "\u0000nitro-worker-19-37-37-3295.sock"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 6/25/2026, 7:56:46 PM
|
||||
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 6/26/2026, 1:03:38 AM
|
||||
import "@nuxtjs/tailwindcss/config-ctx"
|
||||
import configMerger from "@nuxtjs/tailwindcss/merger";
|
||||
|
||||
|
||||
1
frontend_admin/.nuxt/types/plugins.d.ts
vendored
1
frontend_admin/.nuxt/types/plugins.d.ts
vendored
@@ -29,6 +29,7 @@ type NuxtAppInjections =
|
||||
InjectionType<typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/plugins/i18n.js")> &
|
||||
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/warn.dev.server.js")> &
|
||||
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/check-if-layout-used.js")> &
|
||||
InjectionType<typeof import("../../plugins/api")> &
|
||||
InjectionType<typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/plugins/ssg-detect.js")>
|
||||
|
||||
declare module '#app' {
|
||||
|
||||
@@ -98,11 +98,13 @@
|
||||
"title": "Garages CRM",
|
||||
"subtitle": "Garage list, package management and status changes",
|
||||
"total_garages": "Total Garages",
|
||||
"active_garages": "Active Garages",
|
||||
"premium_garages": "Premium Garages",
|
||||
"enterprise_garages": "Enterprise Garages",
|
||||
"private_garages": "Private Garages",
|
||||
"corporate_garages": "Corporate Garages",
|
||||
"search_placeholder": "Search by name, contact or email...",
|
||||
"all_tiers": "All packages",
|
||||
"all_types": "All types",
|
||||
"type_individual": "Private (Individual)",
|
||||
"type_corporate": "Corporate",
|
||||
"company_name": "Company Name",
|
||||
"email": "Email",
|
||||
"status": "Status",
|
||||
@@ -160,7 +162,50 @@
|
||||
"contact_department": "Department",
|
||||
"contact_phone": "Phone",
|
||||
"no_contact": "No primary contact person",
|
||||
"coming_soon": "This feature will be available soon..."
|
||||
"edit_details": "Edit Details",
|
||||
"save": "Save",
|
||||
"generalTab": "General",
|
||||
"employeesTab": "Employees",
|
||||
"fleetTab": "Fleet",
|
||||
"analyticsTab": "Analytics",
|
||||
"editBasicTab": "Basic Info",
|
||||
"editContactTab": "Contact",
|
||||
"editAddressTab": "Addresses",
|
||||
"zip": "ZIP Code",
|
||||
"city": "City",
|
||||
"street_name": "Street",
|
||||
"street_type": "Street Type",
|
||||
"house_number": "House Number",
|
||||
"coming_soon": "This feature will be available soon...",
|
||||
"missing_data_title": "Missing Data",
|
||||
"missing_data_desc": "The garage profile is incomplete. Please fill in the missing fields.",
|
||||
"missing_data_activation_required": "To activate the garage, the following data must be provided:",
|
||||
"missing_tax_number": "Tax Number",
|
||||
"missing_billing": "Billing Address",
|
||||
"missing_contact_email": "Contact Email",
|
||||
"missing_contact_phone": "Contact Phone",
|
||||
"missing_data_count": "{count} fields missing",
|
||||
"contact_person_name": "Contact Person Name",
|
||||
"contact_email": "Contact Email",
|
||||
"contact_phone": "Contact Phone",
|
||||
"billing_address": "Billing Address",
|
||||
"billing_zip": "ZIP Code",
|
||||
"billing_city": "City",
|
||||
"billing_street_name": "Street",
|
||||
"billing_street_type": "Street Type",
|
||||
"billing_house_number": "House Number",
|
||||
"notification_address": "Notification Address",
|
||||
"notification_zip": "ZIP Code",
|
||||
"notification_city": "City",
|
||||
"notification_street_name": "Street",
|
||||
"notification_street_type": "Street Type",
|
||||
"notification_house_number": "House Number",
|
||||
"tab_basic": "Basic Info",
|
||||
"tab_contact": "Contact",
|
||||
"tab_addresses": "Addresses",
|
||||
"basic_info": "Basic Info",
|
||||
"contact_section": "Contact Person",
|
||||
"addresses_section": "Addresses"
|
||||
},
|
||||
"employees": {
|
||||
"title": "Employees",
|
||||
@@ -174,13 +219,43 @@
|
||||
"remove": "Remove",
|
||||
"no_employees": "No employees assigned to this garage.",
|
||||
"edit_role_title": "Edit Employee Role",
|
||||
"editRoleTitle": "Edit Employee Role",
|
||||
"editRoleFor": "Change role for {name}",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Changes",
|
||||
"saveRole": "Save Role",
|
||||
"saving": "Saving...",
|
||||
"status_active": "Active",
|
||||
"status_inactive": "Inactive",
|
||||
"remove_title": "Remove Employee",
|
||||
"removeTitle": "Remove Employee",
|
||||
"remove_confirm": "Are you sure you want to remove \"{name}\" from this garage?",
|
||||
"remove_btn": "Remove"
|
||||
"removeConfirm": "Are you sure you want to remove \"{name}\" from this garage?",
|
||||
"remove_btn": "Remove",
|
||||
"removing": "Removing...",
|
||||
"add_employee": "Add Employee",
|
||||
"addTitle": "Add New Employee",
|
||||
"email": "Email Address",
|
||||
"emailPlaceholder": "Enter email address...",
|
||||
"roleMember": "Member",
|
||||
"roleManager": "Manager",
|
||||
"roleAdmin": "Admin",
|
||||
"roleOwner": "Owner",
|
||||
"add": "Add Employee",
|
||||
"adding": "Adding..."
|
||||
},
|
||||
"fleet": {
|
||||
"title": "Fleet",
|
||||
"total_vehicles": "{count} vehicles total",
|
||||
"plate": "Plate",
|
||||
"brand": "Brand",
|
||||
"model": "Model",
|
||||
"brand_model": "Brand & Model",
|
||||
"year": "Year",
|
||||
"status": "Status",
|
||||
"vin": "VIN",
|
||||
"no_vehicles": "No vehicles assigned to this garage yet.",
|
||||
"no_fleet_access": "You don't have permission to view fleet data."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +98,13 @@
|
||||
"title": "Garázsok CRM",
|
||||
"subtitle": "Garázsok listája, csomagkezelés és státuszmódosítás",
|
||||
"total_garages": "Összes Garázs",
|
||||
"active_garages": "Aktív Garázsok",
|
||||
"premium_garages": "Premium Garázsok",
|
||||
"enterprise_garages": "Enterprise Garázsok",
|
||||
"private_garages": "Privát Garázsok",
|
||||
"corporate_garages": "Céges Garázsok",
|
||||
"search_placeholder": "Keresés név, kapcsolattartó vagy e-mail alapján...",
|
||||
"all_tiers": "Összes csomag",
|
||||
"all_types": "Összes típus",
|
||||
"type_individual": "Privát (Egyéni)",
|
||||
"type_corporate": "Céges (Vállalati)",
|
||||
"company_name": "Cégnév",
|
||||
"email": "E-mail",
|
||||
"status": "Státusz",
|
||||
@@ -160,7 +162,50 @@
|
||||
"contact_department": "Osztály",
|
||||
"contact_phone": "Telefonszám",
|
||||
"no_contact": "Nincs elsődleges kapcsolattartó",
|
||||
"coming_soon": "Ez a funkció hamarosan elérhető lesz..."
|
||||
"edit_details": "Adatok Szerkesztése",
|
||||
"save": "Mentés",
|
||||
"generalTab": "Általános",
|
||||
"employeesTab": "Dolgozók",
|
||||
"fleetTab": "Flotta",
|
||||
"analyticsTab": "Analitika",
|
||||
"editBasicTab": "Alapadatok",
|
||||
"editContactTab": "Kapcsolat",
|
||||
"editAddressTab": "Címek",
|
||||
"zip": "Irányítószám",
|
||||
"city": "Város",
|
||||
"street_name": "Utca",
|
||||
"street_type": "Utca típusa",
|
||||
"house_number": "Házszám",
|
||||
"coming_soon": "Ez a funkció hamarosan elérhető lesz...",
|
||||
"missing_data_title": "Hiányzó adatok",
|
||||
"missing_data_desc": "A garázs profilja hiányos. Kérjük, egészítse ki a hiányzó adatokat.",
|
||||
"missing_data_activation_required": "A garázs aktiválásához az alábbi adatok pótlása kötelező:",
|
||||
"missing_tax_number": "Adószám",
|
||||
"missing_billing": "Számlázási cím",
|
||||
"missing_contact_email": "Kapcsolattartó e-mail",
|
||||
"missing_contact_phone": "Kapcsolattartó telefon",
|
||||
"missing_data_count": "{count} mező hiányzik",
|
||||
"contact_person_name": "Kapcsolattartó neve",
|
||||
"contact_email": "Kapcsolattartó e-mail",
|
||||
"contact_phone": "Kapcsolattartó telefon",
|
||||
"billing_address": "Számlázási cím",
|
||||
"billing_zip": "Irányítószám",
|
||||
"billing_city": "Város",
|
||||
"billing_street_name": "Utca",
|
||||
"billing_street_type": "Utca típusa",
|
||||
"billing_house_number": "Házszám",
|
||||
"notification_address": "Értesítési cím",
|
||||
"notification_zip": "Irányítószám",
|
||||
"notification_city": "Város",
|
||||
"notification_street_name": "Utca",
|
||||
"notification_street_type": "Utca típusa",
|
||||
"notification_house_number": "Házszám",
|
||||
"tab_basic": "Alapadatok",
|
||||
"tab_contact": "Kapcsolat",
|
||||
"tab_addresses": "Címek",
|
||||
"basic_info": "Alapadatok",
|
||||
"contact_section": "Kapcsolattartó",
|
||||
"addresses_section": "Címek"
|
||||
},
|
||||
"employees": {
|
||||
"title": "Dolgozók",
|
||||
@@ -174,13 +219,43 @@
|
||||
"remove": "Eltávolítás",
|
||||
"no_employees": "Még nincsenek tagok ehhez a garázshoz.",
|
||||
"edit_role_title": "Tag szerepkörének szerkesztése",
|
||||
"editRoleTitle": "Tag szerepkörének szerkesztése",
|
||||
"editRoleFor": "{name} szerepkörének módosítása",
|
||||
"cancel": "Mégse",
|
||||
"save": "Mentés",
|
||||
"saveRole": "Szerepkör mentése",
|
||||
"saving": "Mentés...",
|
||||
"status_active": "Aktív",
|
||||
"status_inactive": "Inaktív",
|
||||
"remove_title": "Tag eltávolítása",
|
||||
"removeTitle": "Tag eltávolítása",
|
||||
"remove_confirm": "Biztosan eltávolítod {name} tagot a garázsból?",
|
||||
"remove_btn": "Eltávolítás"
|
||||
"removeConfirm": "Biztosan eltávolítod {name} tagot a garázsból?",
|
||||
"remove_btn": "Eltávolítás",
|
||||
"removing": "Eltávolítás...",
|
||||
"add_employee": "Új dolgozó hozzáadása",
|
||||
"addTitle": "Új dolgozó hozzáadása",
|
||||
"email": "E-mail cím",
|
||||
"emailPlaceholder": "Add meg az e-mail címet...",
|
||||
"roleMember": "Tag",
|
||||
"roleManager": "Menedzser",
|
||||
"roleAdmin": "Admin",
|
||||
"roleOwner": "Tulajdonos",
|
||||
"add": "Hozzáadás",
|
||||
"adding": "Hozzáadás..."
|
||||
},
|
||||
"fleet": {
|
||||
"title": "Flotta",
|
||||
"total_vehicles": "Összesen {count} jármű",
|
||||
"plate": "Rendszám",
|
||||
"brand": "Márka",
|
||||
"model": "Modell",
|
||||
"brand_model": "Márka & Modell",
|
||||
"year": "Évjárat",
|
||||
"status": "Státusz",
|
||||
"vin": "Alvázszám",
|
||||
"no_vehicles": "Ehhez a garázshoz még nincsenek járművek rendelve.",
|
||||
"no_fleet_access": "Nincs jogosultságod a flotta adatok megtekintéséhez."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ const menuGroups: MenuGroup[] = [
|
||||
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 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: [
|
||||
{
|
||||
path: '/users',
|
||||
path: '/', /* P0 HOTFIX: placeholder until /users page is built */
|
||||
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>',
|
||||
},
|
||||
@@ -371,7 +371,7 @@ const menuGroups: MenuGroup[] = [
|
||||
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
|
||||
children: [
|
||||
{
|
||||
path: '/logs',
|
||||
path: '/', /* P0 HOTFIX: placeholder until /logs page is built */
|
||||
label: 'Rendszernaplók',
|
||||
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,43 @@
|
||||
<h1 class="text-2xl font-bold text-white">{{ $t('garages.title') }}</h1>
|
||||
<p class="text-slate-400 mt-1">{{ $t('garages.subtitle') }}</p>
|
||||
</div>
|
||||
<!-- Search & Filter Bar (always rendered — stable DOM for input focus) -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('garages.search_placeholder')"
|
||||
class="w-full pl-10 pr-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="filterType"
|
||||
class="px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
|
||||
>
|
||||
<option value="all">{{ $t('garages.all_types') }}</option>
|
||||
<option value="individual">{{ $t('garages.type_individual') }}</option>
|
||||
<option value="corporate">{{ $t('garages.type_corporate') }}</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="selectedPackage"
|
||||
class="px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
|
||||
>
|
||||
<option value="all">{{ $t('garages.all_tiers') }}</option>
|
||||
<option
|
||||
v-for="tier in uniqueTiers"
|
||||
:key="tier"
|
||||
:value="tier"
|
||||
>
|
||||
{{ tier }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
@@ -30,8 +67,8 @@
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else>
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<!-- P0: Dynamic Stats Cards - based on real org_type data, not fake subscription tiers -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
|
||||
@@ -49,12 +86,12 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.active_garages') }}</p>
|
||||
<p class="text-2xl font-bold text-emerald-400">{{ activeCount }}</p>
|
||||
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.private_garages') }}</p>
|
||||
<p class="text-2xl font-bold text-emerald-400">{{ individualCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,54 +99,15 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.premium_garages') }}</p>
|
||||
<p class="text-2xl font-bold text-amber-400">{{ premiumCount }}</p>
|
||||
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.corporate_garages') }}</p>
|
||||
<p class="text-2xl font-bold text-amber-400">{{ corporateCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-purple-500/10 text-purple-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.enterprise_garages') }}</p>
|
||||
<p class="text-2xl font-bold text-purple-400">{{ enterpriseCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Filter Bar -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('garages.search_placeholder')"
|
||||
class="w-full pl-10 pr-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="filterTier"
|
||||
class="px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
|
||||
>
|
||||
<option value="all">{{ $t('garages.all_tiers') }}</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="premium">Premium</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
@@ -347,7 +345,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
@@ -401,7 +399,8 @@ const error = ref<string | null>(null)
|
||||
const garages = ref<Garage[]>([])
|
||||
const availableTiers = ref<Tier[]>([])
|
||||
const searchQuery = ref('')
|
||||
const filterTier = ref('all')
|
||||
const filterType = ref('all')
|
||||
const selectedPackage = ref('all')
|
||||
|
||||
// Modal state
|
||||
const showSubscriptionModal = ref(false)
|
||||
@@ -414,45 +413,62 @@ const saveError = ref<string | null>(null)
|
||||
// Notification
|
||||
const saveNotification = ref<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||
|
||||
// ── Debounced Search Timer ──────────────────────────────────────────
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* P0: Debounced search — waits 400ms after the user stops typing
|
||||
* before triggering a server-side API call. This prevents excessive
|
||||
* requests on every keystroke while keeping the UI responsive.
|
||||
*/
|
||||
function debouncedFetchGarages() {
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
}
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
fetchGarages()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
// ── Watchers ────────────────────────────────────────────────────────
|
||||
|
||||
// Watch searchQuery with debounce — only fires API call after 400ms idle
|
||||
watch(searchQuery, () => {
|
||||
debouncedFetchGarages()
|
||||
})
|
||||
|
||||
// Watch filterType — immediate API call (no debounce needed for dropdowns)
|
||||
watch(filterType, () => {
|
||||
fetchGarages()
|
||||
})
|
||||
|
||||
// Watch selectedPackage — immediate API call
|
||||
watch(selectedPackage, () => {
|
||||
fetchGarages()
|
||||
})
|
||||
|
||||
// ── Computed ───────────────────────────────────────────────────────
|
||||
|
||||
const activeCount = computed(() => garages.value.filter(g => g.is_active).length)
|
||||
const premiumCount = computed(() => {
|
||||
return garages.value.filter(g => {
|
||||
const name = g.subscription_tier_name.toLowerCase()
|
||||
return name.includes('premium') || name.includes('pro')
|
||||
}).length
|
||||
})
|
||||
const enterpriseCount = computed(() => {
|
||||
return garages.value.filter(g => {
|
||||
const name = g.subscription_tier_name.toLowerCase()
|
||||
return name.includes('enterprise') || name.includes('vip') || name.includes('corp')
|
||||
}).length
|
||||
// P0: Dynamic stats based on real org_type from the database
|
||||
const individualCount = computed(() => garages.value.filter(g => g.org_type === 'individual').length)
|
||||
const corporateCount = computed(() => garages.value.filter(g => g.org_type !== 'individual').length)
|
||||
|
||||
// P0: Extract unique subscription tier names for the filter dropdown
|
||||
const uniqueTiers = computed(() => {
|
||||
const tiers = new Set<string>()
|
||||
for (const g of garages.value) {
|
||||
if (g.subscription_tier_name) {
|
||||
tiers.add(g.subscription_tier_name)
|
||||
}
|
||||
}
|
||||
return Array.from(tiers).sort()
|
||||
})
|
||||
|
||||
const filteredGarages = computed(() => {
|
||||
return garages.value.filter(g => {
|
||||
// Search filter — search across display_name, name, full_name, and city
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
const displayName = garageDisplayName(g).toLowerCase()
|
||||
const matchesSearch =
|
||||
displayName.includes(q) ||
|
||||
g.full_name.toLowerCase().includes(q) ||
|
||||
g.name.toLowerCase().includes(q) ||
|
||||
(g.city && g.city.toLowerCase().includes(q))
|
||||
if (!matchesSearch) return false
|
||||
}
|
||||
// Tier filter
|
||||
if (filterTier.value !== 'all') {
|
||||
const name = g.subscription_tier_name.toLowerCase()
|
||||
if (filterTier.value === 'free' && !name.includes('free') && !name.includes('fallback')) return false
|
||||
if (filterTier.value === 'premium' && !name.includes('premium') && !name.includes('pro')) return false
|
||||
if (filterTier.value === 'enterprise' && !name.includes('enterprise') && !name.includes('vip') && !name.includes('corp')) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
// P0: Server-side filtered — the API handles all filtering.
|
||||
// This computed is now a simple pass-through; the real filtering
|
||||
// happens in fetchGarages() via query params.
|
||||
const filteredGarages = computed(() => garages.value)
|
||||
|
||||
// ── Auth Helper ────────────────────────────────────────────────────
|
||||
|
||||
@@ -468,8 +484,26 @@ async function fetchGarages() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// P0: Server-side search — pass all filter params to the API
|
||||
const params: Record<string, any> = { limit: 200 }
|
||||
|
||||
// Debounced search query → server-side omni-search
|
||||
if (searchQuery.value) {
|
||||
params.search_term = searchQuery.value
|
||||
}
|
||||
|
||||
// Org type filter
|
||||
if (filterType.value !== 'all') {
|
||||
params.org_type_filter = filterType.value
|
||||
}
|
||||
|
||||
// Subscription tier filter
|
||||
if (selectedPackage.value !== 'all') {
|
||||
params.tier_name_filter = selectedPackage.value
|
||||
}
|
||||
|
||||
const response = await $fetch<{ total: number; skip: number; limit: number; garages: Garage[] }>('/api/v1/admin/organizations', {
|
||||
params: { limit: 200 },
|
||||
params,
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
garages.value = response.garages
|
||||
@@ -552,9 +586,28 @@ async function saveSubscription() {
|
||||
// ── Status Toggle ──────────────────────────────────────────────────
|
||||
|
||||
async function toggleStatus(garage: Garage) {
|
||||
// This would call a PATCH endpoint to toggle is_active
|
||||
// For now, we just show a notification that this is not yet implemented
|
||||
showNotification('error', `Status toggle for "${garageDisplayName(garage)}" not yet implemented via API`)
|
||||
// Determine the new status based on current state
|
||||
const newStatus = garage.is_active ? 'inactive' : 'active'
|
||||
const actionLabel = garage.is_active ? 'deaktiválása' : 'aktiválása'
|
||||
|
||||
try {
|
||||
await $fetch(`/api/v1/admin/organizations/${garage.id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { status: newStatus },
|
||||
})
|
||||
|
||||
showNotification('success', `"${garageDisplayName(garage)}" ${actionLabel} sikeresen megtörtént.`)
|
||||
|
||||
// Refresh the list to reflect changes
|
||||
await fetchGarages()
|
||||
} catch (err: any) {
|
||||
console.error('[Garages] Failed to toggle status:', err)
|
||||
showNotification('error', err?.data?.detail || err?.message || `A garázs ${actionLabel} sikertelen.`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
54
frontend_admin/plugins/api.ts
Normal file
54
frontend_admin/plugins/api.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 🌐 Global API Plugin — 401 Unauthorized Interceptor
|
||||
*
|
||||
* This Nuxt plugin wraps the global `$fetch` to intercept 401 responses.
|
||||
* When a 401 is detected, it:
|
||||
* 1. Calls the auth store's logout() method (clears tokens)
|
||||
* 2. Redirects to /login
|
||||
*
|
||||
* This fixes the P0 bug where expired tokens did not force a logout on page reload.
|
||||
*/
|
||||
|
||||
import { defineNuxtPlugin, navigateTo, useCookie } from '#app'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
// We hook into the global $fetch via onResponse interceptor
|
||||
// Nuxt's $fetch is built on ofetch which supports interceptors
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
// Override global fetch to intercept 401 responses
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await originalFetch(input, init)
|
||||
|
||||
// Intercept 401 Unauthorized
|
||||
if (response.status === 401) {
|
||||
// Avoid intercepting login endpoint itself (would cause redirect loops)
|
||||
const url = typeof input === 'string' ? input : (input instanceof URL ? input.href : input.url)
|
||||
if (url.includes('/auth/login') || url.includes('/auth/refresh')) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Clear auth state
|
||||
try {
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
} catch {
|
||||
// Fallback: manually clear the cookie
|
||||
const tokenCookie = useCookie('access_token')
|
||||
tokenCookie.value = null
|
||||
}
|
||||
|
||||
// Redirect to login
|
||||
if (process.client) {
|
||||
// Use window.location for hard redirect to ensure full state reset
|
||||
window.location.href = '/login'
|
||||
} else {
|
||||
await navigateTo('/login')
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user