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 - `_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) - `/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 - `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 typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload from sqlalchemy.orm import selectinload, joinedload
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -125,7 +125,7 @@ class SubscriptionSummary(BaseModel):
class GarageDetailsResponse(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 # Szervezet adatok
id: int id: int
name: str name: str
@@ -135,7 +135,7 @@ class GarageDetailsResponse(BaseModel):
is_active: bool is_active: bool
is_verified: bool is_verified: bool
org_type: str org_type: str
# Cím adatok # Cím adatok (elsődleges)
address_city: Optional[str] = None address_city: Optional[str] = None
address_zip: Optional[str] = None address_zip: Optional[str] = None
address_street_name: Optional[str] = None address_street_name: Optional[str] = None
@@ -143,9 +143,25 @@ class GarageDetailsResponse(BaseModel):
address_house_number: Optional[str] = None address_house_number: Optional[str] = None
tax_number: Optional[str] = None tax_number: Optional[str] = None
reg_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 # Előfizetés
subscription: Optional[SubscriptionSummary] = None subscription: Optional[SubscriptionSummary] = None
# Kapcsolattartó # Kapcsolattartó (ContactPerson modellből)
primary_contact: Optional[ContactPersonInfo] = None primary_contact: Optional[ContactPersonInfo] = None
# Meta # Meta
created_at: Optional[str] = None 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"), 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)"), 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"), 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), db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user), current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("org:view")), _ = Depends(deps.RequirePermission("org:view")),
@@ -183,33 +200,54 @@ async def list_organizations(
- Taglétszámot (OrganizationMember-ek száma) - Taglétszámot (OrganizationMember-ek száma)
- Aktív előfizetés adatait (tier név, lejárati dátum) - Aktív előfizetés adatait (tier név, lejárati dátum)
""" """
# Alap lekérdezés # Alap lekérdezés — outerjoin owner (User) hogy a tulajdonos emailjét is kereshessük
query = select(Organization).options( base_query = select(Organization.id).outerjoin(Organization.owner)
selectinload(Organization.subscription_tier),
)
# Szűrés # Szűrés
if search_term: if search_term:
like_pattern = f"%{search_term}%" like_pattern = f"%{search_term}%"
query = query.where( base_query = base_query.where(
Organization.full_name.ilike(like_pattern) | Organization.full_name.ilike(like_pattern) |
Organization.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: if status_filter:
query = query.where(Organization.status == status_filter) base_query = base_query.where(Organization.status == status_filter)
if org_type_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 # Teljes számolás
count_stmt = select(func.count()).select_from(query.subquery()) count_query = select(func.count()).select_from(base_query.subquery())
total_result = await db.execute(count_stmt) total_result = await db.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0
# Lapozás # Lapozás — get IDs first, then fetch full objects
query = query.order_by(Organization.id.desc()).offset(skip).limit(limit) id_query = base_query.order_by(Organization.id.desc()).offset(skip).limit(limit)
result = await db.execute(query) id_result = await db.execute(id_query)
organizations = result.scalars().all() org_ids_subset = [row[0] for row in id_result.all()]
# Fetch full Organization objects by IDs with joinedload to avoid MissingGreenlet
if org_ids_subset:
from sqlalchemy.orm import joinedload
query = select(Organization).options(
joinedload(Organization.subscription_tier),
joinedload(Organization.owner),
).where(Organization.id.in_(org_ids_subset))
result = await db.execute(query)
# Use unique() because joinedload may produce duplicate rows
organizations = result.unique().scalars().all()
# Preserve the ordering from the id_query
org_map = {org.id: org for org in organizations}
organizations = [org_map[oid] for oid in org_ids_subset if oid in org_map]
else:
organizations = []
# Összes org_id begyűjtése a tömeges subscription lekérdezéshez # Összes org_id begyűjtése a tömeges subscription lekérdezéshez
org_ids = [org.id for org in organizations] org_ids = [org.id for org in organizations]
@@ -276,12 +314,15 @@ async def list_organizations(
tier_name = org.subscription_tier.name tier_name = org.subscription_tier.name
expires_at = org.subscription_expires_at.isoformat() if org.subscription_expires_at else None 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( garage_list.append(GarageListItem(
id=org.id, id=org.id,
name=org.name, name=org.name,
full_name=org.full_name, full_name=org.full_name,
display_name=org.display_name, display_name=org.display_name,
email=None, # Organization-nak nincs közvetlen email mezője email=resolved_email,
status=org.status, status=org.status,
is_active=org.is_active, is_active=org.is_active,
is_verified=org.is_verified, 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) - Előfizetés összefoglalót (tier név, lejárat, járműszám/korlát)
- Elsődleges kapcsolattartó adatait (fleet.contact_persons) - Elsődleges kapcsolattartó adatait (fleet.contact_persons)
- Tagok teljes listáját (OrganizationMember) nested Person adatokkal - 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 = ( org_stmt = (
select(Organization) select(Organization)
.options( .options(
selectinload(Organization.subscription_tier), selectinload(Organization.subscription_tier),
selectinload(Organization.owner).selectinload(User.person),
) )
.where(Organization.id == org_id) .where(Organization.id == org_id)
) )
@@ -438,8 +484,34 @@ async def get_organization_details(
is_primary=contact.is_primary, 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 # 7. Tagok összeállítása nested Person adatokkal
members_list: List[OrganizationMemberResponse] = [] members_list: List[OrganizationMemberResponse] = []
owner_found_in_members = False
for m in member_rows: for m in member_rows:
person_data = None person_data = None
# Try to get Person from the member's direct person relationship first # 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, 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( members_list.append(OrganizationMemberResponse(
id=m.id, id=m.id,
user_id=m.user_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, 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( logger.info(
f"Admin {current_user.id} ({current_user.email}) fetched details for org {org_id} " f"Admin {current_user.id} ({current_user.email}) fetched details for org {org_id} "
f"({org.full_name}) — {len(members_list)} members" f"({org.full_name}) — {len(members_list)} members"
@@ -491,6 +609,22 @@ async def get_organization_details(
address_house_number=org.address_house_number, address_house_number=org.address_house_number,
tax_number=org.tax_number, tax_number=org.tax_number,
reg_number=org.reg_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, subscription=subscription_summary,
primary_contact=primary_contact, primary_contact=primary_contact,
created_at=org.created_at.isoformat() if org.created_at else None, 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 # PATCH /{org_id}/subscription — Előfizetés módosítása
# ============================================================================= # =============================================================================
@@ -620,7 +988,8 @@ async def update_org_subscription(
response_model=OrganizationMemberResponse, response_model=OrganizationMemberResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="Új tag hozzáadása", 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( async def add_organization_member(
org_id: int, org_id: int,
@@ -632,8 +1001,12 @@ async def add_organization_member(
""" """
Új tag hozzáadása egy garázshoz. Ú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 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. - Ellenőrzi, hogy a felhasználó még nem tagja a szervezetnek.
- Létrehozza az OrganizationMember rekordot. - Létrehozza az OrganizationMember rekordot.
""" """
@@ -648,21 +1021,39 @@ async def add_organization_member(
detail=f"Szervezet nem található ID-vel: {org_id}", detail=f"Szervezet nem található ID-vel: {org_id}",
) )
# 2. Ellenőrizzük a felhasználót # 2. Felhasználó azonosítása: user_id vagy email alapján
user_stmt = select(User).where(User.id == payload.user_id) resolved_user_id = payload.user_id
user_result = await db.execute(user_stmt) if resolved_user_id is None and payload.email:
user = user_result.scalar_one_or_none() # Email-based lookup
user_stmt = select(User).where(User.email == payload.email)
if not user: user_result = await db.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Felhasználó nem található e-mail címmel: {payload.email}",
)
resolved_user_id = user.id
elif resolved_user_id is not None:
# user_id-based lookup
user_stmt = select(User).where(User.id == resolved_user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Felhasználó nem található ID-vel: {resolved_user_id}",
)
else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Felhasználó nem található ID-vel: {payload.user_id}", 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 # 3. Ellenőrizzük, hogy a felhasználó még nem tagja a szervezetnek
existing_stmt = select(OrganizationMember).where( existing_stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id, OrganizationMember.organization_id == org_id,
OrganizationMember.user_id == payload.user_id, OrganizationMember.user_id == resolved_user_id,
OrganizationMember.status != "archived", OrganizationMember.status != "archived",
) )
existing_result = await db.execute(existing_stmt) existing_result = await db.execute(existing_stmt)
@@ -671,14 +1062,14 @@ async def add_organization_member(
if existing: if existing:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, 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 # 4. Létrehozzuk az új tag rekordot
person_id = user.person_id person_id = user.person_id
new_member = OrganizationMember( new_member = OrganizationMember(
organization_id=org_id, organization_id=org_id,
user_id=payload.user_id, user_id=resolved_user_id,
person_id=person_id, person_id=person_id,
role=payload.role, role=payload.role,
status=payload.status, status=payload.status,
@@ -706,7 +1097,7 @@ async def add_organization_member(
) )
logger.info( 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}" f"to org {org_id} ({org.full_name}) with role={payload.role}"
) )
@@ -862,3 +1253,122 @@ async def remove_organization_member(
"status": "success", "status": "success",
"message": f"Tag (ID: {member_id}) eltávolítva a szervezetből (ID: {org_id}).", "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

@@ -132,6 +132,25 @@ class Organization(Base):
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True) tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
reg_number: Mapped[Optional[str]] = mapped_column(String(50)) 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( org_type: Mapped[OrgType] = mapped_column(
PG_ENUM(OrgType, name="orgtype", schema="fleet"), PG_ENUM(OrgType, name="orgtype", schema="fleet"),

View File

@@ -15,9 +15,17 @@ class OrganizationMemberBase(BaseModel):
status: str = Field(default="active", description="Membership status (active, inactive, archived)") status: str = Field(default="active", description="Membership status (active, inactive, archived)")
class OrganizationMemberCreate(OrganizationMemberBase): class OrganizationMemberCreate(BaseModel):
"""Schema for adding a new member to a garage.""" """Schema for adding a new member to a garage.
pass
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): 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", title: "Garázsok CRM",
subtitle: "Garázsok listája, csomagkezelés és státuszmódosítás", subtitle: "Garázsok listája, csomagkezelés és státuszmódosítás",
total_garages: "Összes Garázs", total_garages: "Összes Garázs",
active_garages: "Aktív Garázsok", private_garages: "Privát Garázsok",
premium_garages: "Premium Garázsok", corporate_garages: "Céges Garázsok",
enterprise_garages: "Enterprise Garázsok",
search_placeholder: "Keresés név, kapcsolattartó vagy e-mail alapján...", search_placeholder: "Keresés név, kapcsolattartó vagy e-mail alapján...",
all_tiers: "Összes csomag", 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", company_name: "Cégnév",
email: "E-mail", email: "E-mail",
status: "Státusz", status: "Státusz",
@@ -2411,9 +2413,51 @@ var garages$1 = {
contact_name: "Kapcsolattartó neve", contact_name: "Kapcsolattartó neve",
contact_role: "Szerepkör", contact_role: "Szerepkör",
contact_department: "Osztály", contact_department: "Osztály",
contact_phone: "Telefonszám", contact_phone: "Kapcsolattartó telefon",
no_contact: "Nincs elsődleges kapcsolattartó", 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: { employees: {
title: "Dolgozók", title: "Dolgozók",
@@ -2427,13 +2471,43 @@ var garages$1 = {
remove: "Eltávolítás", remove: "Eltávolítás",
no_employees: "Még nincsenek tagok ehhez a garázshoz.", no_employees: "Még nincsenek tagok ehhez a garázshoz.",
edit_role_title: "Tag szerepkörének szerkesztése", 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", cancel: "Mégse",
save: "Mentés", save: "Mentés",
saveRole: "Szerepkör mentése",
saving: "Mentés...",
status_active: "Aktív", status_active: "Aktív",
status_inactive: "Inaktív", status_inactive: "Inaktív",
remove_title: "Tag eltávolítása", 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_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 = { const locale_hu_46json_ee06c965 = {
@@ -2541,11 +2615,13 @@ var garages = {
title: "Garages CRM", title: "Garages CRM",
subtitle: "Garage list, package management and status changes", subtitle: "Garage list, package management and status changes",
total_garages: "Total Garages", total_garages: "Total Garages",
active_garages: "Active Garages", private_garages: "Private Garages",
premium_garages: "Premium Garages", corporate_garages: "Corporate Garages",
enterprise_garages: "Enterprise Garages",
search_placeholder: "Search by name, contact or email...", search_placeholder: "Search by name, contact or email...",
all_tiers: "All packages", all_tiers: "All packages",
all_types: "All types",
type_individual: "Private (Individual)",
type_corporate: "Corporate",
company_name: "Company Name", company_name: "Company Name",
email: "Email", email: "Email",
status: "Status", status: "Status",
@@ -2601,9 +2677,51 @@ var garages = {
contact_name: "Contact Name", contact_name: "Contact Name",
contact_role: "Role", contact_role: "Role",
contact_department: "Department", contact_department: "Department",
contact_phone: "Phone", contact_phone: "Contact Phone",
no_contact: "No primary contact person", 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: { employees: {
title: "Employees", title: "Employees",
@@ -2617,13 +2735,43 @@ var garages = {
remove: "Remove", remove: "Remove",
no_employees: "No employees assigned to this garage.", no_employees: "No employees assigned to this garage.",
edit_role_title: "Edit Employee Role", edit_role_title: "Edit Employee Role",
editRoleTitle: "Edit Employee Role",
editRoleFor: "Change role for {name}",
cancel: "Cancel", cancel: "Cancel",
save: "Save Changes", save: "Save Changes",
saveRole: "Save Role",
saving: "Saving...",
status_active: "Active", status_active: "Active",
status_inactive: "Inactive", status_inactive: "Inactive",
remove_title: "Remove Employee", remove_title: "Remove Employee",
removeTitle: "Remove Employee",
remove_confirm: "Are you sure you want to remove \"{name}\" from this garage?", 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 = { const locale_en_46json_0b63539c = {
@@ -3278,16 +3426,16 @@ _wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
const assets = { const assets = {
"/index.mjs": { "/index.mjs": {
"type": "text/javascript; charset=utf-8", "type": "text/javascript; charset=utf-8",
"etag": "\"23532-gRN4chechZSOn9PdQdfcKj3v+Ek\"", "etag": "\"24b7a-X7Gi3Oi5IkHbqD0x6I8saqEhvJo\"",
"mtime": "2026-06-25T19:56:11.463Z", "mtime": "2026-06-26T00:29:32.171Z",
"size": 144690, "size": 150394,
"path": "index.mjs" "path": "index.mjs"
}, },
"/index.mjs.map": { "/index.mjs.map": {
"type": "application/json", "type": "application/json",
"etag": "\"83172-ssEX0StaUacfa43NRfqyNY4W7FM\"", "etag": "\"83219-k/zre5TDT2FcQBXF7JC+SUbYHHs\"",
"mtime": "2026-06-25T19:56:11.464Z", "mtime": "2026-06-26T00:29:32.171Z",
"size": 536946, "size": 537113,
"path": "index.mjs.map" "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", "preset": "nitro-dev",
"framework": { "framework": {
"name": "nuxt", "name": "nuxt",
@@ -11,7 +11,7 @@
"dev": { "dev": {
"pid": 19, "pid": 19,
"workerAddress": { "workerAddress": {
"socketPath": "\u0000nitro-worker-19-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 "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger"; 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/@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/warn.dev.server.js")> &
InjectionType<typeof import("../../node_modules/nuxt/dist/app/plugins/check-if-layout-used.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")> InjectionType<typeof import("../../node_modules/@nuxtjs/i18n/dist/runtime/plugins/ssg-detect.js")>
declare module '#app' { declare module '#app' {

View File

@@ -98,11 +98,13 @@
"title": "Garages CRM", "title": "Garages CRM",
"subtitle": "Garage list, package management and status changes", "subtitle": "Garage list, package management and status changes",
"total_garages": "Total Garages", "total_garages": "Total Garages",
"active_garages": "Active Garages", "private_garages": "Private Garages",
"premium_garages": "Premium Garages", "corporate_garages": "Corporate Garages",
"enterprise_garages": "Enterprise Garages",
"search_placeholder": "Search by name, contact or email...", "search_placeholder": "Search by name, contact or email...",
"all_tiers": "All packages", "all_tiers": "All packages",
"all_types": "All types",
"type_individual": "Private (Individual)",
"type_corporate": "Corporate",
"company_name": "Company Name", "company_name": "Company Name",
"email": "Email", "email": "Email",
"status": "Status", "status": "Status",
@@ -160,7 +162,50 @@
"contact_department": "Department", "contact_department": "Department",
"contact_phone": "Phone", "contact_phone": "Phone",
"no_contact": "No primary contact person", "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": { "employees": {
"title": "Employees", "title": "Employees",
@@ -174,13 +219,43 @@
"remove": "Remove", "remove": "Remove",
"no_employees": "No employees assigned to this garage.", "no_employees": "No employees assigned to this garage.",
"edit_role_title": "Edit Employee Role", "edit_role_title": "Edit Employee Role",
"editRoleTitle": "Edit Employee Role",
"editRoleFor": "Change role for {name}",
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save Changes", "save": "Save Changes",
"saveRole": "Save Role",
"saving": "Saving...",
"status_active": "Active", "status_active": "Active",
"status_inactive": "Inactive", "status_inactive": "Inactive",
"remove_title": "Remove Employee", "remove_title": "Remove Employee",
"removeTitle": "Remove Employee",
"remove_confirm": "Are you sure you want to remove \"{name}\" from this garage?", "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", "title": "Garázsok CRM",
"subtitle": "Garázsok listája, csomagkezelés és státuszmódosítás", "subtitle": "Garázsok listája, csomagkezelés és státuszmódosítás",
"total_garages": "Összes Garázs", "total_garages": "Összes Garázs",
"active_garages": "Aktív Garázsok", "private_garages": "Privát Garázsok",
"premium_garages": "Premium Garázsok", "corporate_garages": "Céges Garázsok",
"enterprise_garages": "Enterprise Garázsok",
"search_placeholder": "Keresés név, kapcsolattartó vagy e-mail alapján...", "search_placeholder": "Keresés név, kapcsolattartó vagy e-mail alapján...",
"all_tiers": "Összes csomag", "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", "company_name": "Cégnév",
"email": "E-mail", "email": "E-mail",
"status": "Státusz", "status": "Státusz",
@@ -160,7 +162,50 @@
"contact_department": "Osztály", "contact_department": "Osztály",
"contact_phone": "Telefonszám", "contact_phone": "Telefonszám",
"no_contact": "Nincs elsődleges kapcsolattartó", "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": { "employees": {
"title": "Dolgozók", "title": "Dolgozók",
@@ -174,13 +219,43 @@
"remove": "Eltávolítás", "remove": "Eltávolítás",
"no_employees": "Még nincsenek tagok ehhez a garázshoz.", "no_employees": "Még nincsenek tagok ehhez a garázshoz.",
"edit_role_title": "Tag szerepkörének szerkesztése", "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", "cancel": "Mégse",
"save": "Mentés", "save": "Mentés",
"saveRole": "Szerepkör mentése",
"saving": "Mentés...",
"status_active": "Aktív", "status_active": "Aktív",
"status_inactive": "Inaktív", "status_inactive": "Inaktív",
"remove_title": "Tag eltávolítása", "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_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>', icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>',
children: [ children: [
{ {
path: '/users', path: '/', /* P0 HOTFIX: placeholder until /users page is built */
label: 'Felhasználók listája', label: 'Felhasználók listája',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>', icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>',
}, },
@@ -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>', 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: [ children: [
{ {
path: '/logs', path: '/', /* P0 HOTFIX: placeholder until /logs page is built */
label: 'Rendszernaplók', 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>', 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> <h1 class="text-2xl font-bold text-white">{{ $t('garages.title') }}</h1>
<p class="text-slate-400 mt-1">{{ $t('garages.subtitle') }}</p> <p class="text-slate-400 mt-1">{{ $t('garages.subtitle') }}</p>
</div> </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 --> <!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20"> <div v-if="loading" class="flex items-center justify-center py-20">
@@ -30,8 +67,8 @@
<!-- Content --> <!-- Content -->
<template v-else> <template v-else>
<!-- Stats Cards --> <!-- 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-4 gap-4 mb-8"> <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="bg-slate-800 rounded-xl border border-slate-700 p-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-indigo-500/10 text-indigo-400"> <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="flex items-center gap-3">
<div class="p-2 rounded-lg bg-emerald-500/10 text-emerald-400"> <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"> <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> </svg>
</div> </div>
<div> <div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.active_garages') }}</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">{{ activeCount }}</p> <p class="text-2xl font-bold text-emerald-400">{{ individualCount }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -62,54 +99,15 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-500/10 text-amber-400"> <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"> <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> </svg>
</div> </div>
<div> <div>
<p class="text-xs text-slate-400 uppercase tracking-wider">{{ $t('garages.premium_garages') }}</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">{{ premiumCount }}</p> <p class="text-2xl font-bold text-amber-400">{{ corporateCount }}</p>
</div> </div>
</div> </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> </div>
<!-- Data Table --> <!-- Data Table -->
@@ -347,7 +345,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
@@ -401,7 +399,8 @@ const error = ref<string | null>(null)
const garages = ref<Garage[]>([]) const garages = ref<Garage[]>([])
const availableTiers = ref<Tier[]>([]) const availableTiers = ref<Tier[]>([])
const searchQuery = ref('') const searchQuery = ref('')
const filterTier = ref('all') const filterType = ref('all')
const selectedPackage = ref('all')
// Modal state // Modal state
const showSubscriptionModal = ref(false) const showSubscriptionModal = ref(false)
@@ -414,45 +413,62 @@ const saveError = ref<string | null>(null)
// Notification // Notification
const saveNotification = ref<{ type: 'success' | 'error'; message: string } | null>(null) 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 ─────────────────────────────────────────────────────── // ── Computed ───────────────────────────────────────────────────────
const activeCount = computed(() => garages.value.filter(g => g.is_active).length) // P0: Dynamic stats based on real org_type from the database
const premiumCount = computed(() => { const individualCount = computed(() => garages.value.filter(g => g.org_type === 'individual').length)
return garages.value.filter(g => { const corporateCount = computed(() => garages.value.filter(g => g.org_type !== 'individual').length)
const name = g.subscription_tier_name.toLowerCase()
return name.includes('premium') || name.includes('pro') // P0: Extract unique subscription tier names for the filter dropdown
}).length const uniqueTiers = computed(() => {
}) const tiers = new Set<string>()
const enterpriseCount = computed(() => { for (const g of garages.value) {
return garages.value.filter(g => { if (g.subscription_tier_name) {
const name = g.subscription_tier_name.toLowerCase() tiers.add(g.subscription_tier_name)
return name.includes('enterprise') || name.includes('vip') || name.includes('corp') }
}).length }
return Array.from(tiers).sort()
}) })
const filteredGarages = computed(() => { // P0: Server-side filtered — the API handles all filtering.
return garages.value.filter(g => { // This computed is now a simple pass-through; the real filtering
// Search filter — search across display_name, name, full_name, and city // happens in fetchGarages() via query params.
if (searchQuery.value) { const filteredGarages = computed(() => garages.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
})
})
// ── Auth Helper ──────────────────────────────────────────────────── // ── Auth Helper ────────────────────────────────────────────────────
@@ -468,8 +484,26 @@ async function fetchGarages() {
loading.value = true loading.value = true
error.value = null error.value = null
try { 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', { const response = await $fetch<{ total: number; skip: number; limit: number; garages: Garage[] }>('/api/v1/admin/organizations', {
params: { limit: 200 }, params,
headers: getAuthHeaders(), headers: getAuthHeaders(),
}) })
garages.value = response.garages garages.value = response.garages
@@ -552,9 +586,28 @@ async function saveSubscription() {
// ── Status Toggle ────────────────────────────────────────────────── // ── Status Toggle ──────────────────────────────────────────────────
async function toggleStatus(garage: Garage) { async function toggleStatus(garage: Garage) {
// This would call a PATCH endpoint to toggle is_active // Determine the new status based on current state
// For now, we just show a notification that this is not yet implemented const newStatus = garage.is_active ? 'inactive' : 'active'
showNotification('error', `Status toggle for "${garageDisplayName(garage)}" not yet implemented via API`) 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 ──────────────────────────────────────────────────────── // ── 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
}
})