backend admin flotta bekötése

This commit is contained in:
Roo
2026-06-26 01:21:15 +00:00
parent 36109fc722
commit c75c7b2270
17 changed files with 3014 additions and 532 deletions

View File

@@ -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)

View File

@@ -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)
# 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)
organizations = result.scalars().all()
# 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)
# 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ó ID-vel: {payload.user_id}",
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_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,
)

View File

@@ -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

View File

@@ -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):

View 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())

View 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*

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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";

View File

@@ -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' {

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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

View File

@@ -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 ────────────────────────────────────────────────────────

View 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
}
})